Featured image of post Ansible for the homelab: idempotent provisioning from day one

Ansible for the homelab: idempotent provisioning from day one

Every home lab starts with a wiki page of shell commands and ends with a host that nobody dares rebuild. Ansible is the cheapest way out: no agent, SSH only, and a language (YAML) that your future self can read.

The trap is treating Ansible as remote shell with extra steps. These are the habits that make it actually idempotent.

1. Inventory that mirrors reality

# inventory/hosts.ini
[proxmox]
pve01.lan ansible_host=192.168.10.10
pve02.lan ansible_host=192.168.10.11

[docker]
host01.lan ansible_host=192.168.10.20
host02.lan ansible_host=192.168.10.21

[nas]
truenas.lan ansible_host=192.168.10.30

[all:vars]
ansible_user=ansible
ansible_python_interpreter=/usr/bin/python3
ansible_ssh_common_args='-o StrictHostKeyChecking=accept-new'

Role groups (proxmox, docker, nas) let you target by *purpose*, which is the only grouping that survives hardware changes. Group by hardware and you end up with [intel_nuc], which means nothing in a year.

2. One playbook per concern

roles/
  base/            # users, ssh daemon, sysctl, packages
  docker_host/     # docker engine, compose plugin, log rotation
  backup_agent/    # restic + systemd timer
site-base.yml
site-docker.yml

site-base.yml runs everywhere:

- hosts: all
  become: true
  roles:
    - base

- hosts: docker
  become: true
  roles:
    - docker_host
    - backup_agent

Splitting by concern is what makes --tags and --check usable. One 900-line site.yml means every run touches everything, including on the hosts you were not thinking about.

3. Write tasks that can run twice

- name: ensure docker apt key exists
  ansible.builtin.get_url:
    url: https://download.docker.com/linux/debian/gpg
    dest: /etc/apt/keyrings/docker.asc
    mode: "0644"
  args:
    creates: /etc/apt/keyrings/docker.asc

- name: add docker apt repository
  ansible.builtin.apt_repository:
    repo: "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable"
    filename: docker
    state: present
    update_cache: true

- name: install docker packages
  ansible.builtin.apt:
    name: [docker-ce, docker-ce-cli, containerd.io, docker-compose-plugin]
    state: present

Use modules, not command:. A command: task without creates: or changed_when: reports changed on every run, which trains you to ignore the output — the single most common reason hand-rolled Ansible stops being trustworthy.

When you genuinely need a command, be explicit:

- name: enable ip forwarding
  ansible.posix.sysctl:
    name: net.ipv4.ip_forward
    value: "1"
    state: present
    sysctl_file: /etc/sysctl.d/99-forward.conf

4. Make check mode meaningful

ansible-playbook -i inventory/hosts.ini site-docker.yml --check --diff
ansible-playbook -i inventory/hosts.ini site-docker.yml --check --limit host02.lan

Two rules keep --check honest:

  • Never skip check mode with check_mode: false unless the task is a read (a stat, a slurp). A task that reports "changed" in check mode but does nothing is worse than useless.
  • --diff shows the config drift Ansible is about to reconcile, which is the whole reason to use it rather than a shell script.

Run --check in pre-commit on the playbooks you touched. It is not perfect — modules without check support will skip — but it catches the typo that would otherwise restart the wrong service.

5. Safety rails

# ansible.cfg
[defaults]
interpreter_python = auto_silent
forks = 10
timeout = 30
deprecation_warnings = False

[privilege_escalation]
become_method = sudo

And structurally:

  • A dedicated ansible user with NOPASSWD sudo, and PermitRootLogin no. Ansible logging in as root hides which changes were automated.
  • --limit for everything interactive. A bare ansible-playbook on a fresh checkout should make you nervous; that is correct.
  • serial: 1 on anything that restarts networking or SSH. Losing the connection mid-play on ten hosts at once is a memorable lesson.
  • Secrets in ansible-vault, not in group_vars in plaintext. ansible-vault encrypt_string per value keeps diffs readable.

What this buys you

Not "automation" in the abstract — a rebuild story. A dead SD card or a bricked boot disk becomes: install the base OS, add the SSH key, run two playbooks, walk away. In a home lab, that is the difference between an infrastructure and a pile of machines with a wiki attached.

Built with Hugo
Theme Stack designed by Jimmy