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 <noreply@anthropic.com>
8.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.If the stack needs a credential, put it in OpenBao now and ship a
.env.examplelisting the variable names. Pipe the value in on stdin rather than passing<field>=<value>, which lands it in the host's process table and your shell history, and strip the trailing newline —<field>=-stores stdin byte for byte and the role refuses a value containing one:openssl rand -hex 32 | tr -d '\n' \ | docker exec -i openbao bao kv put kv/stacks/<stack> <field>=- -
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, andopenbaomust come before any stack that setsstack_secrets. -
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. Leave a stack out only if it must never deploy from CI — see the table below.
-
Group it, if it belongs in one. Add it to a line in
server/<host>/stack-groupssostackctl up <group>on that host includes it. Optional:stackctl allfinds every stack regardless. -
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, host preparation, or a system service | Write plain tasks. Do not force the role | banner.yml, bootstrap.yml |
| Something must happen between the payload landing and the stack starting (backup, migration, a precondition) | Use the two entry points, tasks_from: sync then tasks_from: up, and gate the middle on pull | default('policy') == 'always' or compose_stack_synced |
webapp.yml |
| It is replacing a hand-deployed instance whose data must be copied in first | -e start=false, copy the data into the bind mounts, then deploy normally. Write the steps into the playbook's header comment |
none — see "Deploying without starting" in AGENTS.md |
| 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 |
| …but a restart is itself disruptive (a vault that seals, a runner that kills its own job) | Report on compose_stack_synced instead of restarting, and let a person choose when |
openbao.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 credential | stack_secrets, naming the vault path and field for each variable. Put the value in OpenBao first |
webapp.yml — see the contract in AGENTS.md and docs/secrets.md |
The stack needs a .env placed by hand, on a host with no vault |
stat + assert that it exists on the host first |
metrics.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. The host needs bootstrap.yml -e deploy_sudo=true |
metrics.yml, openbao.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 |
| Deploying it from CI would kill the CI job — the runner itself, or whatever the runner depends on | Leave it out of site.yml and the deploy form, and assert in pre_tasks that GITHUB_ACTIONS, GITEA_ACTIONS and GITLAB_CI are not true — the dispatch API accepts any stack with a playbook |
none here |
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. Usestack_secrets, or place it on the host by hand. - Storing a vault value with a trailing newline.
bao kv put ... field=-keeps the newline fromechooropenssl rand, and the role refuses to render it. Pipe throughtr -d '\n'. - A reload task with no
when: compose_stack_synced. Every run then reports a change, the playbook is never idempotent, andchanged=0stops meaning anything. - Committing an upstream example compose file without reading it. They are
written for a quick start, not for a host. Look for three things: a password
given as a default (
${POSTGRES_PASSWORD:-changeme}), which is a committed secret — make it${VAR:?}and usestack_secrets; database or cache ports published on every interface, which nothing outside the stack needs; andKEY:valueentries in a list-formenvironment:, which set a variable literally namedKEY:valueand nothing else — the list form needs=. - A stateful service with no bind mount. An app that falls back to SQLite
inside its container loses everything the next time Compose recreates it. Find
where it writes and mount that directory; if the image runs as a fixed uid,
give the directory to it with
stack_dirs. - 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.