From Dropbox to Daily Driver Part 8: An Ansible Control Node, a Second Pi, and What changed=0 Actually Proves

Parts 2 through 7 hardened this Pi by hand, one terminal session at a time. That’s fine for a single device, but it means every step only exists as something I remember to do, in the right order, correctly, every time. If this microSD card dies tomorrow, the honest answer to “how do I get back to where I am now” is currently “read seven blog posts and don’t miss anything.” This part starts fixing that: a dedicated Ansible control node, a second Pi built specifically to prove the automation actually reproduces what Parts 2-7 did by hand, and the first role, covering Part 3’s base hardening.

Building a dedicated control node, not reusing an existing box

Ansible doesn’t need much: SSH out to the machines it manages, and somewhere to keep the project and its secrets. Rather than run it from Box1 or Pro1, it gets its own small LXC container on pve, ansible-ctrl, so it isn’t tangled up with either of my day-to-day machines.

Debian over Ubuntu here, matching the rest of this homelab’s Debian-based infrastructure, and an LXC container rather than a VM — this doesn’t need its own kernel, and a container starts in seconds rather than minutes:

sudo pveam update
sudo pveam download local debian-13-standard_13.6-1_amd64.tar.zst

The container needs its own SSH key for administering it directly, generated fresh:

ssh-keygen -t ed25519 -f ~/.ssh/ansible-ctrl -C "ansible-ctrl" -N ""

Before creating the container, a quick network check was worth doing rather than assuming an address was free — this is home-lab infrastructure with plenty of history, not a clean subnet:

sudo nmap -sn 192.168.1.0/24

.5 came back live, already in use by something else on the network. Worth flagging that mistake honestly rather than skipping to the corrected command: the first draft of this build used .5 without checking first, caught before anything was actually created, and moved to .6 instead.

sudo pct create 104 local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst \
  --hostname ansible-ctrl \
  --net0 name=eth0,bridge=vmbr1v100,ip=192.168.1.6/24,gw=192.168.1.1 \
  --ssh-public-keys ~/.ssh/ansible-ctrl.pub \
  --cores 2 --memory 1024 --rootfs local-lvm:8

That came back with a warning worth reading properly rather than dismissing:

WARN: Systemd 257 detected. You may need to enable nesting.

My first instinct was that nesting only matters for containers-inside-containers, and Ansible doesn’t need that, so I skipped it. That reasoning was wrong. Modern systemd, 257 here, ships with Debian 13, needs nesting enabled inside an unprivileged LXC container for correct internal namespace and cgroup behaviour, regardless of what’s actually running inside it — it’s not just about nested container runtimes. The fix:

sudo pct set 104 --features nesting=1
sudo pct reboot 104

systemctl is-system-running came back running, not degraded, confirming the container was actually healthy rather than limping along with something silently broken.

A non-root user, and a password gap worth mentioning

Logging in as root by default on a box that’s going to hold SSH keys for other machines felt like the wrong default, so a sudo-capable user went on straight away:

apt update && apt install -y sudo
adduser --disabled-password --gecos "" michealg
usermod -aG sudo michealg
mkdir -p /home/michealg/.ssh
cp /root/.ssh/authorized_keys /home/michealg/.ssh/
chown -R michealg:michealg /home/michealg/.ssh
chmod 700 /home/michealg/.ssh
chmod 600 /home/michealg/.ssh/authorized_keys

Logging back in as that user, sudo whoami failed:

Sorry, try again.

--disabled-password does exactly what it says — no password exists at all for sudo to check against, which isn’t the same thing as “no password needed.” passwd michealg, set interactively, fixed it in one line.

Ansible itself, and its community.general.ufw dependency, both went on through the ordinary Debian repos:

sudo apt update && sudo apt install -y ansible git
ansible --version
ansible [core 2.19.4]
ansible community version = 12.0.0

Debian’s ansible package (as opposed to the smaller ansible-core) already bundles community.general, which turned out to matter later — more on that below.

One more thing worth mentioning briefly rather than skipping past: ansible-ctrl is now also enrolled as a Wazuh agent, configured exactly per the Part 7 process against the same manager. It genuinely is deliberately left unhardened for now — it lives on the home LAN only, that exposure is acceptable short-term, and it’s a real, open item to come back to, not an oversight.

A demo target, not the control node itself

The obvious shortcut would be running the first Ansible role against ansible-ctrl itself. That would prove less than it sounds like — testing a role against the exact box it was written on tells you the role runs, not that it reproduces anything. What this part actually needs to demonstrate is that the role rebuilds Part 3’s hardening from a genuinely fresh, independent starting point.

