stack_secrets reads KV v2 on the target host and writes the stack's .env at 0600, failing loudly on a sealed vault, a bad token, a missing field or an unsafe value. Adds an example OpenBao stack on app and moves webapp onto it; metrics keeps a hand-placed .env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.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.
-
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. - 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.