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
+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