Ansible for the Homelab — Part 5: Deploying Services with Docker
In Parts 3 and 4 we got our playbooks organized and our secrets locked down. Now it's time to actually deploy something. This is where Ansible starts earning its keep - instead of SSHing into a machine and running docker compose up by hand, you describe the deployment in a playbook and let Ansible handle it.
The Pattern
Every service in the homelab follows the same layout:
/opt/docker/
├── portainer/
│ └── docker-compose.yml
├── ghost/
│ └── docker-compose.yml
└── n8n/
└── docker-compose.yml
Each service gets its own directory under /opt/docker/. Ansible creates that directory, writes the compose file (with variables already substituted), and starts the containers. Consistent structure means you always know where to find things.
Installing Docker First
Before deploying containers you need Docker on the host. That's a separate playbook targeting the docker_hosts group:
ansible-playbook playbooks/docker-setup.yml
Run it once. It installs Docker CE, the compose plugin, and the Python Docker SDK that Ansible's container modules need. After that, any deployment playbook can target those hosts.
A Simple Deployment: Portainer
Portainer is a good first deployment - no database, no secrets, just a container that needs a volume and a port. Here's the full playbook:
---
- name: Deploy Portainer
hosts: tools_servers
become: yes
vars:
portainer_port: 9000
portainer_data_dir: /opt/docker/portainer
tasks:
- name: Create portainer directory
file:
path: "{{ portainer_data_dir }}"
state: directory
mode: '0755'
- name: Write docker-compose file
copy:
dest: "{{ portainer_data_dir }}/docker-compose.yml"
content: |
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: unless-stopped
ports:
- "{{ portainer_port }}:9000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- {{ portainer_data_dir }}/data:/data
- name: Start portainer
community.docker.docker_compose_v2:
project_src: "{{ portainer_data_dir }}"
state: present
The copy module writes the compose file directly to the host with your variables already substituted. docker_compose_v2 is Ansible's module for managing compose projects - it handles docker compose up, pull, and idempotency automatically. Running the playbook a second time won't restart a container that's already running and unchanged.
Services That Need Secrets
When a service needs passwords - like Ghost with its MySQL database - you pull them from vault variables and inject them into the compose file:
- name: Deploy Ghost
hosts: tools_servers
become: yes
vars_files:
- ../group_vars/vault.yml
vars:
ghost_port: 2368
ghost_data_dir: /opt/docker/ghost
ghost_domain: ghost.example.com
tasks:
- name: Write docker-compose file
copy:
dest: "{{ ghost_data_dir }}/docker-compose.yml"
content: |
services:
ghost:
image: ghost:5-alpine
environment:
url: https://{{ ghost_domain }}
database__connection__password: {{ vault_ghost_db_password }}
db:
image: mysql:8.0
environment:
MYSQL_PASSWORD: {{ vault_ghost_db_password }}
The vault variables are decrypted at runtime and substituted directly into the compose file. The compose file on disk contains the real values, but those stay on the server - not in your repository.
Health Checks
After starting a service it's worth verifying it came up cleanly:
- name: Verify Ghost is running
shell: docker logs ghost 2>&1 | grep -q "Ghost is running"
register: health_check
retries: 12
delay: 5
until: health_check.rc == 0
retries and until tell Ansible to keep trying until the condition is met, with a delay between attempts. Useful for services that take a moment to initialize.
Running a Deployment
ansible-playbook playbooks/portainer-deploy.yml
No flags needed - the inventory already knows which host is tools_servers. To redeploy after changing the compose configuration, run the same command. Ansible will update the file and restart the affected containers.
What's Next
Part 6 closes the loop - once your services are running, you need them reachable from outside the network. We'll use Ansible to automate Cloudflare Tunnel configuration and DNS, so adding a new service means updating a vars file and running two playbooks.