Skip to content

Ansible Base Provisioning -- Homelab

Complete base provisioning of a new Debian host using Ansible. Covers system setup, Docker, Tailscale, NAS backup mounts, and automated backups with Borgmatic.

This is the one complete worked example for the section: every file of the base setup (ansible.cfg, inventory, group_vars/all.yml, vault, every role and playbooks/base.yml) lives here in full. The concept pages (Configuration Files, Inventory, Variables, Vault, Roles) explain the pieces and link back to the matching section below.

Ref: https://docs.ansible.com/projects/ansible/latest/getting_started/index.html

Prerequisites

  • Ansible installed on your control node (workstation/laptop)
  • Dedicated Ansible SSH key
  • Target host has Debian 13 (Trixie) installed
  • Target host has your user account created during OS install
  • Target host is reachable via SSH from the control node

Pre-Ansible Setup on Target Host

SSH into the new host and install sudo (fresh Debian minimal doesn't include it):

ssh youruser@hostname
su -
apt update && apt install -y sudo
echo 'youruser ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/youruser
chmod 440 /etc/sudoers.d/youruser
exit
exit

Generate and copy the Ansible SSH key from the control node:

ssh-keygen -t ed25519 -C "ansible" -f ~/.ssh/ansible -N ""
ssh-copy-id -i ~/.ssh/ansible.pub youruser@hostname

Project Structure

~/ansible/
  ansible.cfg
  .vault_pass
  inventory/
    hosts.ini
    group_vars/
      all.yml
      vault.yml          # encrypted with ansible-vault
  playbooks/
    base.yml
  roles/
    base/
      tasks/main.yml
      handlers/main.yml
      templates/sshd_config.j2
    docker/
      tasks/main.yml
      handlers/main.yml
      templates/daemon.json.j2
    tailscale/
      tasks/main.yml
    mounts/
      tasks/main.yml
      templates/nas-creds.j2
    borgmatic/
      tasks/main.yml
      templates/
        borgmatic-config.yml.j2
        borgmatic.cron.j2

Create the skeleton:

mkdir -p ~/ansible
cd ~/ansible
mkdir -p inventory/group_vars inventory/host_vars
mkdir -p playbooks
mkdir -p roles/base/{tasks,handlers,templates}
mkdir -p roles/docker/{tasks,handlers,templates}
mkdir -p roles/tailscale/tasks
mkdir -p roles/mounts/{tasks,templates}
mkdir -p roles/borgmatic/{tasks,templates}

Configuration Files

ansible.cfg

[defaults]
inventory = inventory/hosts.ini
remote_user = youruser
private_key_file = ~/.ssh/ansible
roles_path = roles
host_key_checking = False
retry_files_enabled = False
callback_result_format = yaml
vault_password_file = .vault_pass

[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False

Settings breakdown:

  • inventory -- Default inventory file so you don't pass -i every time.
  • remote_user -- SSH user on all managed nodes.
  • private_key_file -- The Ansible SSH key.
  • roles_path -- Where to find roles.
  • host_key_checking = False -- Don't prompt on first SSH. Safe on a local network.
  • retry_files_enabled = False -- Don't create .retry files on failure.
  • callback_result_format = yaml -- More readable output format (replaces the removed stdout_callback = yaml plugin).
  • become = True -- Use sudo by default.
  • become_ask_pass = False -- Assumes your user has passwordless sudo (set up in Pre-Ansible Setup).
  • vault_password_file -- Points to the file containing your vault decryption password.

More on each setting: Configuration Files.

inventory/hosts.ini

Organize hosts into groups by role. Adjust hostnames to match your environment.

[infra]
server1.example.com

[apps]
server2.example.com

[standby]
server3.example.com

[nas]
nas.example.com

# Parent group containing all managed nodes
[homelab:children]
infra
apps
standby

When you target homelab, Ansible hits all three groups. When you target infra, only the infra hosts.

Ref: https://docs.ansible.com/projects/ansible/latest/inventory_guide/intro_inventory.html

inventory/group_vars/all.yml

Variables shared across all hosts. Adjust values to match your setup.

---
# User account on all managed nodes
main_user: youruser

# Timezone and locale
timezone: <TZ>
locale: en_US.UTF-8

# DNS server for Docker containers (your gateway/DNS server IP)
docker_dns: "192.0.2.1"

# Packages to install on all hosts
base_packages:
  - sudo
  - curl
  - wget
  - vim
  - nano
  - tmux
  - git
  - htop
  - dnsutils
  - unzip
  - tar
  - rsync
  - tree
  - ncdu
  - jq
  - lsof
  - iotop
  - sysstat
  - python3-pip
  - ca-certificates
  - gnupg
  - lsb-release
  - borgbackup
  - cifs-utils

# Directory structure
docker_compose_dir: /opt/docker
appdata_dir: /mnt/appdata
backup_mount: /mnt/backups

# Borgmatic backup config
borg_backup_targets:
  - /opt/docker
  - /mnt/appdata
  - /etc
borg_excludes:
  - "*.log"
  - "cache/"
  - "Cache/"
  - ".cache/"
  - "__pycache__/"
  - "node_modules/"

# NAS/backup server connection
nas_backup_host: "nas.example.com"
nas_backup_share: "backups"
nas_creds_file: /etc/samba/nas-creds
nas_samba_user: youruser
# The samba password and borg passphrase live in the vault (see below)

Which file wins when the same variable is set in several places: Variables and Facts & Variable Precedence.

Ansible Vault -- Storing Secrets

Passwords and secrets go in an encrypted vault file, never in plain text.

# Create a vault password file (stores the encryption password)
nano ~/ansible/.vault_pass
# Type a password, save
chmod 600 ~/ansible/.vault_pass

# Create the encrypted secrets file
ansible-vault create inventory/group_vars/vault.yml

Put your secrets in the vault file:

---
vault_nas_samba_password: "your-nas-password"
vault_borg_passphrase: "generate-a-long-random-string"

The vault file is loaded explicitly in the playbook via vars_files.

Editing, viewing, rekeying and single-value encryption: see Vault.

Ref: https://docs.ansible.com/projects/ansible/latest/vault_guide/index.html

Roles

base

Installs packages, hardens SSH, sets timezone, creates directory structure.

roles/base/tasks/main.yml

---
- name: Set timezone
  community.general.timezone:
    name: "{{ timezone }}"

- name: Set locale
  community.general.locale_gen:
    name: "{{ locale }}"
    state: present

- name: Update apt cache
  ansible.builtin.apt:
    update_cache: yes
    cache_valid_time: 3600

- name: Install base packages
  ansible.builtin.apt:
    name: "{{ base_packages }}"
    state: present

- name: Create directory structure
  ansible.builtin.file:
    path: "{{ item }}"
    state: directory
    owner: "{{ main_user }}"
    group: "{{ main_user }}"
    mode: "0755"
  loop:
    - "{{ docker_compose_dir }}"
    - "{{ appdata_dir }}"
    - "{{ backup_mount }}"

- 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

- name: Ensure passwordless sudo for main user
  ansible.builtin.copy:
    content: "{{ main_user }} ALL=(ALL) NOPASSWD:ALL\n"
    dest: "/etc/sudoers.d/{{ main_user }}"
    owner: root
    group: root
    mode: "0440"
    validate: "visudo -cf %s"

roles/base/handlers/main.yml

---
- name: restart sshd
  ansible.builtin.service:
    name: sshd
    state: restarted

roles/base/templates/sshd_config.j2

# Managed by Ansible
Port 22
AddressFamily any
ListenAddress 0.0.0.0
ListenAddress ::

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys

ChallengeResponseAuthentication no
UsePAM yes

X11Forwarding no
PrintMotd no

AcceptEnv LANG LC_*

Subsystem sftp /usr/lib/openssh/sftp-server

docker

Installs Docker CE from the official repository and configures log rotation.

Ref: https://docs.docker.com/engine/install/debian/

roles/docker/tasks/main.yml

---
- name: Remove old Docker packages
  ansible.builtin.apt:
    name:
      - docker
      - docker-engine
      - docker.io
      - containerd
      - runc
    state: absent

- name: Install Docker prerequisites
  ansible.builtin.apt:
    name:
      - ca-certificates
      - curl
      - gnupg
    state: present

- name: Create keyrings directory
  ansible.builtin.file:
    path: /etc/apt/keyrings
    state: directory
    mode: "0755"

- 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"

- name: Update apt cache after adding Docker repo
  ansible.builtin.apt:
    update_cache: yes

- name: Install Docker CE
  ansible.builtin.apt:
    name:
      - docker-ce
      - docker-ce-cli
      - containerd.io
      - docker-compose-plugin
    state: present

- name: Add user to docker group
  ansible.builtin.user:
    name: "{{ main_user }}"
    groups: docker
    append: yes

- name: Configure Docker daemon
  ansible.builtin.template:
    src: daemon.json.j2
    dest: /etc/docker/daemon.json
    owner: root
    group: root
    mode: "0644"
  notify: restart docker

- name: Ensure Docker is running and enabled
  ansible.builtin.service:
    name: docker
    state: started
    enabled: yes

roles/docker/handlers/main.yml

---
- name: restart docker
  ansible.builtin.service:
    name: docker
    state: restarted

roles/docker/templates/daemon.json.j2

Ref: https://docs.docker.com/engine/logging/drivers/json-file/

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

tailscale

Installs Tailscale, leaves it stopped. Available as an emergency backdoor if the router is down.

Ref: https://pkgs.tailscale.com/stable/#debian-trixie

The release codename comes from the distribution_release fact (trixie on Debian 13), so the same role keeps working on the next Debian release once Tailscale publishes a repo for it.

roles/tailscale/tasks/main.yml

---
- name: Add Tailscale GPG key
  ansible.builtin.shell: |
    curl -fsSL https://pkgs.tailscale.com/stable/debian/{{ ansible_facts['distribution_release'] }}.noarmor.gpg \
      -o /usr/share/keyrings/tailscale-archive-keyring.gpg
  args:
    creates: /usr/share/keyrings/tailscale-archive-keyring.gpg

- 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"
  register: tailscale_repo

- name: Update apt cache
  ansible.builtin.apt:
    update_cache: yes
  when: tailscale_repo.changed

- name: Install Tailscale
  ansible.builtin.apt:
    name: tailscale
    state: present

- name: Ensure Tailscale is stopped and disabled
  ansible.builtin.service:
    name: tailscaled
    state: stopped
    enabled: no

mounts

Mounts a NAS backup share via CIFS. Skip this role if you don't have a NAS. Every task is skipped on a host that sets skip_nas_mount: true in its host_vars (the NAS itself, see Variables).

roles/mounts/tasks/main.yml

---
- name: Create samba credentials directory
  ansible.builtin.file:
    path: /etc/samba
    state: directory
    owner: root
    group: root
    mode: "0700"
  when: skip_nas_mount is not defined or not skip_nas_mount

- name: Template NAS credentials file
  ansible.builtin.template:
    src: nas-creds.j2
    dest: "{{ nas_creds_file }}"
    owner: root
    group: root
    mode: "0600"
  no_log: true
  when: skip_nas_mount is not defined or not skip_nas_mount

- name: Mount NAS backup share
  ansible.posix.mount:
    path: "{{ backup_mount }}"
    src: "//{{ nas_backup_host }}/{{ nas_backup_share }}"
    fstype: cifs
    opts: "credentials={{ nas_creds_file }},uid=1000,gid=1000,iocharset=utf8"
    state: mounted
  when: skip_nas_mount is not defined or not skip_nas_mount

roles/mounts/templates/nas-creds.j2

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

borgmatic

Installs borgmatic, configures backups, initializes the Borg repository.

borgmatic's upstream docs recommend uv tool install borgmatic, which puts borgmatic in its own virtual environment instead of the system Python (the older pip install --break-system-packages borgmatic approach). Debian 13 doesn't package uv, so the role uses uv's official installer; as root both land in /root/.local/bin.

Ref: https://torsion.org/borgmatic/how-to/install-borgmatic/ ยท https://docs.astral.sh/uv/getting-started/installation/

roles/borgmatic/tasks/main.yml

---
- name: Install uv
  ansible.builtin.shell: curl -LsSf https://astral.sh/uv/install.sh | sh
  args:
    creates: /root/.local/bin/uv

- name: Install borgmatic with uv
  ansible.builtin.command: /root/.local/bin/uv tool install borgmatic
  args:
    creates: /root/.local/bin/borgmatic

- name: Create borgmatic config directory
  ansible.builtin.file:
    path: /etc/borgmatic
    state: directory
    owner: root
    group: root
    mode: "0700"

- name: Template borgmatic config
  ansible.builtin.template:
    src: borgmatic-config.yml.j2
    dest: /etc/borgmatic/config.yaml
    owner: root
    group: root
    mode: "0600"

- name: Template borgmatic cron job
  ansible.builtin.template:
    src: borgmatic.cron.j2
    dest: /etc/cron.d/borgmatic
    owner: root
    group: root
    mode: "0644"

- name: Check if borg repo is initialized
  ansible.builtin.stat:
    path: "{{ backup_mount }}/{{ inventory_hostname_short }}/README"
  register: borg_repo_check
  when: skip_nas_mount is not defined or not 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

roles/borgmatic/templates/borgmatic-config.yml.j2

# Managed by Ansible
source_directories:
{% for dir in borg_backup_targets %}
  - {{ dir }}
{% endfor %}

repositories:
  - path: {{ backup_mount }}/{{ inventory_hostname_short }}
    label: nas

exclude_patterns:
{% for pattern in borg_excludes %}
  - {{ pattern }}
{% endfor %}

encryption_passphrase: "{{ vault_borg_passphrase }}"

retention:
  keep_daily: 7
  keep_weekly: 4
  keep_monthly: 6

consistency:
  checks:
    - repository
    - archives
  check_last: 3

Keep a copy of the passphrase and key

With repokey the encryption key is stored inside the repository, protected by the passphrase. Without the passphrase the backups can't be read, so keep it in your password manager as well as the vault, and export the key (borg key export) somewhere off the NAS.

roles/borgmatic/templates/borgmatic.cron.j2

# Managed by Ansible
0 3 * * * root PATH=$PATH:/usr/bin:/usr/local/bin:/root/.local/bin borgmatic --verbosity -1 --syslog-verbosity 1 --list --stats

Playbook

playbooks/base.yml

---
- name: Base system configuration
  hosts: homelab
  become: yes
  vars_files:
    - ../inventory/group_vars/vault.yml
  roles:
    - { role: base, tags: ['base'] }
    - { role: docker, tags: ['docker'] }
    - { role: tailscale, tags: ['tailscale'] }
    - { role: mounts, tags: ['mounts'] }
    - { role: borgmatic, tags: ['borgmatic'] }

Running It

Run from ~/ansible/ on the control node. Check mode, --diff, tags and the safe-run workflow are explained in Ad-hoc commands & running playbooks.

# Test connectivity to all hosts
ansible homelab -m ping

# Dry run against a specific host (shows what would change)
ansible-playbook playbooks/base.yml --check --limit server1.example.com

# Run everything against a specific host
ansible-playbook playbooks/base.yml --limit server1.example.com

# Run only a specific role using tags
ansible-playbook playbooks/base.yml --tags docker --limit server1.example.com

# Run everything except one role
ansible-playbook playbooks/base.yml --skip-tags borgmatic --limit server1.example.com

# Run against all hosts
ansible-playbook playbooks/base.yml

# Verify idempotency (second run should show changed=0)
ansible-playbook playbooks/base.yml --limit server1.example.com

# Run ad-hoc commands against hosts
ansible homelab -a "uptime"
ansible server1.example.com -a "docker ps"
ansible homelab -a "df -h /mnt/appdata"

What Gets Provisioned

After running the full base playbook, each host has:

  • Base packages (curl, git, htop, jq, tmux, etc.)
  • SSH hardened (key-only auth, no root login, no password auth)
  • Passwordless sudo for your user
  • Docker CE + compose plugin + log rotation
  • Tailscale installed but stopped
  • NAS backup mount via CIFS
  • Borgmatic (installed with uv) configured with an encrypted Borg repo initialized, cron at 03:00 daily
  • Directory structure: /opt/docker, /mnt/appdata, /mnt/backups

Key Concepts

Idempotency. Running the same playbook twice produces the same result. The second run should show changed=0. If a task always shows changed, it's not idempotent and should be fixed with creates: or when: conditions.

Tags. Labels on roles or tasks that let you run subsets of a playbook. --tags docker runs only the docker role. --skip-tags borgmatic runs everything except borgmatic.

Limit. --limit hostname restricts execution to a single host or group. Without it, the playbook runs against all hosts matched by the hosts: directive.

Check mode. --check shows what would change without making changes. Always dry run before applying to production.

Handlers. Tasks that only run when notified. If the SSH config template changes, the handler restarts sshd. If the template is unchanged, sshd is not restarted. This prevents unnecessary service restarts.

Vault. Encrypted storage for secrets. The vault password file decrypts automatically. Never commit .vault_pass or unencrypted secrets to git.

Gotchas

  • Fresh Debian minimal does not include sudo. Install it manually before running Ansible.
  • Don't use shell heredocs (cat << EOF) to write files containing Jinja2 {{ }} syntax. The shell escapes the braces. Use a text editor directly.
  • Vault files in group_vars/ are only auto-loaded if the filename matches a group name. Otherwise, load them explicitly with vars_files in the playbook.
  • The command and shell modules always report CHANGED even for read-only commands. This is normal and doesn't mean the system was modified.
  • Ansible connects via the SSH key specified in ansible.cfg. If you rotate keys, update private_key_file.