That’s pi4-encore: a spare Pi 4 8GB that had been sitting around from an earlier project, running a stale image from a couple of years back. Anything worth keeping off it got pulled across first —

scp -r michealg@192.168.1.61:/home/michealg ~/pi4-encore-backup

— then the card got reflashed with the exact same headless, 64-bit Raspberry Pi OS Lite image used for pi4-128g back in Part 2. Not the desktop variant: the role’s assumptions (the sshd_config.d/ layout Part 3 relied on, no desktop-environment services competing for the same territory) are all built against Lite, and this device stays headless with no monitor attached regardless, so a GUI image would only add attack surface the role was never written to account for while making the “does the role reproduce Part 3” comparison murkier, not clearer.

A networking surprise: netplan, not dhcpcd

Getting a static address on this fresh image turned into a small, genuinely useful discovery. My assumption going in was dhcpcd, the tool Raspberry Pi OS has used for years. Checking first:

nmcli connection show
NAME                    UUID                                  TYPE      DEVICE
netplan-eth0            75a1216a-...                          ethernet  eth0

Current Raspberry Pi OS images (this one’s Debian 13-based) have moved to NetworkManager, with netplan generating its configuration. That could have been the same kind of trap Part 3 hit with sshd_config.d and a competing cloud-init file — a config source that looks authoritative but actually gets silently overwritten by something else. Checking the actual netplan file first ruled that out:

network:
  ethernets:
    eth0:
      renderer: NetworkManager
      networkmanager:
        passthrough:
          proxy._: ""

renderer: NetworkManager plus that passthrough block means netplan has handed this interface entirely to NetworkManager — the YAML file is NetworkManager mirroring its own state for netplan’s benefit, not a separate source of truth competing with it. No fight to route around here; nmcli directly is the correct, persistent way to set this:

sudo nmcli connection modify netplan-eth0 \
  ipv4.addresses 192.168.1.7/24 \
  ipv4.gateway 192.168.1.1 \
  ipv4.dns 192.168.1.1 \
  ipv4.method manual
sudo nmcli connection up netplan-eth0

Confirmed afterward with ip a show eth0 and a reconnect — pi4-encore now sits at a stable, known address, untouched otherwise. No apt update, no manual prep of any kind. That matters for what comes next: the whole point of this device is proving the playbook does that work itself, starting from a state as close to Part 2’s first boot as this build gets.

Writing the base_hardening role

The role is a direct, task-for-task translation of Part 3, in the same order:

Bring the base system current.

- name: Update apt cache and upgrade all packages
  ansible.builtin.apt:
    update_cache: true
    upgrade: safe

Kill password authentication, using the exact same trick Part 3 discovered by hand — a drop-in named to sort ahead of the 50-cloud-init.conf file Raspberry Pi Imager installs on first boot, since sshd_config.d/ applies the first matching directive it finds for a given keyword, not the last:

- name: Disable password authentication via a sorted-first drop-in
  ansible.builtin.template:
    src: 10-harden.conf.j2
    dest: /etc/ssh/sshd_config.d/10-harden.conf
    validate: "/usr/sbin/sshd -t -f %s"
  notify: Restart ssh

That validate parameter is the one piece of this role I’d call a genuine improvement over the manual process, not just an automated copy of it — it runs the same sshd -t syntax check Part 3 did by hand, automatically, against the staged file, before it’s ever live. There’s no way for this role to leave a broken sshd config in place, even transiently.

A default-deny firewall, one allow for SSH:

- name: Set default policy for incoming traffic (deny)
  community.general.ufw:
    direction: incoming
    default: deny

- name: Allow inbound SSH
  community.general.ufw:
    rule: allow
    port: "22"
    proto: tcp
    comment: SSH

Worth a small, honest aside on how that task ended up looking the way it does. My first version set both default policies in a single looped task, passing the policy as policy: "{{ item.policy }}". ansible-lint flagged it — not wrong exactly, policy is a valid alias for the module’s default parameter, but static analysis can’t resolve a Jinja-templated value inside a loop against the module’s allowed choices, and it’s genuinely clearer written as two explicit tasks using the real parameter name anyway. Small thing, but it’s the kind of nudge a linter is actually useful for, not just noise to silence.

Unattended upgrades, with the same caveat Part 3 surfaced rather than glossed over — that Debian’s default Origins-Pattern doesn’t cover Raspberry Pi’s own repository:

- name: Note the manual-update gap this role doesn't close
  ansible.builtin.debug:
    msg: >-
      Raspberry Pi's own kernel/firmware packages from archive.raspberrypi.com
      are NOT covered by unattended-upgrades — a manual apt upgrade is still
      what keeps those current.

