Containers

Deploying Dockerized Apps with Ansible: From Compose to Production

Teach me Ansible | 2026-08-10 | 20 min read

Stop SSH-ing into servers to run docker compose up by hand. Learn how to deploy Dockerized applications with Ansible — from installing the Docker engine to templated Compose stacks, zero-downtime redeploys, private registries, and a complete production-grade Flask deployment.

Why Ansible + Docker Beats Hand-Run Commands

Docker solved the "works on my machine" problem, but it created a new one: how do you get containers onto servers reliably? Many teams still deploy by SSH-ing in and typing docker pull, docker stop, docker run with a dozen flags from memory (or from a text file someone keeps on their laptop). That approach breaks down fast:

  • No idempotency - Running the same commands twice can fail or create duplicates; Ansible modules converge to a desired state
  • No audit trail - Hand-run commands leave no record; playbooks live in git with full history
  • Configuration drift - Each server ends up subtly different; Ansible enforces one source of truth
  • No secrets management - Registry passwords end up in shell history; Ansible Vault encrypts them
  • Doesn't scale - Deploying to 20 hosts by hand is misery; Ansible does them in parallel
  • Environment sprawl - Staging and production diverge; Jinja2 templates parameterize the differences

Ansible sits in the sweet spot between raw shell scripts and heavyweight orchestrators like Kubernetes. If you run a handful of Compose stacks on VMs — which describes an enormous amount of real-world infrastructure — Ansible plus the community.docker collection gives you repeatable, reviewable deployments without a control plane to babysit.

Setting Up the community.docker Collection

All Docker modules live in the community.docker collection. Install it from Galaxy, and pin the version in requirements.yml so your whole team runs the same code:

# Install the collection
ansible-galaxy collection install community.docker

# Verify installation
ansible-galaxy collection list | grep docker

Better: declare it in requirements.yml:

---
collections:
  - name: community.docker
    version: ">=3.10.0"
ansible-galaxy collection install -r requirements.yml

The collection talks to the Docker daemon through the Docker SDK for Python, so the target hosts need the SDK installed. For the Compose v2 modules you only need the docker CLI plugin — more on that below.

- name: Install Docker SDK for Python
  ansible.builtin.pip:
    name: docker
    state: present

Installing the Docker Engine with Ansible

Option 1: geerlingguy.docker (Recommended)

The community-standard role handles repos, packages, the compose plugin, and service configuration across Debian, Ubuntu, and RHEL families:

# requirements.yml
---
roles:
  - name: geerlingguy.docker
    version: "7.4.1"
---
- name: Install Docker engine
  hosts: docker_hosts
  become: yes

  roles:
    - role: geerlingguy.docker
      vars:
        docker_edition: ce
        docker_install_compose_plugin: true
        docker_users:
          - "{{ deploy_user }}"
        docker_daemon_options:
          log-driver: json-file
          log-opts:
            max-size: "10m"
            max-file: "3"

Note the docker_daemon_options block — setting log rotation at the daemon level is the single most important thing you can do to stop containers from filling your disk with logs.

Option 2: Manual Tasks

If you prefer full control, the manual install is only a few tasks on Ubuntu/Debian:

---
- name: Install Docker manually
  hosts: docker_hosts
  become: yes

  tasks:
    - name: Install prerequisites
      ansible.builtin.apt:
        name:
          - ca-certificates
          - curl
          - gnupg
        state: present
        update_cache: yes

    - name: Add Docker GPG key
      ansible.builtin.apt_key:
        url: https://download.docker.com/linux/ubuntu/gpg
        state: present

    - name: Add Docker repository
      ansible.builtin.apt_repository:
        repo: "deb https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
        state: present

    - name: Install Docker packages
      ansible.builtin.apt:
        name:
          - docker-ce
          - docker-ce-cli
          - containerd.io
          - docker-compose-plugin
        state: present

    - name: Ensure Docker is running and enabled
      ansible.builtin.service:
        name: docker
        state: started
        enabled: yes

Managing Images and Containers

