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
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
| |
That is it. No roles/. Roles are a distribution mechanism for reusable units, and you are not distributing anything to anyone.
| |
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.
| |
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
| |
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:
| |
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.
| |
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
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.
| |
| |
| |
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:
| |
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.




