Deploying Dockerized Apps with Ansible: From Compose to Production
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 forlatest-style tags)recreate: always- Force recreation even if config is unchangedservices:- Restrict the operation to specific services in the stackwait: 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)
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; nolatestambiguity - Rollback is a re-run - Deploy an older version by re-running the pipeline (or playbook) with the previous tag
- Health-gated - The
uriverification 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.