README for humans, AGENTS.md for agents and contributors, and the docs that outlive any one platform: architecture, secrets, connectivity, triggering a deploy from another repository. CLAUDE.md, GEMINI.md and .claude/skills/ are pointers rather than copies, so every agent and every human reads the same text. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
11 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 —pullis the one optional flag. 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. 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 |
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), webapp-staging.yml
(stack_src + stack_dest + stack_env), banner.yml (not a stack at all).
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.
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.
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.