Managing a Fleet of Raspberry Pis with Ansible: Automated Provisioning, Updates, and Config Management
One Raspberry Pi running Pi-hole is easy to maintain by hand. Once you're running a Pi-hole, a Home Assistant box, an OctoPrint server, a NAS, and a couple of sensor nodes, SSHing into each one individually to run apt upgrade or fix a config drift becomes a real chore — and it's exactly the kind of repetitive, error-prone task Ansible was built to eliminate. This guide covers setting up Ansible to manage a small fleet of Raspberry Pis: inventory, ad-hoc commands, writing your first playbook, and a couple of real playbooks worth having on hand.
Why Ansible for a Home Pi Fleet
Ansible's main advantage for this use case is that it's agentless — there's no daemon to install and maintain on each Pi, just SSH access. You write a description of the state you want a machine to be in (these packages installed, this config file present, this service enabled), and Ansible figures out what needs to change to get there, skipping anything already correct. That idempotency is what makes it safe to re-run the same playbook against your whole fleet every week without worrying about it breaking something that's already configured correctly.
Prerequisites
- A control machine: any Linux, macOS, or WSL machine with Ansible installed — this can be your desktop, a dedicated Pi, or even one of the Pis in the fleet managing the others.
- SSH key authentication: copy your public key to every Pi with ssh-copy-id pi@<hostname> so Ansible can connect without a password prompt on every run.
- Consistent hostnames or static DHCP reservations: set a unique hostname on each Pi during imaging (Raspberry Pi Imager's advanced options let you do this at flash time) and either use mDNS (.local) names or static DHCP reservations in your router so IPs don't shift under you.
Install Ansible on the control machine with sudo apt install ansible on Debian/Ubuntu-based systems, or pip install ansible --break-system-packages if you want a more current version than your distro repo carries.
Building an Inventory
The inventory file tells Ansible which hosts exist and how to group them. A simple INI-style inventory for a mixed fleet:
[pihole] pihole.local [homeassistant] hass.local [printfarm] octoprint1.local octoprint2.local [sensors] sensor-shop.local sensor-garage.local [all:vars] ansible_user=pi ansible_python_interpreter=/usr/bin/python3Groups let you target playbooks at a role ("update every printfarm host") or run something across the entire fleet at once (the implicit all group). Save this as inventory.ini in a working directory you'll keep your playbooks in — ideally under version control.
Ad-Hoc Commands
Before writing playbooks, ad-hoc commands are the fastest way to confirm connectivity and run one-off tasks across the fleet:
# Confirm every host is reachable ansible all -i inventory.ini -m ping # Check uptime across the whole fleet ansible all -i inventory.ini -a "uptime" # Update package lists on just the sensor group ansible sensors -i inventory.ini -b -m apt -a "update_cache=yes"The -b flag runs the task with privilege escalation (sudo) — the default pi user needs it for anything that touches system packages or services.
Your First Playbook: Baseline Configuration
A playbook is a YAML file describing a sequence of tasks to apply to a group of hosts. This one applies a sensible baseline to every Pi in the fleet — timezone, unattended security updates, and a couple of always-useful packages:
--- - name: Baseline Pi configuration hosts: all become: true tasks: - name: Set timezone community.general.timezone: name: America/New_York - name: Update apt cache apt: update_cache: yes cache_valid_time: 3600 - name: Install baseline packages apt: name: - vim - htop - unattended-upgrades - fail2ban state: present - name: Enable unattended security upgrades copy: dest: /etc/apt/apt.conf.d/20auto-upgrades content: | APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1";Run it with ansible-playbook -i inventory.ini baseline.yml. The first run will make changes; every run after that reports "ok" (no change) for tasks that are already satisfied — that idempotency is what makes it safe to schedule this on a cron job or run it manually whenever you remember.
A Real-World Playbook: Rolling Patch-and-Reboot
Patching every Pi and rebooting them all simultaneously takes down your Pi-hole and Home Assistant at the same moment, which is a bad time if you're relying on either. Ansible's serial keyword runs a play against a limited batch of hosts at a time, waiting for each batch to finish before moving to the next:
--- - name: Rolling patch and reboot hosts: all become: true serial: 1 tasks: - name: Full upgrade apt: upgrade: dist update_cache: yes - name: Check if reboot is required stat: path: /var/run/reboot-required register: reboot_required - name: Reboot if needed and wait for host to come back reboot: reboot_timeout: 300 when: reboot_required.stat.existsserial: 1 patches and reboots hosts one at a time, so if a reboot hangs on one Pi you find out before it's done the same to your whole fleet.
Templating Config Files
For files that need per-host values (a hostname in a config, a unique sensor ID), Ansible's Jinja2 templating combined with group_vars/host_vars is the cleanest way to keep one template working across many hosts. Define a variable per host in host_vars/sensor-shop.local.yml:
sensor_location: "Workshop" mqtt_topic: "sensors/workshop"Then reference it in a Jinja2 template file (templates/sensor-config.j2) deployed with the template module — Ansible substitutes {{ sensor_location }} and {{ mqtt_topic }} per-host automatically when the play runs.
Handling Secrets
Never put WiFi passwords, API keys, or MQTT credentials in plain text inside a playbook you might commit to a repo. Ansible Vault encrypts sensitive variable files at rest: ansible-vault create group_vars/all/secrets.yml opens an editor for encrypted content, and ansible-playbook takes an --ask-vault-pass flag (or a password file) to decrypt it at run time.
Troubleshooting
SymptomLikely CauseFix "UNREACHABLE" on pingSSH key not deployed, or hostname/IP wrongVerify with plain ssh first; re-run ssh-copy-id "Missing sudo password" errorsPlaybook needs become but pi user requires a password for sudoAdd --ask-become-pass, or configure passwordless sudo for automation Python interpreter errors on newer Pi OSAuto-detection picks the wrong Python pathSet ansible_python_interpreter=/usr/bin/python3 explicitly in inventory Playbook hangs on reboot taskHost took longer than reboot_timeout to come backIncrease reboot_timeout, verify network boot order on that PiOnce the baseline and patch-and-reboot playbooks are in place, extending Ansible to actually provision new Pis from a fresh SD card image — installing Docker, deploying your Home Assistant or OctoPrint config, joining WireGuard — turns "flash a card and spend an evening configuring it" into "flash a card, run one playbook, walk away." For a fleet of more than three or four Pis scattered around a shop and house, that time investment pays for itself within the first couple of update cycles.
Related Guides
- Self-Hosted CI/CD Runner on a Raspberry Pi: GitHub Actions and Gitea Actions
- Raspberry Pi: Complete Headless Setup Guide (No Monitor Needed)
- How to Set Up a Raspberry Pi Headless with SSH and WiFi
- Automated Plant Watering System with Raspberry Pi
- Running Home Assistant on a Raspberry Pi 4
- Auto-Backup Your Raspberry Pi SD Card to a Network Share