Contents

Ansible for a Homelab That Fits in One Playbook

Enough automation to rebuild the rack, and no more than that

Contents

Most Ansible advice is written for people managing four hundred servers, and it is very good advice for those people. Roles, collections, dynamic inventories, Molecule test harnesses, an execution environment built as a container image — all of it exists because at four hundred hosts the cost of getting it wrong is enormous and the cost of the structure is amortised across a team.

You have five machines. Possibly six, if the Pi in the loft still counts. The structure will cost you more than the mistakes it prevents, and the most common outcome of homelab Ansible is a repository with a beautiful roles/ tree, three roles that work, and a set of tasks/main.yml files last run eight months ago against a host that has since been reinstalled by hand.

So this is the argument for staying small deliberately: one playbook, a flat inventory, group variables, and a strict rule about what belongs in Ansible at all. It is what I run, it fits on two screens, and it will rebuild my rack from bare Debian in about twenty minutes.

Decide what Ansible is for

Advertisement

The failure mode is scope. Ansible can install packages, template configs, manage users, start containers, configure firewalls, and — if you let it — become a worse version of every tool it is orchestrating. The lab that works has a line drawn through it.

Mine: Ansible owns the host. Compose owns the services.

Ansible gets a machine from a fresh install to “Docker is running, the user exists, SSH is locked down, the filesystem is mounted, the secrets are on disk, and the compose stack is checked out”. Then it stops. What runs inside the containers is compose files in git, managed by the tools that manage compose files, and Ansible has no opinion about them beyond git pull && docker compose up -d.

That line matters because host configuration changes twice a year and service configuration changes twice a week. Automating the rare thing with the heavy tool and leaving the frequent thing to the light tool is the right way round, and every homelab I have seen that inverted it — Ansible templating out compose files, community.docker.docker_container for forty services — collapsed under its own change rate within a year.

If your hosts are genuinely disposable, note that NixOS does this job better by being declarative all the way down. Ansible is the pragmatic choice for machines running an ordinary distribution that you would rather not rebuild.

The whole layout

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
homelab-ansible/
├── ansible.cfg
├── inventory.yml
├── site.yml
├── group_vars/
   ├── all.yml
   ├── all.sops.yml
   └── media.yml
└── files/
    └── sshd_hardening.conf

That is it. No roles/. Roles are a distribution mechanism for reusable units, and you are not distributing anything to anyone.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ansible.cfg
[defaults]
inventory = inventory.yml
host_key_checking = True
retry_files_enabled = False
stdout_callback = yaml
callbacks_enabled = profile_tasks
interpreter_python = auto_silent

