Roles¶
Each role is a self-contained unit: everything needed for one job (install Docker, configure backups) in one folder, with tasks/main.yml (what to do, in order), handlers/main.yml (actions triggered by changes) and templates/ (config files with variables).
The five base roles below are explained with short excerpts. The complete files for each one live in the worked example on Base Configuration, so there's one copy to keep current. The two stack roles at the bottom are only shown here.
Role: base¶
Installs packages, hardens SSH, sets timezone, creates directory structure.
The key pattern is a template that notifies a handler, so sshd only restarts when its config actually changed, and validate refuses a broken config before it's written:
- name: Configure SSH daemon
ansible.builtin.template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: "0644"
validate: "sshd -t -f %s"
notify: restart sshd
Full files: roles/base/tasks/main.yml · handlers/main.yml · templates/sshd_config.j2. The tutorial walks through every task of this role.
Role: docker¶
Installs Docker CE from the official repo and configures the daemon.
The repo line uses the distribution_release fact, and the GPG key task uses creates: so it only runs once:
- name: Add Docker GPG key
ansible.builtin.shell: |
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
args:
creates: /etc/apt/keyrings/docker.gpg
- name: Add Docker apt repository
ansible.builtin.copy:
content: |
deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian {{ ansible_facts['distribution_release'] }} stable
dest: /etc/apt/sources.list.d/docker.list
mode: "0644"
Full files: roles/docker/tasks/main.yml · handlers/main.yml · templates/daemon.json.j2 (log rotation).
Ref: https://docs.docker.com/engine/install/debian/
Role: borgmatic¶
Installs borgmatic and configures backups to the NAS.
The repo is only initialised once (a stat check first), and the tasks are skipped on the NAS itself through skip_nas_mount:
- name: Initialize borg repo
ansible.builtin.command: >
borg init --encryption=repokey
{{ backup_mount }}/{{ inventory_hostname_short }}
environment:
BORG_PASSPHRASE: "{{ vault_borg_passphrase }}"
no_log: true
when:
- skip_nas_mount is not defined or not skip_nas_mount
- not borg_repo_check.stat.exists
Full files: roles/borgmatic/tasks/main.yml · templates/borgmatic-config.yml.j2 · templates/borgmatic.cron.j2.
Role: tailscale¶
Installs Tailscale but does not start it.
The repo is picked from the host's own Debian release instead of a hard-coded codename:
- name: Add Tailscale apt repository
ansible.builtin.copy:
content: |
deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/debian {{ ansible_facts['distribution_release'] }} main
dest: /etc/apt/sources.list.d/tailscale.list
mode: "0644"
Full file: roles/tailscale/tasks/main.yml.
Role: mounts¶
Mounts the NAS backup share via CIFS.
The credentials file is a template filled from the vault, written 0600:
Full files: roles/mounts/tasks/main.yml · templates/nas-creds.j2.
Role: stacks_infra¶
Deploys Docker Compose stacks to server1 (infra node).
roles/stacks_infra/tasks/main.yml
---
# Each stack gets its own directory and compose file.
# Only a few key stacks shown here as examples.
# Follow the same pattern for each additional stack.
# --- NPM ---
- name: Create NPM directory
ansible.builtin.file:
path: "{{ docker_compose_dir }}/npm"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
- name: Create NPM appdata directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
loop:
- "{{ appdata_dir }}/npm/data"
- "{{ appdata_dir }}/npm/letsencrypt"
- name: Deploy NPM compose
ansible.builtin.template:
src: npm-compose.yml.j2
dest: "{{ docker_compose_dir }}/npm/docker-compose.yml"
owner: "{{ main_user }}"
group: "{{ main_user }}"
register: npm_compose
- name: Start NPM stack
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ docker_compose_dir }}/npm"
when: npm_compose.changed
become_user: "{{ main_user }}"
# --- Monitoring (Beszel Hub, Uptime Kuma, AutoKuma, Dozzle) ---
- name: Create monitoring directory
ansible.builtin.file:
path: "{{ docker_compose_dir }}/monitoring"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
- name: Create monitoring appdata directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
loop:
- "{{ appdata_dir }}/beszel/hub-data"
- "{{ appdata_dir }}/uptime-kuma"
- "{{ appdata_dir }}/autokuma"
- name: Deploy monitoring compose
ansible.builtin.template:
src: monitoring-compose.yml.j2
dest: "{{ docker_compose_dir }}/monitoring/docker-compose.yml"
owner: "{{ main_user }}"
group: "{{ main_user }}"
register: monitoring_compose
- name: Start monitoring stack
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ docker_compose_dir }}/monitoring"
when: monitoring_compose.changed
become_user: "{{ main_user }}"
# --- Vaultwarden ---
- name: Create Vaultwarden directory
ansible.builtin.file:
path: "{{ docker_compose_dir }}/vaultwarden"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
- name: Create Vaultwarden appdata directory
ansible.builtin.file:
path: "{{ appdata_dir }}/vaultwarden/data"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
- name: Deploy Vaultwarden compose
ansible.builtin.template:
src: vaultwarden-compose.yml.j2
dest: "{{ docker_compose_dir }}/vaultwarden/docker-compose.yml"
owner: "{{ main_user }}"
group: "{{ main_user }}"
register: vaultwarden_compose
- name: Start Vaultwarden stack
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ docker_compose_dir }}/vaultwarden"
when: vaultwarden_compose.changed
become_user: "{{ main_user }}"
# Repeat the same pattern for: cloudflared, homeassistant, homepage, code-server
# The structure is identical: create dir, create appdata, template compose, start.
Info
Every additional stack follows the exact same 4-step pattern:
-
Create compose directory
-
Create appdata directories
-
Template the compose file
-
Start the stack if changed
Role: stacks_apps¶
Same pattern for server2 (appserver) stacks. Only showing a couple as examples since the pattern is identical.
roles/stacks_apps/tasks/main.yml
---
# --- Paperless-ngx ---
- name: Create Paperless directory
ansible.builtin.file:
path: "{{ docker_compose_dir }}/paperless"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
- name: Create Paperless appdata directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
loop:
- "{{ appdata_dir }}/paperless/data"
- "{{ appdata_dir }}/paperless/media"
- "{{ appdata_dir }}/paperless/export"
- "{{ appdata_dir }}/paperless/consume"
- "{{ appdata_dir }}/paperless/pgdata"
- name: Deploy Paperless compose
ansible.builtin.template:
src: paperless-compose.yml.j2
dest: "{{ docker_compose_dir }}/paperless/docker-compose.yml"
owner: "{{ main_user }}"
group: "{{ main_user }}"
register: paperless_compose
- name: Start Paperless stack
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ docker_compose_dir }}/paperless"
when: paperless_compose.changed
become_user: "{{ main_user }}"
# --- Forgejo ---
- name: Create Forgejo directory
ansible.builtin.file:
path: "{{ docker_compose_dir }}/forgejo"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
- name: Create Forgejo appdata directory
ansible.builtin.file:
path: "{{ appdata_dir }}/forgejo/data"
state: directory
owner: "{{ main_user }}"
group: "{{ main_user }}"
- name: Deploy Forgejo compose
ansible.builtin.template:
src: forgejo-compose.yml.j2
dest: "{{ docker_compose_dir }}/forgejo/docker-compose.yml"
owner: "{{ main_user }}"
group: "{{ main_user }}"
register: forgejo_compose
- name: Start Forgejo stack
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ docker_compose_dir }}/forgejo"
when: forgejo_compose.changed
become_user: "{{ main_user }}"
# Repeat for: affine, vikunja, trilium, utilities, omada, socket-proxy