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]>
5.4 KiB
name, description
| name | description |
|---|---|
| adding-a-stack | Use when adding a new service to this repository, moving a service to a different host, or bringing a hand-deployed service under Ansible and CI. |
Adding a stack
A stack is one Docker Compose deployment on one host. Adding one touches four
files and one button. AGENTS.md holds the conventions and the full
compose_stack role contract — read it first; this file is the order of work.
Steps
-
Payload. Put the compose file and its configuration in
server/<host>/<stack>/. Payloads are copied verbatim, so do not template or restructure them. Never commit a.env, a key, or a secret written inline in a compose file — readdocs/secrets.mdbefore you decide something is fine to commit. -
Playbook. Create
ansible/playbooks/<stack>.yml. Copystatic-site.yml; most stacks are a name and a network. Tag the play with the stack name. -
Register it. Add an
import_playbookentry toansible/playbooks/site.ymlin dependency order — the reverse proxy ownsproxy-netand the vhost configuration, so it stays first. -
Give it a button. All three CI configurations need the stack by name, and none of them can generate the list:
.github/workflows/deploy.yml— add it to thestackinput'soptions:..gitea/workflows/deploy.yml— the sameoptions:list again. Gitea reads.gitea/workflows/instead of.github/workflows/, so the two files are never both in play and never both wrong at once — which is exactly why one of them gets forgotten..gitlab-ci.yml— add acheck:<stack>anddeploy:<stack>pair extending.checkand.deploy; copy the pair above them, three lines each. Pin the stack withSTACK_FIXED, neverSTACK— a pipeline variable outranks a job's YAMLvariables:, so a button named withSTACKis hijacked by any pipeline triggered with a different one.
Delete whichever platforms you are not using rather than letting them rot.
-
Verify statically.
make checkfrom the repository root. It must pass; this is the definition of done for the playbook. -
Deploy. Run the check mode, read the diff, then deploy. Never run a playbook against a host unless the person you are working with asked for it in that turn.
Does it fit the shared role?
| If | Do | Example |
|---|---|---|
| It is not a Compose stack — one file into another stack's directory, or a system service | Write plain tasks. Do not force the role | banner.yml |
| Something must happen between the payload landing and the stack starting (backup, migration) | Use the two entry points, tasks_from: sync then tasks_from: up, and gate the middle on pull | default('policy') == 'always' |
webapp.yml |
| The payload is bind-mounted config, which Compose never recreates a container for | Add a post_tasks reload keyed on compose_stack_synced |
reverse-proxy.yml, metrics.yml |
The compose file is not named docker-compose.yml, or there are several |
stack_files |
webapp.yml |
| The same payload serves several hosts, or several instances | stack_src, and stack_dest for the second instance |
metrics.yml, webapp-staging.yml |
The stack needs a .env that is deliberately never shipped |
stat + assert that it exists on the host first |
webapp.yml |
| It needs an external Docker network | stack_networks |
any of them |
| A bind mount would otherwise be created root-owned by Docker | stack_dirs; omit mode so an existing directory is left alone |
reverse-proxy.yml |
| A bind mount must be owned by a uid from inside the container | stack_dirs with owner/group and recurse, and no mode |
metrics.yml |
| The image is built from a Dockerfile in the payload | stack_build, set to always only on an update run |
webapp.yml |
| Hosts differ in some small way | A dict keyed by inventory_hostname, looked up in the role vars |
metrics.yml |
Extend the role's variable contract rather than adding a second role or loose tasks. If a stack needs something the contract cannot express, say so instead of working around it — that is information about the contract.
Common mistakes
- Skipping step 3 or 4. The stack then works from a workstation and is invisible in CI, which is exactly how services end up hand-deployed and undocumented. This is the most common failure and the least visible.
- Pre-creating a Postgres data directory in
stack_dirs. Postgres refuses to start unless it is0700or0750, so leave it to the image. - Setting
modeon a bind mount that already has data. It rewrites the permissions of everything the container has written there. Setowner/groupwithrecurseinstead, and nomode. - A
pre_taskschown instead ofstack_dirs.pre_tasksrun before the stack directory exists, and the escalatedfiletask stamps its owner onto every parent it creates — leaving the payload sync unable to write into the stack's own directory. - Expecting
.envto reach the host. It is excluded from every sync by design. Secrets live on the host. - A reload task with no
when: compose_stack_synced. Every run then reports a change, the playbook is never idempotent, andchanged=0stops meaning anything. - Putting the
stack_*vars insideroles:for a two-phase playbook. They are scoped to that invocation and the secondinclude_rolewill not see them. Declare them at play level.