If you manage more than two Linux servers in your homelab, you’ve
probably SSH’d into each one, run the same three commands, and thought
“there has to be a better way.” There is — it’s Ansible.
Ansible is agentless infrastructure-as-code. You write YAML playbooks
on your control machine, and it SSH’s into your hosts to apply them.
No agents, no daemons, no persistent connections. Just SSH and Python
on the target.
This guide covers setting up Ansible for a homelab, writing playbooks
that manage Docker containers and system configuration, handling secrets
safely with ansible-vault, and structuring your project so it doesn’t
turn into an unmaintainable mess. Real configs, real examples.
Why Ansible and Not a Shell Script?#
Shell scripts work until they don’t. Here’s what Ansible gives you that
a bash script doesn’t:
- Idempotence — Run the playbook 100 times, same result each time.
Shell scripts crash on the second run when the config file already exists.
- Inventory — Manage dozens of hosts from one control node without
maintaining SSH configs everywhere.
- Templating — Jinja2 templates for config files with variables that
differ per host.
- Facts — Ansible gathers system info (OS, IPs, disks, memory) and
exposes it as variables you can use in playbooks.
- Error handling — Failed tasks stop the playbook instead of silently
corrupting your config.
For a homelab with 3–20 VMs or LXCs, Ansible turns “SSH into each and
pray” into a single ansible-playbook command.
Setting Up Ansible#
Control Node (Your Workstation or a Management LXC)#
Ansible runs from any Linux machine. I run it inside a dedicated LXC
container on Proxmox with 1 core and 512 MB RAM — it doesn’t need
much.
1
2
3
4
5
6
7
|
# Debian/Ubuntu — install from official PPA for latest version
sudo apt update
sudo apt install -y ansible ansible-lint
# Verify
ansible --version
# Should show ansible [core 2.18+] with python 3.x
|
For macOS or WSL: brew install ansible or pip install ansible.
Target Host Requirements#
Ansible only needs two things on each target:
- SSH server running and reachable
- Python 3 — most Debian/Ubuntu installs have it. If not:
1
2
|
# Control node can install it via the raw module if needed
ansible all -m raw -a "apt install -y python3" -u root
|
That’s it. No agents, no extra ports, no registration process.
Project Structure#
Don’t throw everything into one playbook.yml. A maintainable structure
looks like this:
homelab-ansible/
├── ansible.cfg
├── inventory/
│ ├── production.yml
│ └── group_vars/
│ └── all.yml
├── playbooks/
│ ├── site.yml
│ ├── docker-hosts.yml
│ └── system-base.yml
├── roles/
│ ├── docker/
│ ├── common/
│ ├── traefik/
│ └── monitoring/
└── requirements.yml
ansible.cfg#
1
2
3
4
5
6
7
8
9
10
11
12
|
[defaults]
inventory = inventory/production.yml
host_key_checking = False
retry_files_enabled = False
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible-facts
stdout_callback = yaml
[ssh_connection]
pipelining = True
control_path = /tmp/ansible-%%h-%%p-%%r
|
Key settings:
pipelining = True — reduces SSH connections per task. Huge speedup.
host_key_checking = False — don’t prompt for new host keys on first
connect (safe in a homelab with known hardware).
stdout_callback = yaml — prettier output.
Inventory#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
# inventory/production.yml
all:
children:
proxmox_hosts:
hosts:
srv1:
ansible_host: 10.0.20.30
ansible_user: root
docker_hosts:
hosts:
docker-01:
ansible_host: 10.0.20.31
ansible_user: gntech
ansible_become: yes
docker-02:
ansible_host: 10.0.20.32
ansible_user: gntech
ansible_become: yes
routers:
hosts:
router-01:
ansible_host: 10.0.20.1
ansible_user: admin
ansible_network_os: routeros
|
Ansible pulls facts about each host on first run — OS, IPs, interfaces,
disk, memory — and makes them available as variables like
ansible_os_family, ansible_default_ipv4.address, etc.
First Playbook: System Baseline#
Apply this to every new host. It ensures you can reach it, it has sane
defaults, and Docker is ready.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
|
# playbooks/system-base.yml
---
- name: System baseline for all Linux hosts
hosts: all
become: yes
vars:
timezone: America/Santo_Domingo
admin_user: gntech
ssh_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
tasks:
- name: Set timezone
community.general.timezone:
name: "{{ timezone }}"
- name: Install essential packages
apt:
name:
- htop
- iotop
- curl
- wget
- git
- ufw
- fail2ban
- unattended-upgrades
state: present
update_cache: yes
- name: Create admin user
user:
name: "{{ admin_user }}"
groups: sudo,docker
shell: /bin/bash
append: yes
state: present
- name: Deploy SSH key
authorized_key:
user: "{{ admin_user }}"
key: "{{ ssh_key }}"
state: present
- name: Harden SSH
lineinfile:
path: /etc/ssh/sshd_config
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
state: present
validate: 'sshd -t -f %s'
notify: restart sshd
loop:
- { regexp: '^PermitRootLogin', line: 'PermitRootLogin prohibit-password' }
- { regexp: '^PasswordAuthentication', line: 'PasswordAuthentication no' }
- { regexp: '^PubkeyAuthentication', line: 'PubkeyAuthentication yes' }
- name: Configure UFW defaults
ufw:
direction: "{{ item.dir }}"
policy: "{{ item.policy }}"
loop:
- { dir: incoming, policy: deny }
- { dir: outgoing, policy: allow }
- name: Allow SSH through UFW
ufw:
rule: allow
port: '22'
proto: tcp
- name: Enable UFW
ufw:
state: enabled
handlers:
- name: restart sshd
service:
name: sshd
state: restarted
|
Run it:
1
|
ansible-playbook playbooks/system-base.yml --limit docker_hosts
|
First run on a raw Debian install takes about 30 seconds. Subsequent
runs finish in under 2 seconds because everything is already in the
desired state — that’s idempotence in action.
Managing Docker Containers with Ansible#
The community.docker collection lets you define containers, images,
networks, and volumes as Ansible state. This is more powerful than
Docker Compose for multi-host setups because Ansible handles ordering,
secrets, and host-specific variables.
Install the Collection#
1
|
ansible-galaxy collection install community.docker
|
Add it to requirements.yml so your project is reproducible:
1
2
3
|
# requirements.yml
collections:
- name: community.docker
|
Then install with:
1
|
ansible-galaxy collection install -r requirements.yml
|
Docker Host Playbook#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
# playbooks/docker-services.yml
---
- name: Deploy Docker containers across hosts
hosts: docker_hosts
become: yes
vars_files:
- vars/containers.yml
- vault/secrets.yml
tasks:
- name: Ensure Docker is installed
apt:
name: docker-ce
state: present
tags: [docker, never]
- name: Create required Docker networks
community.docker.docker_network:
name: "{{ item.name }}"
driver: "{{ item.driver | default('bridge') }}"
ipam_config: "{{ item.ipam | default(omit) }}"
state: present
loop: "{{ docker_networks }}"
tags: [networks]
- name: Create persistent volumes
community.docker.docker_volume:
name: "{{ item }}"
state: present
loop: "{{ docker_volumes }}"
tags: [volumes]
- name: Deploy containers
community.docker.docker_container:
name: "{{ item.name }}"
image: "{{ item.image }}"
state: started
restart_policy: unless-stopped
networks: "{{ item.networks | default(omit) }}"
ports: "{{ item.ports | default(omit) }}"
volumes: "{{ item.volumes | default(omit) }}"
env: "{{ item.env | default(omit) }}"
labels: "{{ item.labels | default(omit) }}"
memory: "{{ item.memory | default('512m') }}"
cpus: "{{ item.cpus | default(1.0) }}"
restart: "{{ item.force_restart | default(false) }}"
loop: "{{ docker_containers }}"
tags: [containers]
|
Container Definitions#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
# vars/containers.yml
docker_networks:
- name: web
driver: bridge
ipam:
- subnet: "172.20.0.0/24"
- name: internal
driver: bridge
ipam:
- subnet: "172.20.1.0/24"
docker_volumes:
- nginx_data
- postgres_data
- prometheus_data
- grafana_data
docker_containers:
- name: nginx
image: nginx:stable-alpine
networks:
- name: web
ports:
- "80:80"
- "443:443"
volumes:
- "nginx_data:/etc/nginx/conf.d"
labels:
traefik.enable: "true"
- name: postgres
image: postgres:16-alpine
networks:
- name: internal
volumes:
- "postgres_data:/var/lib/postgresql/data"
env:
POSTGRES_PASSWORD: "{{ vault_postgres_password }}"
POSTGRES_DB: homelab
memory: 1g
cpus: 2.0
- name: prometheus
image: prom/prometheus:latest
networks:
- name: internal
- name: web
volumes:
- "prometheus_data:/prometheus"
memory: 1g
cpus: 1.0
|
Run it:
1
|
ansible-playbook playbooks/docker-services.yml -l docker-01
|
One command deploys the entire service stack to any Docker host. If you
add a new host to the inventory, run the same playbook against it.
Secrets with Ansible Vault#
Hardcoding passwords in your playbooks is bad, and committing them to
Git is worse. Ansible Vault encrypts sensitive variables at rest.
Create an Encrypted Vault File#
1
|
ansible-vault create vault/secrets.yml
|
You’ll be prompted for a vault password. Inside, add your secrets:
1
2
3
4
|
vault_postgres_password: "S3cur3P@ssw0rd!"
vault_traefik_email: "[email protected]"
vault_cloudflare_token: "abc123def456..."
vault_wireguard_private_key: "..."
|
Save and exit. The file is encrypted with AES-256.
Use Secrets in Playbooks#
Reference the vault file in your playbook:
1
2
|
vars_files:
- vault/secrets.yml
|
Then use variables like {{ vault_postgres_password }} in your
container configs or templates.
Run with Vault Password#
1
|
ansible-playbook playbooks/docker-services.yml --ask-vault-pass
|
Or use a vault password file (never commit this!):
1
2
3
|
echo "my-vault-password" > ~/.ansible-vault-pass
chmod 600 ~/.ansible-vault-pass
ansible-playbook playbooks/docker-services.yml --vault-password-file ~/.ansible-vault-pass
|
Edit or Rotate the Vault#
1
2
|
ansible-vault edit vault/secrets.yml
ansible-vault rekey vault/secrets.yml # change password
|
Templates: Dynamic Config Files#
Ansible’s template module processes Jinja2 templates with host
variables. This is invaluable for service configs that differ per host.
Example: Prometheus Target Config#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
# roles/prometheus/templates/prometheus.yml.j2
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'node'
static_configs:
- targets:
{% for host in groups['docker_hosts'] %}
- '{{ hostvars[host].ansible_default_ipv4.address }}:9100'
{% endfor %}
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
|
Task to Deploy Template#
1
2
3
4
5
6
7
8
|
- name: Deploy Prometheus config
template:
src: prometheus.yml.j2
dest: /opt/prometheus/prometheus.yml
owner: nobody
group: nogroup
mode: '0644'
notify: restart prometheus
|
Ansible replaces {{ groups['docker_hosts'] }} with the actual IPs
from your inventory at runtime. Add a new host to the inventory, run
the playbook, and Prometheus discovers it automatically.
Running Playbooks: Common Workflows#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
# Ping all hosts without applying changes (test connectivity)
ansible all -m ping
# Gather facts without running tasks
ansible all -m setup
# Run system baseline on everything
ansible-playbook playbooks/system-base.yml
# Deploy Docker services to one host only
ansible-playbook playbooks/docker-services.yml -l docker-01
# Deploy with secrets
ansible-playbook playbooks/site.yml --ask-vault-pass
# Check mode — see what would change without applying
ansible-playbook playbooks/site.yml --check --diff
# Limit to specific tags
ansible-playbook playbooks/docker-services.yml --tags networks,volumes
# See playbook with step-by-step confirmation
ansible-playbook playbooks/site.yml --step
|
The --check --diff combination is incredibly useful. It shows exactly
which files would change and how, without touching anything. Run this
before any production change.
Full Site Playbook#
Tie everything together with a master playbook:
1
2
3
4
5
6
7
8
9
10
11
12
|
# playbooks/site.yml
---
- name: Apply system baseline to all hosts
import_playbook: system-base.yml
- name: Deploy Docker on designated hosts
import_playbook: docker-services.yml
when: inventory_hostname in groups['docker_hosts']
- name: Configure monitoring
import_playbook: monitoring.yml
when: inventory_hostname in groups['monitoring_hosts']
|
Now a single command manages your entire homelab:
1
|
ansible-playbook playbooks/site.yml
|
A fresh Proxmox LXC goes from a bare Debian install to running
Postgres, Prometheus, Grafana, and Nginx in about 90 seconds with
Ansible. No manual SSH, no copy-pasting, no “I forgot to install
that package.”
Git Workflow for Playbooks#
Your Ansible project belongs in Git. The structure is simple:
1
2
3
4
5
6
7
8
9
10
11
|
cd ~/homelab-ansible
git init
git add .
git commit -m "Initial Ansible homelab project"
# .gitignore
echo "vault-password-file" >> .gitignore
echo "*.retry" >> .gitignore
git add .gitignore
git commit -m "Add gitignore"
|
Commit the encrypted vault/secrets.yml — it’s safe because it’s
encrypted. Only share the vault password with trusted operators via a
secure channel (or store it in your password manager).
When you need to rebuild a host after a disk failure or migrate to new
hardware:
1
2
3
|
git clone [email protected]:you/homelab-ansible.git
cd homelab-ansible
ansible-playbook playbooks/site.yml --ask-vault-pass
|
That’s it. Your entire infrastructure, restored from a single git clone.
Pro Tips#
Use --diff liberally. Always run ansible-playbook --check --diff
before applying changes to production services. It catches mistakes.
Start small. Don’t write a 200-line playbook on day one. Write a
playbook that sets the hostname and installs htop. Then add Docker.
Then add one container. Build incrementally.
Leverage gather_facts: no for speed. If a playbook doesn’t need
system facts (e.g., it just deploys containers by name), disable fact
gathering to save ~5 seconds per host.
Use tags on everything. Tags let you run specific parts of a
playbook without the full 10-minute run. Tag docker, ufw,
monitoring, etc.
Ansible pull for unattended hosts. If a host reboots but isn’t
reachable by your control node, run ansible-pull from cron on the
target. It checks out your Git repo and applies the playbook locally.
1
2
|
# Target host's crontab — check and apply every 30 minutes
*/30 * * * * /usr/bin/ansible-pull -U https://github.com/you/homelab-ansible.git playbooks/site.yml --vault-password-file ~/.vault-pass
|
Fragile hosts get any_errors_fatal: true. If a misconfigured
host crashes during the playbook, stop immediately instead of
continuing to wreck other hosts.
What’s Next#
You now have a project skeleton, a Docker deployment playbook, secret
management with ansible-vault, and Jinja2 templating for dynamic configs.
The next step is building out roles — reusable task bundles for common
services like Traefik, Prometheus/Grafana, Postgres, and WireGuard.
In a follow-up post, I’ll cover writing reusable Ansible roles,
handling RouterOS via the community.routeros collection, and using
Semaphore or AWX for a web UI over your playbooks.
For now: clone your inventory, write a system baseline playbook, and
run it against your least-critical host. You’ll be automating your
entire homelab inside an afternoon.