One shared script installed into /srv/stacks on every host, with per-host groups in server/<host>/stack-groups. Tested in check.sh. Also two new common mistakes in the adding-a-stack skill. Co-Authored-By: Claude Opus 5.5 <[email protected]>
15 KiB
AGENTS.md
Instructions for LLMs and AI agents working in this repository. Humans should
read README.md first; this file assumes you already know what the repo is for.
This is the one instruction file. CLAUDE.md and GEMINI.md are pointers to it,
and .claude/skills/ points at skills/. Put content here or in skills/,
never in a pointer — the whole arrangement exists so that every agent, and every
human, reads the same text.
What this repository is
A pattern for deploying Docker Compose stacks to a small number of Linux hosts
with Ansible, published as a working example. Every service on every host is a
Compose stack living in a directory under /srv/stacks/ on the target. This repo
holds the files that get copied there, and the playbooks that copy them.
| Host | Address | Runs |
|---|---|---|
edge |
edge.example.com |
The reverse proxy, metrics |
app |
app.example.com |
The applications behind it, metrics |
Both use SSH user deploy on port 22.
Everything under server/ and every host name is an example. example.com
is reserved by RFC 2606 and can never resolve to a real service. Replace them
with yours; the framework around them is the part meant to be reused.
Vocabulary
- host — one machine (
edge,app). The top-level directories underserver/and the inventory names must always agree, because the role resolves a payload asserver/{{ inventory_hostname }}/{{ stack_name }}. - stack — one Docker Compose deployment on one host (
metrics,webapp). - payload — the files under
server/<host>/<stack>/, copied to the host verbatim. Payloads are data. Do not template them, do not restructure them, and do not lint them as Ansible content.
Skills
Step-by-step guides for recurring jobs live in skills/<name>/SKILL.md, outside
any vendor directory.
| Skill | Use when |
|---|---|
adding-a-stack |
Adding a new service, moving one to a different host, or bringing a hand-deployed service under Ansible and CI |
Playbook rules
These are the conventions. Follow them; do not invent per-stack variations.
- One playbook per stack, at
ansible/playbooks/<stack>.yml. If the same stack runs on several hosts it is still one playbook with a host group, not one per host.metrics.ymlis the example. - Self-contained. Every playbook must run standalone as
ansible-playbook playbooks/<stack>.ymlwith no required extra vars.pullandstartare the two optional flags, and both are repository-wide. It declares its ownhosts, its own vars, and everything the stack needs (directories, networks, compose files). Never rely on another playbook having run first. Where that means two playbooks create the same directory, that is correct and the repetition is deliberate. - Tag every play with the stack name, so
site.yml --tags metricsworks. - Reuse goes in
roles/compose_stack, not in copy-pasted tasks. If two playbooks need the same logic, extend the role's variable contract rather than adding a second role. Keep the role count at one unless there is a genuinely different shape of work. - Modules, not shell.
community.docker.docker_compose_v2rather thandocker compose up;ansible.builtin.filerather thanmkdir -m 777. Reach forcommand/shellonly when no module exists, and then setchanged_whenexplicitly. - Fully-qualified collection names everywhere (
ansible.builtin.copy, notcopy). - Name every task, sentence case, imperative:
Sync the metrics payload, notsync files. - Idempotent. A second run must report zero changes, and
--check --diffmust not error. - No secrets in the repository.
.envfiles stay on the hosts — rendered from OpenBao bystack_secrets, or placed by hand. Never commit a key, a password, or a token — including inside a compose file, where no exclude can help you. Seedocs/secrets.md.
The compose_stack role contract
Playbooks configure the role through these variables. Extend this list when a stack needs something new; do not work around it with loose tasks.
| Variable | Default | Purpose |
|---|---|---|
stack_name |
required | Stack identifier, used for logs and tags |
stack_src |
server/{{ inventory_hostname }}/{{ stack_name }} |
Payload directory in this repository |
stack_dest |
{{ stack_root }}/{{ stack_name }} |
Destination directory on the host |
stack_dirs |
[] |
Directories to pre-create (see below) |
stack_networks |
[] |
External Docker networks to ensure exist |
stack_files |
[docker-compose.yml] |
Compose files passed to the module |
stack_exclude |
[.env] |
Paths never synced to the host |
stack_prune |
false |
Delete host files absent from the payload |
stack_env |
{} |
Extra env for compose; PWD is always set |
stack_pull |
{{ pull | default('policy') }} |
Per-invocation override of the pull flag |
stack_build |
policy |
always for stacks built from a Dockerfile |
stack_state |
present |
present, absent, or restarted |
stack_start |
{{ start | default(true) }} |
false syncs and starts nothing |
stack_secrets |
{} |
Secrets to fetch from the vault and render to .env |
stack_vault_addr |
{{ vault_addr | default('http://127.0.0.1:8200') }} |
The vault, as seen from the target host |
stack_vault_mount |
{{ vault_kv_mount | default('kv') }} |
KV v2 mount point |
stack_vault_token |
$VAULT_TOKEN |
Read from the control node's environment |
Worked examples for each: static-site.yml (the minimum), reverse-proxy.yml
(stack_dirs, reload), metrics.yml (stack_src, per-host maps, ownership),
webapp.yml (two phases, stack_files, stack_build, stack_secrets),
openbao.yml (owner-only escalation; a notice rather than a restart on
compose_stack_synced), webapp-staging.yml
(stack_src + stack_dest + stack_env), banner.yml (not a stack at all),
stackctl.yml (not a stack either: one shared file and one per-host file into
stack_root), bootstrap.yml (preparing a host; see docs/bootstrap.md).
stack_dirs
Entries take a path plus optional mode, owner, group and recurse. All
four are applied only when given, so a directory that already exists on the
host is never re-chmodded — several bind mounts are created root-owned by Docker
on first start, and Postgres refuses to start if its data directory is not
0700/0750.
The role escalates only when an entry sets owner or group. Everything
else is created as the deploy user, which is the whole point of pre-creating a
bind mount: a root-owned directory is exactly what you are trying to avoid. A
stack that genuinely needs another uid says so with owner/group — see
metrics.yml, which hands grafana-data to 472 with recurse: true and no
mode.
Put that in stack_dirs rather than a pre_tasks chown. pre_tasks run before
the role creates the stack directory, and ansible.builtin.file stamps the
attributes it was given onto every parent directory it creates on the way down —
so an escalated chown of a child leaves the stack directory itself owned by that
uid, and the payload sync, which runs as the deploy user, cannot write into it.
compose_stack_synced
After the sync the role sets this fact. Use it to fire a reload for payloads that are bind-mounted configuration, which Compose never recreates a container for:
post_tasks:
- name: Reload nginx so changed vhost and conf files take effect
ansible.builtin.command:
cmd: docker exec reverse-proxy nginx -s reload
when: compose_stack_synced | default(false)
changed_when: true
Without the when, every run reports a change and the stack is never idempotent.
Not every service survives a restart unattended. openbao.yml keys a notice
off the fact instead of a restart, because restarting OpenBao seals it and would
fail every stack imported after it in a site.yml run.
stack_secrets
A stack's credentials can come from OpenBao on the host rather than from a
.env someone placed by hand. Each entry names the environment variable and
where its value lives:
stack_secrets:
SECRET_KEY:
path: stacks/webapp
field: secret_key
The role reads them at the end of the sync and writes {{ stack_dest }}/.env at
0600, so -e start=false and two-phase playbooks get the file too. Compose
files are unchanged: keep reading the variable with ${VAR:?message}, which is
still what catches a value that went missing.
The read runs on the target host, against 127.0.0.1:8200. That is what
lets the vault be published nowhere, and it means a deploy works identically
from a laptop and from a CI runner. It also means a host with no vault of its
own needs vault_addr pointing at one it can reach. VAULT_TOKEN is read from
the control node's environment and appears in neither this repository nor the
inventory.
A sealed vault, an unset token, or a path that does not hold the field all fail
before the stack starts, each naming what to fix. No task may print a value:
keep no_log on anything that sees one, and keep the asserts that explain a
failure off loops over the reads, where a failed item would carry the plaintext
into the result. See docs/secrets.md.
Removing stack_secrets from a playbook stops the render on the next deploy;
it does not delete the .env already on the host, which keeps serving its
stale value until something else removes it.
Doing maintenance between sync and up
roles: - role: compose_stack syncs and then starts, which is what almost every
stack wants. When something has to happen in between — a backup, a schema
migration — use the two entry points rather than inventing a second mechanism:
tasks:
- name: Sync the payload
ansible.builtin.include_role:
name: compose_stack
tasks_from: sync
- name: Migrate the database
# ... gated on an update run
- name: Start the stack
ansible.builtin.include_role:
name: compose_stack
tasks_from: up
Declare the stack_* vars at play level so both phases see them; inside
roles: they are scoped to that invocation and the second include will not see
them. webapp.yml is the worked example.
Deploy mode versus update mode
Every playbook serves two purposes and must support both without being edited:
ansible-playbook playbooks/webapp.yml # bring the stack up
ansible-playbook playbooks/webapp.yml -e pull=always # pull newer images
pull is a single repo-wide extra-var passed straight through to
community.docker.docker_compose_v2. Valid values are policy (the default —
Compose decides, so nothing is fetched for images already present), always,
missing and never. Read it as pull | default('policy') at every use site so
the playbook still runs when the var is undefined.
This is also the hook for the heavier work a stack needs only when its images
actually move. Gate those tasks on the same flag rather than creating a separate
*-update.yml playbook:
- name: Back up the database before migrating
ansible.builtin.command:
cmd: docker compose -f docker-compose.yml -f docker-compose.migrate.yml run --rm backup
chdir: /srv/stacks/webapp
when: pull | default('policy') == 'always'
changed_when: true
Two consequences worth stating plainly: a default run must never destroy or migrate anything, and an update run must be safe to repeat.
Deploying without starting
start is the second repo-wide extra-var, and the mirror image of pull:
ansible-playbook playbooks/webapp.yml -e start=false
The payload is synced, stack_dirs and stack_networks are created, and the
play stops before Compose — no container is created, started or recreated. It is
for the case where something has to be put into a bind mount between the files
landing and the stack opening them: a data directory copied across from a
hand-deployed instance, a database restored from a dump. When you bring a
hand-deployed service under Ansible this way, write the steps you used into the
playbook's header comment, so the next migration has a recipe.
Prefer it to commenting a playbook out or running the role with
tasks_from: sync by hand, and prefer the two entry points to it when the step
in the middle is something Ansible should own — start=false is for work a
person does, once. Nothing needs to read the flag: the role gates the Compose
task on it in tasks/up.yml, so it applies to two-phase playbooks too.
Safety
- Never run a playbook against a real host unless the user asks in that turn.
Run
--check --difffirst and say what it would change. - Never read or print the contents of
.envfiles, vault files, or SSH keys. If a task genuinely needs one, ask. - Do not
git push, open pull requests, or commit unless asked.
Verifying your work
No Ansible tooling is assumed to be installed in a fresh checkout. One command runs everything CI runs:
make check # or: scripts/check.sh
It runs yamllint from the repository root, installs the Galaxy collections,
runs ansible-lint at the production profile, and syntax-checks every
playbook. Locally it reaches for uvx so the repo needs no virtualenv of its
own; set RUNNER=installed if the tools are already on PATH.
Two things worth knowing if you run the commands by hand instead:
- Run every ansible command from the
ansible/directory.ansible.cfgis only discovered in the current directory, and it is what putsroles/on the roles path. From the repository root you getThe role 'compose_stack' was not found. - Install the collections before linting, or
community.dockerandansible.posixresolve to nothing and the fully-qualified-name checks pass vacuously.
Lint and syntax-check are the definition of "done" for a playbook. They are static and safe; run them yourself rather than asking the user to.