I have only recently started using Ansible for my homelab. However, I have been using it professionally in various roles for several years.
Before integrating Ansible, my normal approach for new homelab systems went something like this:
Install OS.
Ssh into box.
Fiddle with the configuration until I am satisfied.
Leave box alone for a few months.
Update something or try something new.
Break box.
Repeat step 1.
This approach is great for learning and experimenting with new services. However, once you start depending on those services or machines, it becomes painful — especially if, like me, you have the memory of a fish 🐟.
Home Router Woes
At one point I was learning about dhcp and dns running inside podman and NATing behind a Linux bridge and thought to myself, hang on, I could technically configure this at home as my main router on something like a raspberry pi.
And yes, I did. This became my router for a while. The biggest issues I faced were:
My wife complaining when the internet went down mid-show.
Updating packages or the OS occasionally broke the router — and with my “fish 🐟 memory”, there was rarely a quick fix, which of course led straight back to point 1 😂.
Warning
There were other, more serious issues — such as running without properly configured firewalls or sensible default settings.
Tread carefully.
I switched over to using OpenWrt on the Raspberry Pi — and yes, even that is configured via Ansible.
Ansible
You might be asking why ansible 🤔?
From their site
Ansible provides open-source automation that reduces complexity and runs everywhere. Using Ansible lets you automate virtually any task.
Here are some common use cases for Ansible:
Eliminate repetition and simplify workflows
Manage and maintain system configuration
Continuously deploy complex software
Perform zero-downtime rolling updates
Ansible uses simple, human-readable scripts called playbooks to automate your tasks. You declare the desired state of a local or remote system in your playbook. Ansible ensures that the system remains in that state.
It is not all sunshine and rainbows. There are some drawbacks:
Can take a very long time to configure your machine depending on what you do - this is because it essentially SSHs into a machine and needs to copy all configurations to that machine. In a homelab situation this is a non-issue.
It does not save state in the same way Terraform does so you need to check, before creation in some cases see - name: "network | Check if exists - {{ lan_bridge_name }}" below.
What does Ansible look like?
This is a direct copy from my Cloud Server which is my main machine running all my service - hardware.
---- name:network | Get physical interface nameansible.builtin.set_fact:fresh_os_eth_name:"{{ ansible_facts.interfaces | select('match', '^' ~ fresh_os_eth_prefix ~ '[^.]*$') | list | first }}"# Again seems like debian needs this- name:network | Persist IPv6 disablebecome:trueansible.builtin.copy:dest:/etc/sysctl.d/99-disable-ipv6.confmode:u=rw,g=rw,o=rcontent:| net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1# Disable IPv6- name:network | Disable IPv6 on hostansible.posix.sysctl:name:"{{ item }}"value:'1'state:presentreload:trueloop:- net.ipv6.conf.all.disable_ipv6- net.ipv6.conf.default.disable_ipv6- net.ipv6.conf.lo.disable_ipv6- name:network | Get stats of netplan fileansible.builtin.stat:path:/etc/netplan/50-cloud-init.yamlregister:fresh_os_netplan- name:network | Make Backup Of Existing fileansible.builtin.copy:src:/etc/netplan/50-cloud-init.yamldest:/etc/netplan/50-cloud-init.yaml.bakowner:rootgroup:rootmode:u=rw,g=rwremote_src:truewhen:fresh_os_netplan.stat.exists- name:network | Remove Network Setupansible.builtin.file:path:/etc/netplan/50-cloud-init.yamlstate:absentwhen:fresh_os_netplan.stat.exists- name:"network | Check if exists - {{ lan_bridge_name }}"ansible.builtin.set_fact:fresh_os_lan_bridge_exist:truewhen:"lan_bridge_name in ansible_facts.interfaces"- name:"network | Set default if not exist - {{ lan_bridge_name }}"ansible.builtin.set_fact:fresh_os_lan_bridge_exist:falsewhen:"lan_bridge_name not in ansible_facts.interfaces"- name:network | Display interface nameansible.builtin.debug:msg:"eth_name: {{ fresh_os_eth_name }}. Does {{ lan_bridge_name }} exist? {{ fresh_os_lan_bridge_exist }}"- name:network | Check if exists - {{ dmz_bridge_name }}ansible.builtin.set_fact:fresh_os_dmz_bridge_exist:truewhen:"dmz_bridge_name in ansible_facts.interfaces"- name:"network | Set default if does not exist - {{ dmz_bridge_name }}"ansible.builtin.set_fact:fresh_os_dmz_bridge_exist:falsewhen:"dmz_bridge_name not in ansible_facts.interfaces"- name:network | Display interface nameansible.builtin.debug:msg:"eth_name: {{ fresh_os_eth_name }}. Does {{ dmz_bridge_name }} exist? {{ fresh_os_dmz_bridge_exist }}"- name:network | Ensure systemd-resolved is installedbecome:trueansible.builtin.apt:name:systemd-resolvedstate:present- name:network | Enable and start systemd-resolvedbecome:trueansible.builtin.systemd:name:systemd-resolvedenabled:truestate:started- name:network | Only Run if either bridge does not existwhen:not fresh_os_lan_bridge_exist or not fresh_os_dmz_bridge_existnotify:- Rebootblock:- name:"network | Create Bridge {{ lan_bridge_name }}"ansible.builtin.template:src:lan_bridge.yaml.j2dest:/etc/netplan/01-lan_bridge.yamlowner:rootgroup:rootmode:u=rw,g=rw- name:"network | Create DMZ Bridge {{ dmz_bridge_name }}"ansible.builtin.template:src:dmz_bridge.yaml.j2dest:/etc/netplan/02-dmz_bridge.yamlowner:rootgroup:rootmode:u=rw,g=rw
This configures the networking and does the following:
Finds the interface name - on this box I only have a single interface - yes this is consumer kit 😞.
Disable IPv6 - There seems to be some differences in how this works between Ubuntu and Debian.
Make backups of existing netplan configs - I originally wrote this for Ubuntu. I am now moving to Debian, which handles this like a champ and still uses systemd-networkd on the backend (see renderer: networkd below), though you need to install the netplan to systemd-netword renderer.
#GENERATED BY ANSIBLEnetwork:version:2renderer:networkd# This config is purely so that the machine is still accessible# even if we send untagged dataethernets:{{fresh_os_eth_name }}:dhcp4:novlans:{{fresh_os_eth_name }}.{{ fresh_os_lan_vlan }}:id:{{fresh_os_lan_vlan }}link:{{fresh_os_eth_name }}# This config: {{ fresh_os_eth_name }} allows the machine to be accessible# even if we send untagged data via the switchbridges:{{lan_bridge_name }}:dhcp4:yesmacaddress:{{lan_bridge_mac }}interfaces:- {{fresh_os_eth_name }}# untagged
Here we do the following:
Create VLAN interface from the interface.
Create a bridge and add an interface. NOTE: I did have both interfaces added and man did that mess up my routing ☹️.
Molecule
If you have ever done any software development, you might wonder how do we test all these somewhat complicated Ansible scripts?
From their site
Molecule is an Ansible testing framework designed for developing and testing Ansible collections, playbooks, and roles.
Molecule leverages standard Ansible features including inventory, playbooks, and collections to provide flexible testing workflows.
Test scenarios can target any system or service reachable from Ansible, from containers and virtual machines to cloud infrastructure, hyperscaler services, APIs, databases, and network devices.
I must come clean — I was first exposed to Molecule at my current employer, and have adopted it enthusiastically for my own projects as its an awesome piece of kit 👏.
I use molecule in the following projects:
Cloud Server - Yes I spin up a qemu VM with a debian image and run my ansible scripts against it, great way to test it before running against an actual machine.
openWRT modem - Again I spin up a openWRT image in qemu and run my scripts against it.
DMZ Server - Same here I spin up the DMZ image, debian in this case and test the scripts against it.
How does Molecule look like?
My configuration is somewhat unconventional as I am currently on macOS and use devcontainers per project and then spin up qemu inside the container, perhaps a future post 🤔.
Molecule runs through several phases:
create – Stand up infrastructure used to test the role.
This simply runs the Ansible role — the component we want to test against the QEMU-based infrastructure.
The issues with Molecule
There are a few gotchas to be aware of when using Molecule — some of which I only discovered recently while moving my Cloud Server from Ubuntu 24.04 to Debian 13.
One of the biggest issues I ran into was not using the exact same image in QEMU for testing as the one running on the actual server.
For testing, I used the Debian cloud-init image in QEMU. It’s fantastic for quickly configuring SSH access and spinning up test environments. However, because it is designed for cloud infrastructure, it comes preloaded with packages that the minimal Debian image does not include.
This led to a frustrating debugging session where things worked perfectly in Molecule, but failed on the real machine. After some digging, I discovered that I needed systemd-resolved installed and running on the actual server — something the cloud image handled implicitly.
The lesson here is simple: try to keep your testing and production environments as close as possible. I know this isn’t always feasible in a limited homelab setup, and there will be trade-offs — but the closer they match, the fewer surprises you’ll encounter.
Recommendation
If, like me, you ❤️ fiddling with your computers and routers, this is a game changer for getting things back to baseline quickly and consistently.
You also get the added benefit of being able to run everything again with minimal changes when you inevitably need to update or patch your systems.
It also keeps the wife happy seeing that things get up and running in no time 😂.