docker_image: Pulling and Building

- name: Pull a specific image tag
  community.docker.docker_image:
    name: nginx
    tag: "1.27-alpine"
    source: pull

- name: Build an image from a local Dockerfile
  community.docker.docker_image:
    name: myapp
    tag: "{{ app_version }}"
    source: build
    build:
      path: /opt/myapp/src
      pull: yes
    force_source: yes

- name: Remove an old image
  community.docker.docker_image:
    name: myapp
    tag: "1.0.0"
    state: absent

docker_container: Running Containers

The docker_container module is idempotent: if the running container's configuration differs from what you declare, Ansible recreates it; if it matches, nothing happens. That is exactly the convergence behavior hand-run docker run can never give you.

- name: Run application container
  community.docker.docker_container:
    name: myapp
    image: "myapp:{{ app_version }}"
    state: started
    restart_policy: unless-stopped
    published_ports:
      - "127.0.0.1:8000:8000"
    env:
      DATABASE_URL: "{{ vault_database_url }}"
      SECRET_KEY: "{{ vault_secret_key }}"
      APP_ENV: "{{ app_env }}"
    volumes:
      - /opt/myapp/data:/app/data
    networks:
      - name: app_net
    memory: "512M"
    log_driver: json-file
    log_options:
      max-size: "10m"
      max-file: "3"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 20s

Compose Deployments with docker_compose_v2

Most real applications are multi-container stacks, and Compose files are the lingua franca. The docker_compose_v2 module drives the docker compose CLI plugin directly (the older docker_compose module targeted the deprecated Python docker-compose v1 and should not be used for new work).

- name: Deploy the stack
  community.docker.docker_compose_v2:
    project_src: /opt/myapp
    state: present
    pull: policy
    remove_orphans: yes
  register: compose_result

- name: Show which containers changed
  ansible.builtin.debug:
    var: compose_result.containers

