Skip to content

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:

# Managed by Ansible
username={{ nas_samba_user }}
password={{ vault_nas_samba_password }}

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:

  1. Create compose directory

  2. Create appdata directories

  3. Template the compose file

  4. 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