Ansible for the Homelab — Part 5: Deploying Services with Docker

Ansible for the Homelab — Part 5: Deploying Services with Docker
Ansible for the Homelab — The Daily Stomp

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.