Useful parameters to know:

  • pull: always - Always pull images before starting (essential for latest-style tags)
  • recreate: always - Force recreation even if config is unchanged
  • services: - Restrict the operation to specific services in the stack
  • wait: yes - Block until containers are running/healthy (compose's --wait)
  • state: absent - Tear the stack down

Templating .env and Compose Files with Jinja2

This is where Ansible earns its keep: one Compose template, many environments. Ship the compose file and the .env file as Jinja2 templates, driven by group_vars.

templates/docker-compose.yml.j2:

services:
  web:
    image: "{{ registry_url }}/myapp:{{ app_version }}"
    restart: unless-stopped
    env_file: .env
    ports:
      - "127.0.0.1:{{ app_port }}:8000"
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 15s
      timeout: 5s
      retries: 3

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: "{{ db_name }}"
      POSTGRES_USER: "{{ db_user }}"
      POSTGRES_PASSWORD: "{{ vault_db_password }}"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U {{ db_user }}"]
      interval: 10s
      retries: 5

volumes:
  pgdata:

templates/env.j2:

APP_ENV={{ app_env }}
SECRET_KEY={{ vault_secret_key }}
DATABASE_URL=postgresql://{{ db_user }}:{{ vault_db_password }}@db:5432/{{ db_name }}

Deploy both with the template module — and notice that a changed template triggers a redeploy via handler or a direct compose run:

- name: Render compose file
  ansible.builtin.template:
    src: docker-compose.yml.j2
    dest: /opt/myapp/docker-compose.yml
    mode: "0644"
  register: compose_file

- name: Render .env file (contains secrets - lock it down)
  ansible.builtin.template:
    src: env.j2
    dest: /opt/myapp/.env
    mode: "0600"
    owner: root
  register: env_file

- name: Apply the stack
  community.docker.docker_compose_v2:
    project_src: /opt/myapp
    state: present
    pull: policy
  when: compose_file.changed or env_file.changed or force_deploy | default(false)
Secrets: keep vault_db_password and friends in an Ansible Vault-encrypted vars file (ansible-vault create group_vars/production/vault.yml). Never commit plaintext secrets, and always set mode: "0600" on rendered .env files.

Zero-ish-Downtime Redeploys

True zero-downtime needs a load balancer draining connections, but you can get very close on a single host: pull the new image first (so the swap is fast), recreate the container, then verify health before declaring victory — and fail the play if the app never comes up.

- name: Pull the new image before touching the running container
  community.docker.docker_image:
    name: "{{ registry_url }}/myapp"
    tag: "{{ app_version }}"
    source: pull

- name: Recreate the web service with the new image
  community.docker.docker_compose_v2:
    project_src: /opt/myapp
    services:
      - web
    state: present
    pull: never          # already pulled above
    recreate: always
    wait: yes
    wait_timeout: 120

- name: Verify the application is actually healthy
  ansible.builtin.uri:
    url: "http://127.0.0.1:{{ app_port }}/health"
    status_code: 200
  register: health
  retries: 10
  delay: 5
  until: health.status == 200

Because the image is pulled in advance, the gap between "old container stops" and "new container serves" is typically a second or two. Rolling across multiple hosts? Add serial: 1 to the play and put the health check before the next host starts — a failed health check halts the rollout instead of taking down the whole fleet.

Private Registry Login

Authenticate on the target host before pulling private images, and log out when the play finishes:

- name: Log in to private registry
  community.docker.docker_login:
    registry_url: "{{ registry_url }}"
    username: "{{ registry_user }}"
    password: "{{ vault_registry_token }}"
  no_log: true

# ... pull / deploy tasks ...

- name: Log out of registry
  community.docker.docker_login:
    registry_url: "{{ registry_url }}"
    state: absent

Use a read-only deploy token (GitHub PAT with read:packages, GitLab deploy token, etc.), store it in Vault, and set no_log: true so the credential never appears in playbook output.

Log Rotation and Cleanup with docker_prune

Every redeploy leaves behind a dangling image. Six months of deploys later, the disk is full at 3 a.m. Bake cleanup into the deploy playbook itself:

- name: Prune unused Docker objects
  community.docker.docker_prune:
    images: yes
    images_filters:
      dangling: false
      until: "168h"      # only images older than 7 days
    containers: yes
    networks: yes
    builder_cache: yes
    volumes: no           # never auto-prune volumes - that's your data

Pair this with the daemon-level json-file log limits shown earlier (max-size: 10m, max-file: 3) and disk-full pages become a memory. For belt-and-braces, run the prune task on a schedule via ansible.builtin.cron or a systemd timer.

Complete Example: Flask + Gunicorn Behind Nginx

Let's put it all together: a Flask application served by gunicorn, fronted by an nginx reverse proxy, deployed as one Compose stack. Project layout:

deploy/
├── inventory/production/hosts.yml
├── group_vars/
│   └── production/
│       ├── vars.yml
│       └── vault.yml          # ansible-vault encrypted
├── templates/
│   ├── docker-compose.yml.j2
│   ├── env.j2
│   └── nginx.conf.j2
└── deploy.yml

group_vars/production/vars.yml:

---
app_name: flaskapp
app_version: "{{ lookup('env', 'APP_VERSION') | default('latest', true) }}"
app_env: production
app_dir: /opt/flaskapp
registry_url: ghcr.io/myorg
server_name: app.example.com
gunicorn_workers: 4

templates/docker-compose.yml.j2:

services:
  app:
    image: "{{ registry_url }}/{{ app_name }}:{{ app_version }}"
    restart: unless-stopped
    env_file: .env
    command: >
      gunicorn --bind 0.0.0.0:8000
      --workers {{ gunicorn_workers }}
      --access-logfile - wsgi:app
    expose:
      - "8000"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 15s

  nginx:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      app:
        condition: service_healthy

templates/nginx.conf.j2:

server {
    listen 80;
    server_name {{ server_name }};
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name {{ server_name }};

    ssl_certificate     /etc/letsencrypt/live/{{ server_name }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ server_name }}/privkey.pem;

    location / {
        proxy_pass http://app:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

deploy.yml — the whole deployment in one playbook:

---
- name: Deploy Flask application
  hosts: production
  become: yes

  tasks:
    - name: Ensure app directory exists
      ansible.builtin.file:
        path: "{{ app_dir }}"
        state: directory
        mode: "0755"

    - name: Log in to GitHub Container Registry
      community.docker.docker_login:
        registry_url: ghcr.io
        username: "{{ registry_user }}"
        password: "{{ vault_registry_token }}"
      no_log: true

    - name: Render deployment files
      ansible.builtin.template:
        src: "{{ item.src }}"
        dest: "{{ app_dir }}/{{ item.dest }}"
        mode: "{{ item.mode }}"
      loop:
        - { src: docker-compose.yml.j2, dest: docker-compose.yml, mode: "0644" }
        - { src: env.j2, dest: .env, mode: "0600" }
        - { src: nginx.conf.j2, dest: nginx.conf, mode: "0644" }
      register: rendered

    - name: Pull the new application image
      community.docker.docker_image:
        name: "{{ registry_url }}/{{ app_name }}"
        tag: "{{ app_version }}"
        source: pull

    - name: Deploy / update the stack
      community.docker.docker_compose_v2:
        project_src: "{{ app_dir }}"
        state: present
        pull: never
        remove_orphans: yes
        wait: yes
        wait_timeout: 120

    - name: Verify HTTPS endpoint responds
      ansible.builtin.uri:
        url: "https://{{ server_name }}/health"
        status_code: 200
      register: health
      retries: 10
      delay: 5
      until: health.status == 200
      delegate_to: localhost
      become: no

    - name: Prune old images
      community.docker.docker_prune:
        images: yes
        images_filters:
          until: "168h"

Run it:

APP_VERSION=1.4.2 ansible-playbook -i inventory/production deploy.yml \
  --ask-vault-pass

CI/CD Integration Pattern

The playbook above slots straight into a pipeline: CI builds and pushes the image, then calls Ansible with the freshly-built tag. A GitHub Actions example:

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build and push image
        run: |
          docker build -t ghcr.io/myorg/flaskapp:${GITHUB_REF_NAME#v} .
          echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker push ghcr.io/myorg/flaskapp:${GITHUB_REF_NAME#v}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Ansible + collection
        run: |
          pipx install ansible-core
          ansible-galaxy collection install community.docker

      - name: Run deployment playbook
        env:
          APP_VERSION: ${{ github.ref_name }}
          ANSIBLE_VAULT_PASSWORD: ${{ secrets.VAULT_PASSWORD }}
        run: |
          echo "$ANSIBLE_VAULT_PASSWORD" > .vault-pass
          ansible-playbook -i deploy/inventory/production deploy/deploy.yml \
            --vault-password-file .vault-pass
          rm -f .vault-pass

Key properties of this pattern:

  • Immutable versioned images - The git tag becomes the image tag becomes app_version; no latest ambiguity
  • Rollback is a re-run - Deploy an older version by re-running the pipeline (or playbook) with the previous tag
  • Health-gated - The uri verification fails the pipeline if the release is broken, so you find out from CI, not from customers
  • Same playbook everywhere - Engineers run the exact playbook CI runs, against staging inventory, before it ever reaches production

Conclusion

Ansible and Docker are a natural pairing: Docker packages the application, Ansible describes how it runs in each environment. With the community.docker collection you get idempotent images, containers, and Compose stacks; with Jinja2 templates you get one definition parameterized across staging and production; with Vault you get secrets that never touch git in plaintext; and with health-checked, image-prepulled redeploys you get releases measured in seconds of disruption instead of minutes.

Start small: convert one hand-deployed Compose stack into a playbook with templated .env and compose files. Once you trust it, wire it into CI, add serial rolling deploys across hosts, and schedule docker_prune. You'll never type a 12-flag docker run command over SSH again.

Pro Tip

Add --check --diff to your deploy playbook run before the real one: the template tasks will show you exactly which config lines are about to change on the server. Catching a wrong variable in a diff is a lot cheaper than catching it in production.