Files
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 <[email protected]>
2026-10-03 17:49:12 +02:00
..

server/ — the payloads

Everything under this directory is data. These files are copied to the hosts verbatim: no templating, no variable substitution by Ansible, no generation.

server/<host>/<stack>/     a stack that runs on exactly one host
server/shared/<stack>/     a payload used by more than one host or instance
server/shared/components/  single files that belong to another stack's directory,
                           or to the stack root itself
server/<host>/stack-groups that host's stackctl groups; a file, not a stack

The directory names under server/ must match the inventory host names, because the role resolves a payload as server/{{ inventory_hostname }}/{{ stack_name }} unless a playbook overrides stack_src.

server/ is excluded from ansible-lint and yamllint. A compose file is not Ansible content, and most of these come from upstream projects that format them their own way — linting them produces noise and pressure to reformat files you want to be able to diff against upstream.

The examples

All seven are fictional. example.com is reserved by RFC 2606 and can never resolve to a real service. Replace them with your own; the framework around them is the reusable part.

Payload Used by Notes
edge/reverse-proxy/ reverse-proxy.yml nginx-proxy + acme-companion. Owns proxy-net. Its proxy-data/ subdirectories are bind mounts holding plain config files.
app/static-site/ static-site.yml One container, one network, no state. The simplest thing that works.
app/webapp/ webapp.yml, webapp-staging.yml Postgres plus an app built from app/Dockerfile. A second compose file holds maintenance-only services. Every credential comes from OpenBao, via stack_secrets.
app/openbao/ openbao.yml The vault, on 127.0.0.1:8200 only. config/openbao.hcl is bind-mounted; a change needs a manual restart and unseal.
shared/metrics/ metrics.yml Prometheus and Grafana, identical on both hosts, so it lives here rather than being duplicated per host. Its .env is placed on each host by hand.
shared/components/banner.html banner.yml A single file that belongs to the reverse proxy's directory on every host.
shared/components/stackctl, <host>/stack-groups stackctl.yml The by-hand stack runner, installed into /srv/stacks on every host, with each host's groups beside it. Not stacks.

Conventions worth copying

Take secrets from .env, and require them. Every credential in these payloads is written as ${VAR:?message}. Compose then refuses to start and names the missing variable, instead of expanding it to an empty string and starting the service with no password. The .env itself is never synced: the role renders it on the host from OpenBao for a stack that sets stack_secrets, and otherwise leaves one placed by hand alone. Ship a .env.example listing the names with no values as documentation. See docs/secrets.md.

Pin image tags. nginx:1.27-alpine, not nginx:latest. A latest tag means a plain deploy will not pick up a new build (Compose keeps the image it has) and an update run picks up whatever was published this morning. Both are surprises.

Declare shared networks external. A network two stacks need is created by the playbook through stack_networks, not by whichever compose file happened to run first. That is what lets every playbook stand on its own.

Do not put host paths in the compose file. Use ${PWD} if a sibling container needs one — the role exports it as the stack's directory on the host, which is the only value that is correct from inside a deploy.