[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s

pipelining = True is close to free and roughly halves runtime by cutting the number of SSH round-trips per task. profile_tasks tells you which task is the slow one, which you will want within a week.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# inventory.yml
all:
  vars:
    ansible_user: deploy
    ansible_python_interpreter: /usr/bin/python3
  children:
    servers:
      hosts:
        vault01: { ansible_host: 192.168.1.20 }
        media01: { ansible_host: 192.168.1.40 }
        metrics01: { ansible_host: 192.168.1.42 }
    edge:
      hosts:
        proxy01: { ansible_host: 192.168.1.10 }
    media:
      hosts:
        media01:

Static, in git, five lines per host. Dynamic inventory exists so you do not hand-maintain a list of four hundred ephemeral cloud instances. Yours have names and they are in a cupboard.

The playbook

Advertisement
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# site.yml
- name: Base configuration for every host
  hosts: all
  become: true
  vars_files:
    - group_vars/all.sops.yml

  tasks:
    - name: Install the packages every box needs
      ansible.builtin.apt:
        name:
          - curl
          - git
          - htop
          - unattended-upgrades
          - restic
          - jq
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Deploy user exists with my key
      ansible.builtin.user:
        name: deploy
        groups: [sudo, docker]
        shell: /bin/bash
        append: true

    - name: Authorised key for deploy
      ansible.posix.authorized_key:
        user: deploy
        key: "{{ deploy_ssh_pubkey }}"
        exclusive: true

    - name: Harden sshd
      ansible.builtin.copy:
        src: files/sshd_hardening.conf
        dest: /etc/ssh/sshd_config.d/99-hardening.conf
        owner: root
        mode: "0644"
        validate: /usr/sbin/sshd -t -f %s
      notify: reload sshd

    - name: Age key for SOPS on this host
      ansible.builtin.copy:
        content: "{{ host_age_keys[inventory_hostname] }}"
        dest: /var/lib/sops/keys.txt
        owner: root
        mode: "0400"
      no_log: true

  handlers:
    - name: reload sshd
      ansible.builtin.systemd:
        name: ssh
        state: reloaded

- name: Container hosts
  hosts: servers
  become: true
  tasks:
    - name: Docker repository and engine
      ansible.builtin.include_tasks: tasks/docker.yml

    - name: Stack checked out
      ansible.builtin.git:
        repo: [email protected]:smarc/homelab-stacks.git
        dest: /srv/stacks
        version: main
      become_user: deploy
      notify: compose up

  handlers:
    - name: compose up
      ansible.builtin.command:
        cmd: docker compose up -d --remove-orphans
        chdir: "/srv/stacks/{{ inventory_hostname }}"
      become_user: deploy
      changed_when: true

Two plays, one file, and it does the whole job. The validate: on the sshd config is the single most valuable line in there — it runs sshd -t against the new file before installing it, so a typo produces a failed task rather than a machine you can no longer log into. Learn that lesson from me instead.

no_log: true on the age key task stops the secret appearing in output and in --diff. Every task that touches a secret needs it, and forgetting is how your key ends up in a CI log.

First contact with a fresh host

The gap every guide skips: the playbook above assumes a deploy user with your key and passwordless sudo. A machine that came out of the Debian installer twenty minutes ago has none of that. Something has to bridge from “root password I typed at the console” to “Ansible can log in”, and that something is either a bootstrap play or ten minutes of manual work you will do slightly differently on every host.

Write the bootstrap play. It runs once per machine, as root, with a password:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# bootstrap.yml — ansible-playbook bootstrap.yml -l newbox -u root -k
- name: Bring a fresh host under management
  hosts: all
  gather_facts: false
  vars_files:
    - group_vars/all.sops.yml

  tasks:
    - name: Python exists, so that every other module can work
      ansible.builtin.raw: which python3 || (apt-get update && apt-get install -y python3)
      changed_when: false

    - name: Gather facts now that we can
      ansible.builtin.setup:

    - name: Deploy user
      ansible.builtin.user:
        name: deploy
        groups: sudo
        shell: /bin/bash
        append: true

    - name: Key for deploy
      ansible.posix.authorized_key:
        user: deploy
        key: "{{ deploy_ssh_pubkey }}"

    - name: Passwordless sudo, validated so a typo cannot lock us out
      ansible.builtin.copy:
        content: "deploy ALL=(ALL) NOPASSWD:ALL
"
        dest: /etc/sudoers.d/deploy
        mode: "0440"
        validate: /usr/sbin/visudo -cf %s

raw is the one place where a non-idempotent module is correct, because it is the only module that works before Python exists. Everything after that line is normal Ansible.

Then ansible-playbook site.yml -l newbox and the machine joins the lab. The whole path from installer to running services is two commands and neither of them involves remembering what you did last time. That reproducibility is the actual product here — the playbook is just how it is written down.

Worth pairing this with SSH hardening you do not have to think about: once the key is in place, the hardening drop-in in site.yml disables password authentication permanently, and the bootstrap path is closed behind you.

Idempotence is a promise you make

The word gets used as though Ansible provides it. It does not. Modules provide it, and only the ones that were written to. apt, copy, user, systemd are idempotent because someone did the work. command and shell are not, and they will report changed on every single run for the rest of time.

That matters more than it sounds, because a playbook that reports twelve changes on a no-op run is a playbook whose output you have stopped reading, which means the one genuine change you needed to notice goes past unremarked. The value of --check and of a clean run is entirely destroyed by noise.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Wrong. Changed forever, tells you nothing.
- name: Set up the thing
  ansible.builtin.shell: /opt/setup-thing.sh

# Right. Runs once, honest about it afterwards.
- name: Set up the thing
  ansible.builtin.command:
    cmd: /opt/setup-thing.sh
    creates: /var/lib/thing/.initialised

# Also right, where there is no artefact to point at.
- name: Query current state
  ansible.builtin.command: /opt/thing status --json
  register: thing_status
  changed_when: false
  check_mode: false

- name: Configure if needed
  ansible.builtin.command: /opt/thing configure
  when: (thing_status.stdout | from_json).configured is false

creates:, removes:, changed_when: and failed_when: are the four keywords that separate a playbook you trust from a script with YAML syntax. The target to hold yourself to: a second consecutive run reports changed=0. If it does not, find out why, every time. That number is the entire signal.

Secrets: use SOPS, skip Vault

Advertisement

Ansible Vault works and I stopped using it. It encrypts whole files, so diffs are useless, and it has its own key management that lives nowhere else in your infrastructure. If the rest of your lab already uses SOPS and age, having a second scheme for one tool is a pointless second thing to lose.

1
ansible-galaxy collection install community.sops
1
2
3
4
5
6
7
# .sops.yaml
creation_rules:
  - path_regex: group_vars/.*\.sops\.ya?ml$
    key_groups:
      - age:
          - age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
          - age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
1
2
3
# ansible.cfg
[defaults]
vars_plugins_enabled = host_group_vars, community.sops.sops

Now group_vars/all.sops.yml decrypts transparently at runtime, per-value, with readable diffs and the same key you use everywhere else. The values are visible in -vvv output, so resist the urge to debug at that verbosity in a terminal you are recording.

Drift, and the limits of what Ansible sees

People hear “desired state” and assume Ansible tracks the whole machine. It enforces the state you described, on the resources you mentioned, at the moment you run it. Everything else on the machine is invisible to it.

That distinction has teeth. Remove a task from your playbook and the thing it installed stays installed, forever, on every host that ever ran the old version. Delete the apt entry for a package and the package remains. Rename a config file in files/ and the old one sits in /etc doing whatever it does. Your playbook describes a set of assertions rather than the machine, and the gap between those two grows quietly, in the direction of things you have forgotten about.

There are three honest responses.

Use state: absent deliberately. When you stop wanting something, say so explicitly rather than deleting the task. It is more YAML and it is the only thing that actually converges the fleet:

1
2
3
4
5
- name: Things that used to be here and should not be
  ansible.builtin.apt:
    name: [snapd, telnet, nginx]
    state: absent
    purge: true

That list grows and it looks silly and it is correct. After a couple of years you get to delete it, once every host has certainly run it.

Rebuild instead of converging. If a host can be reinstalled in twenty minutes from bare metal, drift stops mattering because the machine does not live long enough to accumulate any. This is the impermanence argument and it is the strongest answer available; it is also a bigger change than adopting Ansible.

Check, and read the output. ansible-playbook site.yml --check --diff on a schedule tells you what has changed underneath you since last time. Run it from a timer, mail yourself the diff, and treat a non-empty result as a question worth answering. This is cheap and nobody does it, and it is how you discover that you fixed something by hand at midnight in March and never wrote it down.

The thing to internalise: Ansible tells you the truth about the resources you asked about and nothing about the ones you did not. A clean run means “my assertions hold”, never “this machine matches my repo”.

Where Ansible stops

The boundary at the container line has a second boundary above it. Ansible configures machines that exist; it is poor at making them exist. Creating VMs, sizing disks, attaching networks — that is Terraform’s job on Proxmox, and the division works cleanly: Terraform creates the VM and writes an inventory entry, Ansible configures what booted. People do try to provision infrastructure with Ansible modules and it works right up to the point where you need to know what already exists, at which point you have reimplemented state files, badly.

The other place it stops is anything with a change rate measured in days. Updating containers is a job for Renovate against your compose files and a redeploy, and package updates belong to unattended-upgrades rather than to a playbook you run when you remember. Ansible’s job is the shape of the machine, which changes rarely, and letting it creep into the fast-moving things is how a twenty-minute playbook becomes a forty-minute one you avoid running.

Troubleshooting

changed=3 on every run and you cannot see why. --diff shows the before and after of file modules. For command, add -vvv and read what it actually executed. Nine times in ten it is a missing creates:.

“Failed to connect to the host via ssh” on one host only. Check ansible_host and try the raw command Ansible builds — ansible -m ping vault01 -vvv prints the exact SSH invocation. Usually it is the ControlPersist socket holding a dead connection: rm ~/.ansible/cp/*.

sudo: a password is required. become: true with no become_password. Either configure NOPASSWD for the deploy user in a sudoers drop-in — which you deploy with Ansible, using validate: /usr/sbin/visudo -cf %s, for the same reason as the sshd validate — or run with --ask-become-pass.

Playbook works by hand, fails from cron or CI. Almost always the SSH agent. Batch runs need a key without a passphrase, scoped to the deploy user, or an agent explicitly forwarded.

Everything is slow. Turn on profile_tasks and look. apt update on five hosts is usually the answer, and cache_valid_time: 3600 is the fix. gather_facts: false on plays that use no facts saves a few seconds per host and is worth it once you notice.

A task hangs forever. Something is prompting on the far end. apt with a config-file conflict, a command that wants confirmation. Add DEBIAN_FRONTEND=noninteractive to the environment and -o Dpkg::Options::=--force-confold where relevant.

The verdict

Ansible is the right tool for a homelab that runs an ordinary distribution and wants to be rebuildable. One playbook, a static inventory, group variables and a firm boundary at the container line will do everything you actually need, and it stays small enough that you will still run it in a year — which is the only quality that matters, because an automation you have stopped running is worse than no automation, since you now believe things are configured that are not.

Refuse the enterprise furniture. No roles until you have three hosts that genuinely need the same non-trivial thing and you can feel the duplication. No Molecule, no AWX, no execution environments. Those solve coordination problems that a team of one does not have.

The test I would set: reinstall a host from bare metal, run ansible-playbook site.yml --limit that-host, and see whether the machine comes back. Do that once a year. It takes an afternoon, it finds every piece of drift you have accumulated by hand, and it converts your playbook from a set of hopeful YAML into something you know works — which, when the disk actually dies, is the difference between an evening and a weekend.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.