Ansible-deployed home server for a Raspberry Pi 5. A single playbook run turns a clean Raspberry Pi OS Lite install into a smart-home host: it installs Docker, renders all configuration, and brings up a stack of services (automation, MQTT, monitoring) as Docker Compose containers.
The project is declarative and reproducible: image tags are pinned, secrets live in Ansible Vault, and role correctness is checked by linters and Molecule in CI.
A control node (a laptop running Ansible) configures a single Raspberry Pi over SSH. The Pi runs one Docker Compose project under /opt/home-server; services talk to each other over the internal Compose bridge network, and only the required ports are published.
control node (Ansible) ──ssh──▶ Raspberry Pi 5
├─ Docker Engine + Compose
└─ /opt/home-server (docker-compose.yml + configs)
├─ Home Assistant ──┐
├─ Mosquitto (MQTT) ◀┘ (127.0.0.1:1883)
├─ Zigbee2MQTT ──▶ Mosquitto (optional, needs dongle)
├─ Prometheus ──▶ scrape ──▶ Node Exporter / Blackbox
│ ▲ (Node Exporter also reads the
│ │ LibreSpeed textfile results)
│ └── Grafana (provisioned datasource + dashboard)
└─ librespeed.timer ──▶ librespeed-cli ──▶ textfile metrics
Monitoring flow: Node Exporter exposes host metrics and the Blackbox exporter continuously ICMP-probes public resolvers (latency + reachability). Internet bandwidth is measured by librespeed-cli (Ookla/speedtest.net is blocked on some ISPs), run by a systemd timer that writes results to Node Exporter's textfile collector. Prometheus scrapes everything and Grafana visualizes it via an auto-provisioned datasource and the "Home Server" dashboard. Bandwidth is sampled every librespeed_interval (default 1h; each test moves ~1 GB, so ≈24 GB/day); connection quality is sampled continuously (every blackbox_scrape_interval, default 20s).
| Service | Image | Port | Purpose |
|---|---|---|---|
| Home Assistant | ghcr.io/home-assistant/home-assistant:2026.6 |
8123 | Smart-home automation hub. Runs in host network mode; reaches MQTT at 127.0.0.1:1883. |
| Mosquitto | eclipse-mosquitto:2.0.22 |
1883 | MQTT broker. Anonymous access on the LAN by default. |
| Zigbee2MQTT | ghcr.io/koenkk/zigbee2mqtt:2.12.0 |
8080 | Bridges a Zigbee coordinator to MQTT. Optional, needs a USB dongle; disabled by default. |
| LibreSpeed CLI | github.com/librespeed/speedtest-cli v1.0.13 (host binary) |
— | Internet bandwidth (download/upload). Run by librespeed.timer; writes to Node Exporter's textfile collector. Replaces Ookla, which is ISP-blocked here. |
| Blackbox exporter | quay.io/prometheus/blackbox-exporter:v0.28.0 |
9115 | Continuous ICMP probes (latency + reachability) to public resolvers. Needs NET_RAW. |
| Prometheus | prom/prometheus:v3.12.0 |
9090 | Metrics collection and storage (30-day retention). |
| Grafana | grafana/grafana-oss:13.0.2 |
3000 | Dashboards. Prometheus datasource and the "Home Server" dashboard are provisioned automatically. |
| cAdvisor | ghcr.io/google/cadvisor:v0.53.0 |
8088 | Per-container resource metrics. Optional, disabled by default (no per-container data on Pi cgroup v2 without a cmdline change + reboot). |
| Node Exporter | quay.io/prometheus/node-exporter:v1.11.1 |
9100 | Host metrics (CPU, RAM, disk, network). |
| Uptime Kuma | louislam/uptime-kuma:1 |
3001 | Uptime monitoring. Optional, disabled by default. |
| Tailscale | tailscale/tailscale:stable |
— | Mesh VPN for remote access. Optional, disabled by default. |
Each service is toggled by a *_enabled flag in group_vars/all.yml; disabled services are excluded from the Compose file, the Prometheus config, and the firewall rules.
.
├── ansible.cfg # Ansible settings (inventory, vault password file)
├── site.yml # playbook: base → docker → rescue → home_server roles
├── upgrade.yml # playbook: OS upgrade + verified reboot
├── requirements.yml # Galaxy collections (community.docker/general, ansible.posix)
├── inventory/
│ └── hosts.yml.example # inventory template (real hosts.yml is gitignored)
├── group_vars/
│ └── all.yml # all configuration: services, ports, images, tuning
├── vault.yml # secrets (Ansible Vault, encrypted)
├── vault.example.yml # secrets template
├── roles/
│ ├── base/ # OS: packages, hostname, timezone, avahi, UFW
│ ├── docker/ # Docker Engine, Compose plugin, daemon tuning
│ ├── rescue/ # dropbear, watchdog, self-check, journal, backup, serial console
│ └── home_server/ # directories, config rendering, stack deployment
├── molecule/default/ # test scenario (converge/idempotence/verify)
└── .github/workflows/ci.yml # CI: lint + molecule
base — brings the OS to a working state: asserts a Debian-family host, updates the system, installs base packages, sets hostname and timezone, enables avahi-daemon, and optionally configures UFW (the allowed-port list is derived from the enabled services).
docker — installs Docker Engine and the Compose plugin from Docker's official repository, enables the service, adds the user to the docker group, and — if docker_daemon_options is set — writes /etc/docker/daemon.json (e.g. MTU) and restarts the daemon.
rescue — makes sure you never lose access to the headless host again (see Resilience): a second SSH server (dropbear) on another port, the hardware watchdog, a self-check timer with a dead-man's-switch ping, a persistent journal, a daily backup of the non-reproducible state, and the serial console on the Pi 5 debug UART.
home_server — resolves the Zigbee dongle path (when Zigbee2MQTT is enabled), creates the directory tree under /opt/home-server, renders every config (Mosquitto, Zigbee2MQTT, Prometheus, Grafana provisioning, docker-compose.yml), deploys the stack via community.docker.docker_compose_v2, and waits for services to become ready on their ports (Home Assistant additionally gets an HTTP readiness check).
Prerequisites
- A Raspberry Pi running Raspberry Pi OS Lite 64-bit, with SSH key access and passwordless or
-K-prompted sudo. - Ansible on the control machine (
pip install ansible-coreorbrew install ansible). - For Zigbee2MQTT: a USB Zigbee coordinator plugged into the Pi.
1. Install collections
ansible-galaxy collection install -r requirements.yml2. Point Ansible at your Pi
cp inventory/hosts.yml.example inventory/hosts.yml
# edit inventory/hosts.yml — set ansible_host and ansible_user# inventory/hosts.yml
all:
hosts:
pi:
ansible_host: 192.168.1.10
ansible_user: youruser
ansible_python_interpreter: /usr/bin/python33. Create the vault with your secrets
cp vault.example.yml vault.yml
# fill in real values, e.g.:
# vault_grafana_admin_password: "$(openssl rand -base64 24)"
echo 'your-vault-password' > .vault_pass && chmod 600 .vault_pass
ansible-vault encrypt vault.yml4. Review configuration
Open group_vars/all.yml and check at least pi_hostname, server_timezone, the *_enabled flags, and zigbee_adapter if you use Zigbee.
5. Deploy
ansible all -m ping # connectivity check
ansible-playbook site.yml -K # -K prompts for the sudo passwordThe first run pulls multi-gigabyte images and can take a while. Re-running is idempotent.
6. Access the services (replace <pi> with your Pi's address)
Home Assistant http://<pi>:8123
Grafana http://<pi>:3000 (admin + vault_grafana_admin_password)
Prometheus http://<pi>:9090/targets
Blackbox exporter http://<pi>:9115
The bandwidth test runs on a timer (librespeed.timer); to run it once
immediately: systemctl start librespeed.service. Check the timer with
systemctl list-timers librespeed.timer and results in
/opt/home-server/textfile/librespeed.prom.
Internet speed lives on the Grafana "Pi Home Server Overview" dashboard: the "Download/Upload (latest)" stats and the "Speedtest bandwidth" graph for throughput (from LibreSpeed), and "Internet latency" / "Internet reachability" for connection quality.
Day-2 operations (on the Pi, in /opt/home-server)
docker compose ps # status
docker compose logs -f # logs
docker compose up -d # apply changes / restart
docker compose down # stop the stackOS upgrades — never a side effect of site.yml (base_do_full_upgrade: false). Run the dedicated playbook, which upgrades, reboots and then proves the host came back (OpenSSH and dropbear banners, dpkg --audit, Docker, every compose service running, the rescue self-check):
ansible-playbook upgrade.yml -KAn upgrade that only breaks on the next boot is caught here, while you are at the keyboard — not months later at the first power cycle.
Why: in October 2026 the Pi came back from a summer of being unplugged with OpenSSH accepting connections and closing them before the banner, four of six containers down, and no way in — one SSH implementation, no console, and the last apt full-upgrade had never been followed by a reboot. The rescue role adds independent layers so that one failure never means "pull the SSD":
| Layer | What it does | Knob |
|---|---|---|
| Dropbear rescue SSH | Second SSH server, different implementation, own host key, port 2222. A broken OpenSSH (interrupted upgrade, bad config, lost keys) does not touch it: ssh -p 2222 user@pi-home.local. |
rescue_dropbear_* |
| Hardware watchdog | systemd pets /dev/watchdog; a hung kernel/systemd reboots itself. |
rescue_watchdog_* |
| Self-check timer | Every 5 min: sshd banner on 127.0.0.1:22, Docker daemon, root fs writable and below 90 %. Consecutive sshd failures → systemctl restart ssh after 3, reboot after 6 (at most once a day). Logs to the journal as rescue-check. |
rescue_check_* |
| Dead man's switch | The self-check pings a healthchecks.io URL on success and …/fail with details on failure. Silence = the host is sick or dead = Telegram/e-mail alert. Grafana on the Pi cannot report that the Pi itself is down; this can. |
vault_healthcheck_url |
| Persistent journal | Storage=persistent, 200 MB cap, so a broken boot can be read afterwards (journalctl -b -1). |
rescue_journal_* |
| Backup | Daily tarball of homeassistant/ (incl. .storage, the entity registry) and zigbee2mqtt/data/ to /var/backups/home-server, newest 7 kept, optional rsync to another host. Everything else is reproduced by site.yml. |
rescue_backup_* |
| Serial console | Keeps console=serial0,115200 in cmdline.txt. Plug a Raspberry Pi Debug Probe into the 3-pin UART between the micro-HDMI ports, run screen /dev/tty.usbmodem* 115200 on the laptop, and you have a login prompt plus the full boot log whatever sshd or the network are doing. |
rescue_serial_console |
| Wired only | Optional dtoverlay=disable-wifi: one interface, one IP, one router segment. |
rescue_disable_wifi |
Operational rules that no role can enforce: shut down with the power button or sudo poweroff, never by pulling the plug; keep a DHCP reservation for the Pi on the router; run upgrade.yml for upgrades.
Restore from a backup after a rebuild:
ansible-playbook site.yml -K # fresh host, empty stack
docker compose -f /opt/home-server/docker-compose.yml stop homeassistant zigbee2mqtt
tar -C /opt/home-server -xzf /var/backups/home-server/home-server-<stamp>.tar.gz
docker compose -f /opt/home-server/docker-compose.yml up -dAll user-facing configuration lives in group_vars/all.yml:
- Host:
pi_hostname,server_timezone,server_user,base_dir. - Services:
*_enabledflags and*_portports. - Images: tags are pinned (no
:latest) for reproducibility. - Docker:
docker_daemon_options— contents ofdaemon.json(defaults tomtu: 1280andmax-concurrent-downloads: 1, see Networking). - Service settings: Prometheus retention, LibreSpeed interval, Zigbee adapter type, etc.
The inventory (inventory/hosts.yml) holds the Pi's real address and user and is intentionally not committed — only hosts.yml.example is in the repo.
- yamllint and ansible-lint (
basicprofile) — static analysis. - Molecule (
molecule/default) —converge → idempotence → verifyof thebase,dockerandrescueroles in a systemd-enabled Debian 12 container. The full service stack is not tested in CI (it needs a Zigbee dongle and multi-GB images). - GitHub Actions (
.github/workflows/ci.yml) —lintandmoleculejobs on push tomainand on PRs. A repository secretANSIBLE_VAULT_PASSWORDis required so CI can decrypt the vault.
Tasks that can't run inside a container (editing /etc/hostname, /etc/hosts) are tagged molecule-notest; they are skipped only under Molecule and run normally on a real host.
The defaults assume a trusted LAN:
- Mosquitto allows anonymous connections (
mosquitto_allow_anonymous: true). - UFW is disabled; when enabled, the open TCP port list is built from the active services (plus the dropbear rescue port).
- External access (Tailscale) is disabled.
To harden: enable UFW, configure Mosquitto authentication/ACLs, and use Tailscale instead of exposing ports. The Blackbox exporter port (9115) only needs to be reachable by Prometheus on the Docker network — you can drop it from the published ports if you don't need to curl it directly.
Image pulls from some CDNs can stall on double-NAT/PPPoE links because of a Path-MTU-Discovery black hole: the TLS handshake succeeds but large layers hang. The fix is to lower Docker's MTU (docker_daemon_options.mtu, default 1280) — the docker role writes this to daemon.json before deploying. A network-wide fix is MSS clamping on the router's WAN interface. If the IPv6 route to a registry is broken, restrict Docker to IPv4.