Automating Your Home Lab with Ansible
At some point, your homelab crosses a threshold. You go from one or two machines you can configure by hand to five, ten, or more — and suddenly you're SSH-ing into each one to run the same apt update && apt upgrade command, repeating the same config changes, and forgetting which machine has which setup.
Ansible fixes this. It's an agentless automation tool that lets you define what your machines should look like and then makes them look that way. No software to install on target machines (just SSH access), no complex server infrastructure, and the configuration is written in YAML that's readable enough to serve as documentation.
Why Ansible for Homelabs
There are plenty of automation tools — Puppet, Chef, SaltStack, Terraform. Ansible is the right choice for homelabs because:
- Agentless: It just needs SSH access to your machines. Nothing to install on target hosts.
- Low overhead: No central server required. You run it from your workstation or a control node.
- Declarative YAML: The syntax is straightforward and readable. You don't need to learn a programming language.
- Idempotent: You can run the same playbook multiple times safely. If a machine is already in the desired state, Ansible skips that task.
Installation
Install Ansible on your control machine (your workstation or a dedicated management node):
# Ubuntu/Debian
sudo apt install ansible
# Fedora
sudo dnf install ansible
# macOS
brew install ansible
# Or via pip (any OS)
pip install ansible
You also need SSH key-based access to your target machines. If you haven't set that up:
# Generate a key pair (if you don't have one)
ssh-keygen -t ed25519
# Copy it to each target machine
ssh-copy-id user@192.168.1.10
ssh-copy-id user@192.168.1.11
ssh-copy-id user@192.168.1.12
Inventory: Defining Your Machines
The inventory file tells Ansible what machines to manage. Create a project directory and add an inventory:
mkdir ~/homelab-ansible
cd ~/homelab-ansible
Create inventory.yml:
all:
children:
servers:
hosts:
nas:
ansible_host: 192.168.1.50
ansible_user: admin
proxmox:
ansible_host: 192.168.1.10
ansible_user: root
containers:
hosts:
pihole:
ansible_host: 192.168.1.53
ansible_user: root
nginx-proxy:
ansible_host: 192.168.1.80
ansible_user: root
pis:
hosts:
pi-monitor:
ansible_host: 192.168.1.60
ansible_user: pi
pi-vpn:
ansible_host: 192.168.1.61
ansible_user: pi
Groups (servers, containers, pis) let you target subsets of your infrastructure. You can also have groups of groups and host-specific variables.
Test connectivity:
ansible all -i inventory.yml -m ping
You should see a SUCCESS response from each host.
Your First Playbook
Playbooks are YAML files that describe the desired state of your machines. Let's start with the most common homelab task — keeping everything updated.
Create update-all.yml:
---
- name: Update all Debian/Ubuntu machines
hosts: all
become: true
tasks:
- name: Update apt cache
apt:
update_cache: true
cache_valid_time: 3600
- name: Upgrade all packages
apt:
upgrade: dist
- name: Remove unnecessary packages
apt:
autoremove: true
- name: Check if reboot is required
stat:
path: /var/run/reboot-required
register: reboot_required
- name: Reboot if required
reboot:
reboot_timeout: 300
when: reboot_required.stat.exists
Run it:
ansible-playbook -i inventory.yml update-all.yml
That updates every machine in your inventory, removes orphaned packages, and reboots any machine that needs it. What used to take 20 minutes of SSH-ing around now takes one command.
Practical Playbook Examples
Install and Configure Docker
---
- name: Set up Docker on target hosts
hosts: servers
become: true
tasks:
- name: Install prerequisites
apt:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
state: present
- name: Add Docker GPG key
apt_key:
url: https://download.docker.com/linux/debian/gpg
state: present
- name: Add Docker repository
apt_repository:
repo: "deb https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable"
state: present
- name: Install Docker
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
- name: Start and enable Docker
systemd:
name: docker
state: started
enabled: true
- name: Add user to docker group
user:
name: "{{ ansible_user }}"
groups: docker
append: true
Harden SSH on All Machines
---
- name: Harden SSH configuration
hosts: all
become: true
tasks:
- name: Disable password authentication
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
- name: Disable root login
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
- name: Disable X11 forwarding
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?X11Forwarding'
line: 'X11Forwarding no'
- name: Restart SSH
systemd:
name: sshd
state: restarted
Deploy a Docker Compose Stack
---
- name: Deploy monitoring stack
hosts: pi-monitor
become: true
tasks:
- name: Create monitoring directory
file:
path: /opt/monitoring
state: directory
mode: '0755'
- name: Copy docker-compose file
copy:
src: files/monitoring-compose.yml
dest: /opt/monitoring/docker-compose.yml
- name: Copy Prometheus config
copy:
src: files/prometheus.yml
dest: /opt/monitoring/prometheus.yml
- name: Start the monitoring stack
community.docker.docker_compose_v2:
project_src: /opt/monitoring
state: present
Using Roles for Organization
As your playbooks grow, roles keep things organized. A role is a structured directory of tasks, files, templates, and variables for a specific purpose.
# Create a role structure
mkdir -p roles/common/{tasks,files,templates,handlers,defaults}
Create roles/common/tasks/main.yml:
---
- name: Install essential packages
apt:
name:
- vim
- htop
- tmux
- curl
- wget
- git
- unzip
state: present
- name: Set timezone
timezone:
name: "{{ timezone }}"
- name: Configure NTP
apt:
name: chrony
state: present
- name: Enable NTP
systemd:
name: chrony
state: started
enabled: true
Create roles/common/defaults/main.yml:
---
timezone: America/Los_Angeles
Use the role in a playbook:
---
- name: Base configuration for all hosts
hosts: all
become: true
roles:
- common
A typical homelab might have roles like:
common— Base packages, timezone, NTPdocker— Docker installation and configmonitoring— node_exporter and Promtailsecurity— SSH hardening, fail2ban, firewall rulesbackup— Restic/borg backup client setup
Variables and Templates
Ansible uses Jinja2 templates for dynamic configuration. This is powerful for generating config files that differ slightly per host.
Create roles/monitoring/templates/node_exporter.service.j2:
[Unit]
Description=Node Exporter
After=network.target
[Service]
Type=simple
User=node_exporter
ExecStart=/usr/local/bin/node_exporter \
--web.listen-address=:{{ node_exporter_port | default('9100') }} \
--collector.filesystem.mount-points-exclude="^/(sys|proc|dev|host|etc)($$|/)"
[Install]
WantedBy=multi-user.target
Use it in a task:
- name: Create node_exporter systemd unit
template:
src: node_exporter.service.j2
dest: /etc/systemd/system/node_exporter.service
notify: restart node_exporter
Ansible Vault: Managing Secrets
Don't put passwords in plain text in your playbooks. Ansible Vault encrypts sensitive data:
# Create an encrypted variables file
ansible-vault create group_vars/all/vault.yml
Inside, store your secrets:
vault_smtp_password: "your-email-password"
vault_grafana_admin_password: "your-grafana-password"
vault_wireguard_private_key: "your-wg-key"
Reference them in playbooks:
- name: Configure Grafana
template:
src: grafana.ini.j2
dest: /etc/grafana/grafana.ini
vars:
admin_password: "{{ vault_grafana_admin_password }}"
Run playbooks with vault:
ansible-playbook -i inventory.yml site.yml --ask-vault-pass
# Or use a password file
ansible-playbook -i inventory.yml site.yml --vault-password-file ~/.vault_pass
Recommended Project Structure
As your automation grows, organize it like this:
homelab-ansible/
├── inventory.yml
├── site.yml # Main playbook that includes everything
├── update.yml # Standalone: update all machines
├── group_vars/
│ ├── all/
│ │ ├── vars.yml # Shared variables
│ │ └── vault.yml # Encrypted secrets
│ ├── servers.yml # Server-specific vars
│ └── pis.yml # Pi-specific vars
├── host_vars/
│ └── nas.yml # NAS-specific vars
├── roles/
│ ├── common/
│ ├── docker/
│ ├── monitoring/
│ └── security/
└── files/
└── monitoring-compose.yml
Your site.yml pulls everything together:
---
- name: Base configuration
hosts: all
become: true
roles:
- common
- security
- name: Docker hosts
hosts: servers
become: true
roles:
- docker
- name: Monitoring
hosts: all
become: true
roles:
- monitoring
Tips for Homelab Ansible
Start small: Don't try to automate everything at once. Start with updates and SSH hardening, then add roles gradually.
Use --check mode: Run playbooks with --check to see what would change without making changes. Add --diff to see the actual file differences.
ansible-playbook -i inventory.yml site.yml --check --diff
Tag your tasks: Add tags so you can run subsets of your playbooks:
- name: Install Docker
apt:
name: docker-ce
tags: [docker, setup]
ansible-playbook -i inventory.yml site.yml --tags docker
Version control everything: Put your Ansible project in a git repo. This gives you history, rollback, and the ability to collaborate or recreate your setup from scratch.
Don't fight Ansible: If a task is hard to express in Ansible, it might be better as a shell script called by Ansible rather than a complex chain of modules. Use the command or shell modules as escape hatches when needed.
Ansible isn't just for enterprises with hundreds of servers. Even a five-machine homelab benefits from having its configuration defined in code rather than in your memory. When your Proxmox host dies and you rebuild from scratch, you'll be glad you can run one command to get everything back to where it was.