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 <[email protected]>
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
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. |
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.