README for humans, AGENTS.md for agents and contributors, and the docs that outlive any one platform: architecture, secrets, connectivity, triggering a deploy from another repository. CLAUDE.md, GEMINI.md and .claude/skills/ are pointers rather than copies, so every agent and every human reads the same text. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
6.9 KiB
Why it is shaped this way
Short notes on the decisions that are not obvious from the code, and what each
one is buying. Read AGENTS.md for the rules; this is the reasoning behind them.
Payloads are data, not templates
A stack's compose file is copied to the host verbatim. Nothing is templated, nothing is generated.
This is the decision most likely to look wrong at first, because templating is what configuration management is for. The argument against it here:
- You can read the repository and know what is on the host. With templating you know what the host will get after rendering, which is not the same thing at three in the morning.
- You can
docker compose upit by hand. When the deploy path is broken — and the deploy path is what breaks — the payload is still a working compose project you can run from an SSH session. - Upstream projects ship compose files. Nextcloud, Gitea, Discourse and most self-hosted software give you one. Keeping it verbatim means you can diff against theirs when you upgrade. Templating it means you re-derive your changes every time.
- Per-host differences belong in the playbook. Which networks exist, which
directories to create, which compose files to use: these are deployment
concerns and the role contract expresses all of them. See
metrics.yml, which covers two hosts from one payload without templating anything.
The cost is real: genuinely dynamic values have to come from the environment
rather than from Ansible. In practice that means a .env on the host, which is
where the secrets had to live anyway.
One role, and a contract
There is one role. Every stack-specific need is a variable on it, and the list is
documented in AGENTS.md as a contract.
The alternative — a role per stack, or a role per kind of stack — is how these repositories usually grow, and the failure mode is consistent: twelve roles that are each 90% the same, diverging slowly, so that a fix to the sync logic has to be applied twelve times and is applied to nine.
The rule that keeps it honest is in AGENTS.md rule 4: if two playbooks need
the same logic, extend the contract, do not add a second role. When a stack
needs something the contract cannot express, that is information — say so, rather
than working around it with loose tasks.
banner.yml is the deliberate exception. It is not a Compose stack at all: one
shared file dropped into another stack's directory. Forcing it through a role
built around "sync a directory, then docker compose up" would mean weakening
the role for one caller. Plain tasks are the right answer, and the fact that
there is exactly one such playbook is the signal that the contract is holding.
Two entry points, not two playbooks
compose_stack exposes sync and up separately, so a playbook that must do
work in between — a backup, a schema migration — can interleave its own tasks.
The alternative is a second playbook, webapp-update.yml, and the reason to
avoid it is that the two immediately drift. The update playbook gains a network
the deploy playbook does not have; someone fixes a directory in one and not the
other. One playbook that behaves differently under a flag cannot drift from
itself.
Deploy and update are one flag
pull is a single repository-wide extra-var. policy is the default and means
"Compose decides", so nothing is fetched for an image already present. always
fetches newer images and gates the heavier per-stack work.
Two properties follow, and both are load-bearing:
- A default run must never destroy or migrate anything. You should be able to
run
site.ymlat any time, against everything, without thinking about it. That is what makes it useful for convergence after a manual change. - An update run must be safe to repeat. It is not transactional. Something will fail halfway through, and the fix is to run it again.
The SSH_KEY_PATH seam
Ansible knows one thing about credentials: a path to a private key. One script decides where that key comes from.
The payoff is that the same playbooks run unchanged from a laptop, from GitHub Actions, from GitLab CI and from Gitea Actions, and that adding a secret backend is a change to one file that no playbook imports. The script prints a path and never prints key material, so it is safe to call in a CI log.
The constraint that makes it work is worth stating: nothing else in the repository touches key material. The moment a playbook learns how to read a vault, the seam is gone.
Nothing deploys on push
Every deploy path is manual or externally triggered. Merging changes what would be deployed; a person still decides when.
This is a judgement call, not a universal truth. It is right for a handful of long-lived stateful services where a bad deploy means restoring a database, and where the person merging is often not the person who should be watching the deploy. It is wrong for a fleet of stateless services with good rollback, where continuous deployment is the whole point.
If you adopt this repository and your situation is the second one, the change is
small — add a push trigger to the deploy workflow — but make it deliberately.
Bind mounts and the directories nobody creates
A surprising amount of the role contract exists because of one Docker behaviour: a bind mount whose host path does not exist is created by Docker, owned by root. The container then cannot write to it, and the failure surfaces as an application error rather than a permissions one.
stack_dirs pre-creates them as the deploy user. Three details are the result of
getting this wrong:
- Attributes apply only when set. A directory that already exists on the host
keeps its permissions. Re-chmodding a live bind mount is how you break a
Postgres data directory, which refuses to start unless it is
0700or0750. - Escalate only for
owner/group. Creating these as root defeats the purpose. The role escalates only when an entry actually asks for another uid. - It belongs in
stack_dirs, notpre_tasks.pre_tasksrun before the role creates the stack directory, andansible.builtin.filestamps its attributes onto every parent it creates on the way down. An escalated chown of a child leaves the stack directory owned by that uid, and the payload sync — running as the deploy user — then cannot write into it.
Bind-mounted config needs a reload
Compose recreates a container when the compose file changes. It does not know or care that a file bind-mounted into the container changed, so a new nginx vhost or Prometheus scrape config lands on the host and has no effect.
The role sets compose_stack_synced after the sync, and playbooks key a reload
off it. The when: is what keeps the playbook idempotent — without it every run
reports a change forever, and "changed=0 means nothing happened" stops being
true, which is the only cheap signal these deploys have.