Docker Compose describes what runs on a server. Ansible describes how the server gets there: Docker installed, project files in place, secrets delivered, and the stack started or updated. Together, they give you repeatable deployments to one or many VPS servers, without logging in by hand.
Basics: Ansible playbooks and Docker Compose in production.
Project layout
deploy/
├── inventory.ini
├── group_vars/
│ └── app/
│ ├── vars.yml
│ └── vault.yml # encrypted with ansible-vault
├── files/app/compose.yaml
├── templates/app.env.j2
└── deploy.yml
# group_vars/app/vars.yml
app_dir: /opt/app
app_image_tag: "1.8.3"
db_password: "{{ vault_db_password }}"
See Ansible Vault.
Install Docker
ansible-galaxy collection install community.docker
# deploy.yml (part 1)
- hosts: app
become: true
tasks:
- name: Docker prerequisites
ansible.builtin.apt:
name: [ca-certificates, curl]
update_cache: true
- name: Docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Docker repository
ansible.builtin.apt_repository:
repo: "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
filename: docker
- name: Docker Engine and Compose plugin
ansible.builtin.apt:
name: [docker-ce, docker-ce-cli, containerd.io, docker-compose-plugin]
update_cache: true
- name: Docker running
ansible.builtin.service:
name: docker
state: started
enabled: true
(Use linux/debian in the URLs for Debian. The geerlingguy.docker role does the same, if you prefer a ready-made role. See Ansible roles.)
Deploy the stack
# deploy.yml (part 2)
- name: App directory
ansible.builtin.file:
path: "{{ app_dir }}"
state: directory
mode: "0750"
- name: Compose file
ansible.builtin.copy:
src: files/app/compose.yaml
dest: "{{ app_dir }}/compose.yaml"
mode: "0640"
- name: Environment file with secrets
ansible.builtin.template:
src: app.env.j2
dest: "{{ app_dir }}/.env"
mode: "0600"
no_log: true
- name: Start or update the stack
community.docker.docker_compose_v2:
project_src: "{{ app_dir }}"
pull: always # fetch new images for changed tags
remove_orphans: true
state: present
register: compose
- name: Prune old images
community.docker.docker_prune:
images: true
when: compose.changed
{# templates/app.env.j2 #}
APP_IMAGE_TAG={{ app_image_tag }}
DB_PASSWORD={{ db_password }}
In compose.yaml, reference the tag: image: registry.example.com/myapp:${APP_IMAGE_TAG}. See Docker image tags best practices.
Deploy a new version
ansible-playbook -i inventory.ini deploy.yml -e app_image_tag=1.8.4 --ask-vault-pass
Ansible updates .env, and the Compose module recreates only the containers whose image or config changed.
Several servers behind a load balancer: add serial: 1 to the play, so servers update one at a time, and a health check task between them avoids taking everything down at once:
- hosts: app
serial: 1
tasks:
# … deploy tasks …
- name: Wait for health
ansible.builtin.uri:
url: "http://127.0.0.1:8080/health"
register: h
until: h.status == 200
retries: 20
delay: 3
Run it from CI
A CI job (GitHub Actions, GitLab CI, Gitea Actions) can build the image, push it to a registry, then run the playbook with the new tag. Store the SSH key and vault password as CI secrets. See GitHub Actions deploy to VPS and GitHub Actions secrets.
Tips
- Keep port publishing private (
127.0.0.1:port), and run a reverse proxy role. See Docker and UFW. - Back up volumes before major upgrades (an Ansible task can trigger the backup script). See Docker volume backups.
- Use
--check --diffto preview config changes, and--limit web1to deploy to one server first.
Quick reference
| Task | Module / command |
|---|---|
| Install Docker | apt repository + docker-ce, docker-compose-plugin |
| Copy files | copy, template (mode: 0600 for .env) |
| Run stack | community.docker.docker_compose_v2 |
| New version | -e app_image_tag=… |
| Rolling | serial: 1 + health check |
Related: Ansible server hardening and Docker VPS hosting.