Files
config-public/skills/adding-a-stack/SKILL.md
T
Lucas WintherandClaude Opus 5.5 35b1378080
lint / yamllint + ansible-lint + syntax (push) Successful in 56s
lint / shellcheck (push) Successful in 7s
lint / secret scan (push) Successful in 2s
Add stackctl for running stacks by hand on a host
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>
2026-10-03 17:49:12 +02:00

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

  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. Group it, if it belongs in one. Add it to a line in server/<host>/stack-groups so stackctl up <group> on that host includes it. Optional: stackctl all finds every stack regardless.

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

  7. 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.
  • 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 use stack_secrets; database or cache ports published on every interface, which nothing outside the stack needs; and KEY:value entries in a list-form environment:, which set a variable literally named KEY:value and 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 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.