And no fail2ban, deliberately — the same reasoning as Part 3 applies unchanged: no password oracle left once key-only auth is enforced, and Wazuh already provides real alerting on repeated failed key-only attempts.

Choosing how the playbook gets root

Every task above needs sudo. The convenient option — a passwordless sudo rule for the account Ansible connects as — got ruled out on purpose. That’s not really a convenience trade-off, it’s a standing local-privilege-escalation path sitting on disk permanently: if ansible-ctrl is ever compromised, there’s no friction at all between “attacker has a shell” and “attacker has root everywhere it manages.”

The sudo password instead lives encrypted with Ansible Vault:

ansible-vault create group_vars/pis/vault.yml

with a plain-text file mapping the vaulted value to the variable Ansible actually consumes:

# group_vars/pis/vars.yml
ansible_become_password: "{{ vault_ansible_become_password }}"

This is a bit more setup than typing --ask-become-pass on every run, but it’s setup that Parts 4 and 5 are going to need regardless — TOTP secrets, Cloudflare Tunnel credentials — so it’s really “build this once now” rather than “avoid it forever.” Every run after this needs one extra flag to unlock it:

ansible-playbook playbooks/site.yml --ask-vault-pass

The dry run’s one honest failure

Before touching pi4-encore for real, a --check --diff dry run:

ansible-playbook playbooks/site.yml --check --diff --ask-vault-pass

apt update && apt upgrade reported clean, and the diff on 10-harden.conf showed exactly the content expected, being created for the first time. Then:

TASK [base_hardening : Set default policy for incoming traffic (deny)] ***
[ERROR]: Task failed: Module failed: Failed to find required executable "ufw"

Worth explaining honestly rather than treating it as a bug to hide. --check mode is a simulation — the apt task correctly predicted it would install ufw, hence changed, but never actually installs anything in check mode. The very next task needs the real ufw binary just to ask “what’s the current state,” and on a from-scratch install with nothing pre-installed, that binary genuinely doesn’t exist yet. Any time a later task depends on something an earlier task in the same run would create, --check mode hits this wall. It’s a real, known limitation of dry runs, not something this role got wrong.

Running it for real

ansible-playbook playbooks/site.yml --ask-vault-pass
PLAY RECAP *****************************************************************
pi4-encore                 : ok=12   changed=8    unreachable=0    failed=0

Clean, end to end: the apt upgrade, the sshd drop-in, ufw installed and configured, unattended-upgrades installed and its timers enabled, the manual-update-gap note printed, and the sshd restart handler firing once at the end rather than after every individual config change. The two ufw default-policy tasks came back ok rather than changed — Debian’s own ufw package ships with matching defaults already, so there was nothing to change there. Not a problem, just already correct.

Verifying rather than trusting

A green play recap says the role thinks it succeeded. Whether it actually did is a different question, and worth checking the same way Part 3 did by hand rather than taking Ansible’s word for it:

$ ssh -o PubkeyAuthentication=no michealg@192.168.1.7
michealg@192.168.1.7: Permission denied (publickey).

Password authentication is genuinely off, not just reported as changed.

$ sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
22/tcp                     ALLOW IN    Anywhere                   # SSH
22/tcp (v6)                ALLOW IN    Anywhere (v6)              # SSH

That’s the identical output Part 3 got on pi4-128g, built by hand, now reproduced on a completely different physical device by a playbook.

Proving idempotency

The real point of this whole exercise isn’t “the playbook ran once and did stuff.” It’s that running it again against an already-hardened system should do nothing at all — the core promise Ansible makes and the one thing that separates a proper role from a shell script with extra syntax. Same command, no changes made in between:

ansible-playbook playbooks/site.yml --ask-vault-pass
PLAY RECAP *****************************************************************
pi4-encore                 : ok=11   changed=0    unreachable=0    failed=0

Every single task came back ok. Nothing to do, because there was nothing left to fix. That’s concrete, not asserted — two different Pi 4s, one hardened by hand across five posts, one hardened by a role in an afternoon, ending up in the same verified state.

What’s next

base_hardening is one role out of what this build actually needs. Parts 4, 5, and 7 — TOTP, the Cloudflare Tunnel, the Wazuh agent and SCA rollout — still need their own roles, and their secrets will land in the same Vault file this part set up. There’s also an open question I haven’t settled yet: whether pi4-128g, hardened entirely by hand back in Part 3, joins this inventory purely to prove the role changes nothing there either — a related but genuinely different claim from what pi4-encore just demonstrated. Part 9 closes this series out with a full checklist and the comparison back to where the whole rebuild started; whatever’s left of the Ansible work by then gets folded in alongside it.