Homelab & networking · Build log
Ansible manages every config on my OpenWrt router and access points
I manage my Flint 2 router and access points from one Ansible repo
My Flint 2 router is probably my most boring yet awesome piece of equipment. Several years old at this point, it still punches above its weight. It runs vanilla OpenWrt brilliantly with great support.
I manage all OpenWrt devices with Ansible from a single repository. The router uses the openwrt role, and the APs share the openwrt-ap role, each applied with a separate playbook. Getting rid of the Eeros (which are app-only, see OpenWrt to the rescue) was also a way to bring my network back under code control. The main lessons since then have been about the mismatch between what Ansible thinks it has written and what’s actually on the device.
With Eero (and before that, Orbi), everything was provisioned and configured with click ops. It was tough to reproduce and test things and to quickly roll back. This is another example of bringing my professional tendencies home: infrastructure as code and configuration as code are big deals to me.
I created the router role, replacing the Verizon router with Flint 2. It goes straight into the ONT now. The first session was 39 commits. It uses the community.openwrt collection (Galaxy page), which runs modules through the shell so you don’t need Python on the router. I designed it as a single role with a task file for each subsystem, which works well for a single device and scales to VLANs and Wi-Fi without refactoring. The per-subsystem tags (system, syslog, packages, network, firewall, dhcp, dns, wireless, vpn, uhttpd, acme, openvpn, tailscale, alerts, ddns, dawn, performance, storage, backup) let you apply just one area if needed. I love this. Per-device settings go in host vars (the router has over 40 DHCP reservations). Secrets (Wi-Fi passwords, VPN keys, DDNS keys, alert tokens) come from 1Password via the CLI at run time, so none are in the repo. Sweet.
The openwrt-ap role came in shortly after. It takes an M3000 with fresh vanilla OpenWrt and turns it into a dumb AP: bridge the WAN port into the LAN, set a static IP, disable DHCP and router advertisements, configure wireless and client steering. On April 7, I added performance tuning as code: hardware and software flow offloading, packet steering, and some sysctl changes so the APs can keep up with a gigabit WAN. The role stops (and disables) odhcpd on the APs, so the router is the only IPv6 control plane.
The first real test came with the OpenWrt 25.x upgrade on April 6-7, before the Eero cutover. OpenWrt 25.12 switched from opkg to apk, so the roles now test for /usr/bin/apk and branch on it. The new release also wants interface addresses as CIDR list ipaddr in UCI. I dropped WireGuard tasks, usteer tasks, and the bridge-loop watchdog (which only existed to guard the proxy-ARP VPN setup, already gone). I added DAWN for client steering and the new VPN, so the AP role grew DAWN and performance task files.
The first --check --diff dry run of the router role revealed seven check mode bugs.. because community.openwrt.command skips in check mode, so registered results were undefined downstream. I fixed those by marking detection tasks check_mode: false and adding | default(...) fallbacks. ansible.builtin.stat fails outright (no Python on the router), so file checks switched to test -x via the command module. This is working well so far.
The Ansible provisioning wasn’t without its quirks and pitfalls initially, getting it all solid. The DHCP and DNS issues we had out of the gate were a pain. On the surface everything was fine.. but the signals disagreed. It took a little bit of debugging, but we smoothed it out. Lesson learned: RTFM.
The first issue I had: when my iPhone was unable to join the network. I quickly discovered that dnsmasq on the router wasn’t running, and the log told me that was because of a duplicate dhcp-host IP address, which made it fail to start. Turns out that I had some stale DHCP entries from the April 6 subnet change, right next to their replacements: the old short names for two access points and two Phyn water sensors, as well as three old Orbi entries whose addresses collided with the Toniebox. Deleting those seven stale entries by hand brought DNS back up.
I was skeeved out by this, so I audited every UCI entry on all three devices against what Ansible had declared. The result was 4 critical, 8 high, 7 medium and 7 low findings. The VPN obfuscation keys were listed under option names that the driver ignores, so that config was inert. The Phyn sensors had their per-host DHCP options tripled because uci add_list appends on each run; deleting the list and then adding it fixed that, and it stayed single after two more runs. One AP listed the same bridge port four times. The roles contained dead usteer code, and the APs retained their factory WAN zone and firewall rules. Every add_list in the roles now follows delete-then-add.
On the same day, a run failed midway through the DNS tasks. The old dns.yml stopped AdGuard Home, then rendered a template with 1Password secrets. The Ansible server resolves DNS via AdGuard Home, so the 1Password lookup timed out, the task failed, and AdGuard was down for 2 minutes until I manually started it again. This happened more than once. The rewrite: first do a pre-flight DNS check on the server, cache the secret as a fact, render the config to a staging file while AdGuard is still up, then stop the service, atomically move it into place, and start it immediately (inside the task, not a handler, because handlers wait until play end).. ending with a DNS check with retries. A full router run afterward showed 191 tasks ok, 68 changed, 0 failed.
The second time dnsmasq went down, a couple of weeks later, was the same kind of mistake: I renamed two reservations labeled “Apple TV” that were actually the Wi-Fi modules of August smart locks, but left the old sections in place next to the new named ones. The stale anonymous section duplicated the Ring camera’s address and crashed dnsmasq into a loop until I cleaned it up manually. The fix in dhcp.yml is an orphan purge that compares the router’s host sections to the declared reservations and deletes extras. A pre-commit duplicate-IP check aborts the run, and a handler fails the run if dnsmasq isn’t running after restart. The gather+check tasks run in check mode too, so a dry run reports the same problems. The purge is untested on a real rename (the April 25 duplicates were removed by hand first), so I’m watching that closely.. I’ll update this post after the first real cleanup.
The same day, I discovered that DAWN was stopped on all three APs. The roles had been using state: started, which starts a stopped service but never restarts a running one, so none of the DAWN config changes were being applied. Both roles now have a Restart DAWN handler. I also audited the roles for secrets in their output: DDNS keys, Wi-Fi passwords, alert tokens, the AdGuard Home admin hash, VPN peer keys.. all printed to stdout during runs. Every task that handles a 1Password secret now has no_log.
Adding some robustness and resiliency to the deployment pipeline made sense given the issues. I’m very impressed with how easy the configuration is now, especially with agentic assistance.
Part of the AP role is scaffolding we expect to be deleted. The tasks to remove the factory WAN zone and firewall rules only exist because the vanilla OpenWrt image contains them. They wouldn’t be present in a custom AP image.
What’s next: I have automated backups and snapshots of the router dumping often as part of my obsessive backup strategy, but I think I could be a little bit better with config drift detection and remediation (drift from code), and I’m looking into a custom OpenWrt image building pipeline to make updates easier. The building blocks are there with Image Builder, Attended Sysupgrade server, and the firmware selector built on top of it.
Next up is AdGuard Home, the DNS server this post kept tripping over.