Files
config-public/skills/adding-a-stack/SKILL.md
T
Lucas WintherandClaude Opus 5.5 665ac7d52f Render stack secrets from OpenBao in compose_stack
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>
2026-09-30 15:15:04 +02:00

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

  1. 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 — read docs/secrets.md before you decide something is fine to commit.

    If the stack needs a credential, put it in OpenBao now and ship a .env.example listing 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>=-
    
  2. Playbook. Create ansible/playbooks/<stack>.yml. Copy static-site.yml; most stacks are a name and a network. Tag the play with the stack name.

  3. Register it. Add an import_playbook entry to ansible/playbooks/site.yml in dependency order — the reverse proxy owns proxy-net and the vhost configuration, so it stays first, and openbao must come before any stack that sets stack_secrets.

  4. 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 the stack input's options:.
    • .gitea/workflows/deploy.yml — the same options: 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 a check:<stack> and deploy:<stack> pair extending .check and .deploy; copy the pair above them, three lines each. Pin the stack with STACK_FIXED, never STACK — a pipeline variable outranks a job's YAML variables:, so a button named with STACK is 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.

  5. Verify statically. make check from the repository root. It must pass; this is the definition of done for the playbook.

  6. 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 is 0700 or 0750, so leave it to the image.
  • Setting mode on a bind mount that already has data. It rewrites the permissions of everything the container has written there. Set owner/group with recurse instead, and no mode.
  • A pre_tasks chown instead of stack_dirs. pre_tasks run before the stack directory exists, and the escalated file task stamps its owner onto every parent it creates — leaving the payload sync unable to write into the stack's own directory.
  • Expecting .env to reach the host. It is excluded from every sync by design. Use stack_secrets, or place it on the host by hand.
  • Storing a vault value with a trailing newline. bao kv put ... field=- keeps the newline from echo or openssl rand, and the role refuses to render it. Pipe through tr -d '\n'.
  • A reload task with no when: compose_stack_synced. Every run then reports a change, the playbook is never idempotent, and changed=0 stops meaning anything.
  • Putting the stack_* vars inside roles: for a two-phase playbook. They are scoped to that invocation and the second include_role will not see them. Declare them at play level.