Add the compose_stack role, inventory and playbooks

One role that syncs a payload and brings the stack up, configured entirely
through stack_* variables, plus six playbooks that each demonstrate one part
of that contract. Every playbook runs standalone and is tagged with its
stack name, so site.yml --tags <stack> works.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-16 03:58:01 +02:00
co-authored by Claude Opus 5
parent 7cdf9b854b
commit 7b52e9af9e
16 changed files with 606 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Ansible only discovers an ansible.cfg in the current working directory, so
# every command in this repository is run from ansible/. See "Getting started"
# in the README, and ANSIBLE_CONFIG for the CI case.
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
retry_files_enabled = False
stdout_callback = default
callback_result_format = yaml
display_skipped_hosts = False
interpreter_python = auto_silent
# CI runners are ephemeral, so a known_hosts file never survives to be checked
# against. Turn this back on if you deploy from a durable control node or put a
# bastion in front of the hosts — it is the one setting here worth revisiting.
host_key_checking = False
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ServerAliveInterval=30
+20
View File
@@ -0,0 +1,20 @@
---
ansible_user: deploy
# Override per host in inventory/host_vars/<host>.yml if sshd moves off 22.
ansible_port: 22
# Path to the deploy private key, produced by scripts/load-ssh-key.sh. The key
# itself is never stored in this repository — see docs/secrets.md.
ansible_ssh_private_key_file: "{{ lookup('ansible.builtin.env', 'SSH_KEY_PATH') }}"
# Where stacks live on the hosts. A stack deploys to {{ stack_root }}/<stack>
# unless its playbook overrides stack_dest.
stack_root: /srv/stacks
# Image pull policy, overridden per run with `-e pull=always` to update a stack.
# See "Deploy versus update" in the README.
pull: policy
# Repository root, so playbooks and the role can find payloads under server/
# regardless of the directory ansible-playbook was invoked from.
repo_root: "{{ (inventory_dir ~ '/../..') | realpath }}"
+14
View File
@@ -0,0 +1,14 @@
---
# Two example hosts. Connection settings live in group_vars/all.yml, and key
# material lives nowhere in this repository — see docs/secrets.md.
#
# The names here must match the directory names under server/: the compose_stack
# role resolves a payload as server/{{ inventory_hostname }}/{{ stack_name }}.
all:
hosts:
# Public entry point: the reverse proxy and its certificates.
edge:
ansible_host: edge.example.com
# Everything behind it.
app:
ansible_host: app.example.com
+50
View File
@@ -0,0 +1,50 @@
---
# Not a Compose stack, and deliberately not forced into the role.
#
# banner.html is a single shared file that belongs to the *reverse-proxy* stack's
# directory. There is no payload directory of its own and no compose file to
# bring up, and compose_stack syncs a whole directory and then calls
# `docker compose up`, so it cannot express this. Plain tasks instead.
#
# This is the escape hatch, and it is a small one. If you find yourself writing a
# third playbook like this, that is a sign the role's contract should grow
# instead — see AGENTS.md.
- name: Deploy the shared default-vhost banner
hosts:
- edge
- app
gather_facts: false
tags: [banner]
vars:
# One file, shared by both hosts, unlike the per-host stack payloads.
banner_src: "{{ repo_root }}/server/shared/components/banner.html"
banner_html_dir: "{{ stack_root }}/reverse-proxy/proxy-data/html"
tasks:
- name: Ensure the reverse proxy's html directory exists
# reverse-proxy.yml creates this too. Repeated here so this playbook runs
# standalone on a host where the proxy payload has never been synced —
# every playbook in this repository stands on its own (AGENTS.md, rule 2).
ansible.builtin.file:
path: "{{ banner_html_dir }}"
state: directory
mode: "0755"
- name: Install the shared banner
ansible.builtin.copy:
src: "{{ banner_src }}"
dest: "{{ banner_html_dir }}/index.html"
mode: "0644"
notify: Reload nginx
handlers:
# html/ is a bind mount, so the container is never recreated for this file;
# the reload is the only thing that applies it. It fires only when the copy
# above reported a change, so a repeat run is a genuine no-op. It fails if
# the reverse proxy is not running, which means reverse-proxy.yml has never
# run on this host.
- name: Reload nginx
ansible.builtin.command:
cmd: docker exec reverse-proxy nginx -s reload
changed_when: true
+68
View File
@@ -0,0 +1,68 @@
---
# One stack, both hosts, one payload.
#
# Demonstrates three things:
# - stack_src pointing outside the per-host convention, so two hosts share a
# single payload instead of keeping duplicate copies in step by hand;
# - a per-host variable map, which is how a play covers hosts that differ
# without growing a second playbook or a `when:` on every task;
# - stack_dirs entries that set ownership, and ones that only set a mode.
- name: Deploy metrics
hosts:
- edge
- app
gather_facts: false
tags: [metrics]
vars:
# Only the app host runs anything worth proxying a dashboard to, so only it
# joins the private backend network. Keyed by inventory_hostname and looked
# up below — adding a host means adding a line here, not a branch.
metrics_networks:
edge:
- proxy-net
app:
- proxy-net
- backend
metrics_dirs:
# The Grafana image runs as uid 472 and this is a bind mount, so the
# directory has to be owned by that uid on the host. `recurse: true` with
# no `mode`: recursing a mode here would rewrite the permissions of
# Grafana's SQLite database, which holds hashed credentials and datasource
# secrets.
#
# This belongs in stack_dirs rather than a pre_tasks chown. pre_tasks run
# before the role creates the stack directory, and an escalated
# ansible.builtin.file on a child stamps its owner onto every parent it
# creates on the way down — which would leave the payload sync, running as
# the deploy user, unable to write into the stack's own directory on a host
# where metrics has never been deployed.
- path: "{{ stack_root }}/metrics/grafana-data"
owner: "472"
group: "472"
recurse: true
# Written by the prometheus container, which runs as nobody (65534). A mode
# rather than an owner because the uid is not stable across image versions.
# Narrow this to an owner once you have confirmed the uid on your hosts.
- path: "{{ stack_root }}/metrics/prometheus-data"
mode: "0777"
roles:
- role: compose_stack
vars:
stack_name: metrics
# Both hosts run the same thing, so the payload lives under server/shared/
# rather than being copied into server/edge/ and server/app/.
stack_src: "{{ repo_root }}/server/shared/metrics"
stack_networks: "{{ metrics_networks[inventory_hostname] }}"
stack_dirs: "{{ metrics_dirs }}"
post_tasks:
- name: Reload prometheus so a changed scrape configuration takes effect
# prometheus.yml is bind-mounted, so Compose never recreates the container
# for a change to it. Prometheus re-reads its configuration on SIGHUP.
ansible.builtin.command:
cmd: docker kill --signal=SIGHUP prometheus
when: compose_stack_synced | default(false)
changed_when: true
+45
View File
@@ -0,0 +1,45 @@
---
# The public entry point, and the stack every other stack depends on: it owns
# proxy-net and the generated vhost configuration, so site.yml runs it first.
#
# Demonstrates the two things a payload made of plain configuration files needs:
# bind-mount directories created before the container starts, and a reload after
# the payload changes.
- name: Deploy the reverse proxy
hosts: edge
gather_facts: false
tags: [reverse-proxy]
roles:
- role: compose_stack
vars:
stack_name: reverse-proxy
stack_networks:
- proxy-net
stack_dirs:
# Bind mounts the compose file declares but no payload provides. Docker
# would otherwise create them root-owned on first start, and html/ in
# particular has to be writable by the deploy user before banner.yml
# can put a file in it.
#
# No `mode` on any of them on purpose: the role only applies the
# attributes an entry actually sets, so a directory that already exists
# on the host keeps whatever permissions it has.
- path: "{{ stack_root }}/reverse-proxy/proxy-data/conf"
- path: "{{ stack_root }}/reverse-proxy/proxy-data/vhost"
- path: "{{ stack_root }}/reverse-proxy/proxy-data/html"
- path: "{{ stack_root }}/reverse-proxy/proxy-data/dhparam"
# certs/ and acme/ are deliberately absent. They hold live private keys
# and are managed by acme-companion; nothing here should touch their
# ownership or permissions.
post_tasks:
- name: Reload nginx so changed vhost and conf files take effect
# conf/ and vhost/ are bind mounts, so editing a file in them never causes
# Compose to recreate the container — this reload is the only thing that
# applies the change. compose_stack_synced is set by the role from the
# payload sync, so a run that changed nothing stays a genuine no-op.
ansible.builtin.command:
cmd: docker exec reverse-proxy nginx -s reload
when: compose_stack_synced | default(false)
changed_when: true
+25
View File
@@ -0,0 +1,25 @@
---
# Every stack, in dependency order. Each imported playbook also runs on its own
# — see AGENTS.md, rule 2.
#
# ansible-playbook playbooks/site.yml # converge everything
# ansible-playbook playbooks/site.yml --tags metrics # one stack
# ansible-playbook playbooks/site.yml -e pull=always # update everything
#
# Order matters. The reverse proxy owns proxy-net and the vhost configuration
# that every other stack registers against, so it goes first; banner.yml writes
# into the proxy's html directory, so it goes second.
- name: Reverse proxy
ansible.builtin.import_playbook: reverse-proxy.yml
- name: Default vhost banner
ansible.builtin.import_playbook: banner.yml
- name: Metrics
ansible.builtin.import_playbook: metrics.yml
- name: Static site
ansible.builtin.import_playbook: static-site.yml
- name: Webapp
ansible.builtin.import_playbook: webapp.yml
- name: Webapp staging
ansible.builtin.import_playbook: webapp-staging.yml
+16
View File
@@ -0,0 +1,16 @@
---
# The reference playbook. Most stacks look exactly like this: a name, the
# networks the compose file declares external, and nothing else. Copy this file
# when you add a service, and only reach for the rest of the contract when the
# stack genuinely needs it.
- name: Deploy the static site
hosts: app
gather_facts: false
tags: [static-site]
roles:
- role: compose_stack
vars:
stack_name: static-site
stack_networks:
- proxy-net
+35
View File
@@ -0,0 +1,35 @@
---
# A second instance of webapp, from the same payload.
#
# Demonstrates stack_src, stack_dest and stack_env together: one set of compose
# files, deployed twice to the same host under different directories with
# different settings. This is much better than a forked copy of the payload,
# which drifts the first time someone fixes a bug in only one of them.
#
# Staging deliberately has no backup-and-migrate phase. It is rebuilt from the
# same images and its data is disposable, so the whole two-phase apparatus in
# webapp.yml would be ceremony here.
- name: Deploy webapp staging
hosts: app
gather_facts: false
tags: [webapp-staging]
roles:
- role: compose_stack
vars:
stack_name: webapp-staging
# The same payload as the production instance.
stack_src: "{{ repo_root }}/server/app/webapp"
# ...deployed somewhere else. Without this, stack_dest would default to
# {{ stack_root }}/webapp-staging, which happens to be right here, but
# spelling it out is what makes the pairing with stack_src obvious.
stack_dest: "{{ stack_root }}/webapp-staging"
stack_networks:
- proxy-net
stack_files:
- docker-compose.yml
# Per-instance settings, merged into the compose environment on top of
# PWD. The compose file reads these; nothing in the payload knows which
# instance it is.
stack_env:
COMPOSE_PROJECT_NAME: webapp-staging
+111
View File
@@ -0,0 +1,111 @@
---
# The stack that needs more than "sync, then up".
#
# Demonstrates the whole heavy end of the contract: an assertion that a host-side
# .env exists, a payload built from a Dockerfile, a second compose file holding
# maintenance-only services, and work that has to happen *between* the payload
# landing and the stack starting.
#
# The shape to copy is the two entry points. `roles: - role: compose_stack` runs
# sync and then up, which is what almost every stack wants. When something has to
# happen in between, include the role twice with tasks_from — do not invent a
# second mechanism, and do not write a separate *-update.yml playbook.
- name: Deploy webapp
hosts: app
gather_facts: false
tags: [webapp]
vars:
webapp_dir: "{{ stack_root }}/webapp"
# Both compose files, in the order `docker compose -f` wants. Only the
# maintenance runs below use this; see the note on stack_files.
webapp_maintenance_compose: >-
-f docker-compose.yml -f docker-compose.migrate.yml
# The single flag that separates a deploy from an update. Read it through
# `default` at every use site so the playbook still runs when `pull` is
# undefined.
webapp_updating: "{{ (pull | default('policy')) == 'always' }}"
# The compose_stack contract, declared at play level so that both the sync
# phase and the up phase below see the same values. Inside `roles:` they
# would be scoped to the role invocation and the second include would not
# see them.
stack_name: webapp
stack_dest: "{{ webapp_dir }}"
stack_networks:
- proxy-net
# Deliberately only docker-compose.yml. docker-compose.migrate.yml adds
# one-shot `run --rm` targets; bringing the stack up with both files would
# start them as long-running services.
stack_files:
- docker-compose.yml
# Rebuild the image only on an update run. Rebuilding on every deploy would
# report a change every time and quietly break idempotency.
stack_build: "{{ 'always' if webapp_updating else 'policy' }}"
stack_dirs:
# Bind mounts Docker would otherwise create root-owned. ./pgdata is
# deliberately absent: Postgres refuses to start unless its data directory
# is 0700 or 0750, so it must be left to the image.
- path: "{{ stack_root }}/webapp/uploads"
- path: "{{ stack_root }}/webapp/backups"
pre_tasks:
# Every variable in this stack's compose files comes from .env with no
# defaults, and the role never ships .env to a host. Fail loudly and say what
# to do, rather than starting a stack with empty credentials.
- name: Look for the webapp .env on the host
ansible.builtin.stat:
path: "{{ webapp_dir }}/.env"
register: webapp_env
changed_when: false
- name: Verify the webapp .env is present on the host
ansible.builtin.assert:
that:
- webapp_env.stat.exists
fail_msg: >-
{{ webapp_dir }}/.env is missing on {{ inventory_hostname }}. webapp
reads every credential from it and it is never synced from this
repository; copy .env.example on the host and fill it in, or restore
it from your secret store.
quiet: true
tasks:
# Phase 1: deliver the payload, including the current Dockerfile and
# migration script, without starting anything.
- name: Sync the webapp payload
ansible.builtin.include_role:
name: compose_stack
tasks_from: sync
# Phase 2: maintenance, against the payload that was just delivered and only
# on an update run. An ordinary deploy must never back up or migrate
# anything — see "Deploy versus update" in the README.
- name: Build the webapp maintenance images
ansible.builtin.command:
cmd: docker compose {{ webapp_maintenance_compose }} build
chdir: "{{ webapp_dir }}"
changed_when: true
when: webapp_updating
- name: Back up the webapp database before migrating
ansible.builtin.command:
cmd: docker compose {{ webapp_maintenance_compose }} run --rm backup
chdir: "{{ webapp_dir }}"
changed_when: true
when: webapp_updating
- name: Migrate the webapp database
# app/migrate.sh is idempotent by design, so repeating an update run after
# a failure elsewhere is safe.
ansible.builtin.command:
cmd: docker compose {{ webapp_maintenance_compose }} run --rm migrate
chdir: "{{ webapp_dir }}"
changed_when: true
when: webapp_updating
# Phase 3: start the stack, on the new images if any were pulled or built.
- name: Start webapp
ansible.builtin.include_role:
name: compose_stack
tasks_from: up
+8
View File
@@ -0,0 +1,8 @@
---
collections:
# docker_compose_v2 — bringing stacks up.
- name: community.docker
version: ">=3.10.0"
# synchronize — rsyncing stack payloads to the hosts.
- name: ansible.posix
version: ">=1.5.0"
@@ -0,0 +1,53 @@
---
# The variable contract. Playbooks set these; nothing else should. Every entry is
# documented in AGENTS.md — keep the two in step.
# Required. Names the stack, and by default its payload and destination too.
stack_name: ""
# Payload directory in this repository, and where it lands on the host. Override
# stack_src to share one payload between hosts or between two instances of the
# same application; override stack_dest when the remote directory cannot be
# named after the stack.
stack_src: "{{ repo_root }}/server/{{ inventory_hostname }}/{{ stack_name }}"
stack_dest: "{{ stack_root | default('/srv/stacks') }}/{{ stack_name }}"
# Extra directories to create before the stack starts. Each entry needs a `path`;
# `mode`, `owner`, `group` and `recurse` are optional and applied only when
# given, so a directory that already exists on the host is never re-chmodded or
# re-chowned.
stack_dirs: []
# Extra environment for the compose invocation. PWD is always set to stack_dest;
# anything here is merged on top.
stack_env: {}
# External Docker networks the compose file expects to already exist.
stack_networks: []
# Compose files, relative to stack_dest, in the order `docker compose -f` wants.
stack_files:
- docker-compose.yml
# Never ship secrets to the host through the payload. Extend this list; do not
# replace it without understanding what you are letting through.
stack_exclude:
- .env
# Whether the sync removes host files that are no longer in the payload. Off by
# default: a stack's directory usually also holds data the payload knows nothing
# about (bind mounts, certificates, logs).
stack_prune: false
# present, absent, or restarted.
stack_state: present
# Image pull policy for this invocation. Defaults to the repo-wide `pull` flag so
# `-e pull=always` keeps working, but because it is a role variable a playbook
# can override it for a single call — for example to sync with the old images,
# run a database upgrade, and only then bring the stack up on the new ones.
stack_pull: "{{ pull | default('policy') }}"
# Compose build policy. `policy` builds only when the image is missing; stacks
# that build from a Dockerfile in their payload want `always` on an update run.
stack_build: policy
+25
View File
@@ -0,0 +1,25 @@
---
galaxy_info:
role_name: compose_stack
namespace: example
author: The contributors
description: Sync a Docker Compose stack payload to a host and bring the stack up.
license: MIT
min_ansible_version: "2.15"
platforms:
- name: EL
versions:
- "9"
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
galaxy_tags:
- docker
- compose
- deployment
dependencies: []
@@ -0,0 +1,20 @@
---
# Two entry points, so a playbook that must do maintenance between the payload
# landing and the stack starting can interleave its own tasks:
#
# - ansible.builtin.include_role:
# name: compose_stack
# tasks_from: sync
# - name: Run the database migration
# ...
# - ansible.builtin.include_role:
# name: compose_stack
# tasks_from: up
#
# Using the role normally (roles: - role: compose_stack) runs both, in order.
# playbooks/webapp.yml is the worked example.
- name: Sync the payload to the host
ansible.builtin.include_tasks: sync.yml
- name: Start the stack
ansible.builtin.include_tasks: up.yml
@@ -0,0 +1,82 @@
---
- name: Verify the caller named a stack
ansible.builtin.assert:
that:
- stack_name | length > 0
fail_msg: compose_stack requires stack_name
quiet: true
- name: Look for the payload on the control node
ansible.builtin.stat:
path: "{{ stack_src }}"
register: compose_stack_payload
delegate_to: localhost
become: false
changed_when: false
# A missing payload otherwise surfaces as an empty sync that reports success and
# leaves the host running whatever was there before.
- name: Verify the payload directory exists
ansible.builtin.assert:
that:
- compose_stack_payload.stat.isdir | default(false)
fail_msg: "no payload directory at {{ stack_src }}"
quiet: true
- name: Create the stack directory
ansible.builtin.file:
path: "{{ stack_dest }}"
state: directory
mode: "0755"
- name: Create the extra directories the stack needs
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
# All four default to omit: create the directory if it is absent, and leave
# whatever is already on the host alone. Bind mounts are routinely created
# root-owned by Docker on first start, and re-chmodding those breaks them.
mode: "{{ item.mode | default(omit) }}"
owner: "{{ item.owner | default(omit) }}"
group: "{{ item.group | default(omit) }}"
# For a bind mount whose existing tree is owned by the wrong uid. Set it with
# `owner`/`group` and no `mode`: recursing a mode over live data rewrites the
# permissions of everything the container has already written there.
recurse: "{{ item.recurse | default(omit) }}"
# Escalate only to set an owner or group. Creating these as root would defeat
# the point of pre-creating them at all — callers do it precisely so the bind
# mount is *not* root-owned when Docker first starts the stack. Unescalated
# they land as the deploy user, which is the user Compose runs as.
become: "{{ item.owner is defined or item.group is defined }}"
loop: "{{ stack_dirs }}"
loop_control:
label: "{{ item.path }}"
# Deliberately the docker CLI rather than community.docker.docker_network: that
# module needs the Docker SDK for Python installed on every host, and creating a
# network is the only thing this repository would need it for.
- name: Create the external Docker networks
ansible.builtin.command:
cmd: docker network create {{ item }}
register: compose_stack_network
changed_when: compose_stack_network.rc == 0
failed_when:
- compose_stack_network.rc != 0
- '"already exists" not in compose_stack_network.stderr'
loop: "{{ stack_networks }}"
- name: Sync the payload to the host
ansible.posix.synchronize:
src: "{{ stack_src }}/"
dest: "{{ stack_dest }}/"
delete: "{{ stack_prune }}"
rsync_opts: "{{ stack_exclude | map('regex_replace', '^', '--exclude=') | list }}"
register: compose_stack_sync
# Bind-mounted configuration changes without the compose file changing, so
# docker_compose_v2 will not recreate the container and the new file has no
# effect. Playbooks key a reload task off this fact — see
# playbooks/reverse-proxy.yml.
- name: Record whether the payload changed
ansible.builtin.set_fact:
compose_stack_synced: "{{ compose_stack_sync.changed }}"
+14
View File
@@ -0,0 +1,14 @@
---
- name: Bring the stack up
community.docker.docker_compose_v2:
project_src: "{{ stack_dest }}"
files: "{{ stack_files }}"
state: "{{ stack_state }}"
pull: "{{ stack_pull }}"
build: "{{ stack_build }}"
remove_orphans: true
# A compose file that interpolates ${PWD} — a common way to hand a host path to
# a sibling container — resolves it against the *invoking shell*, not the
# project directory. The module sets the process working directory but not PWD,
# so ${PWD} would silently become the SSH login directory. Set it explicitly.
environment: "{{ {'PWD': stack_dest} | combine(stack_env) }}"