Add the documentation and the agent instructions
lint / yamllint + ansible-lint + syntax (push) Successful in 2m52s
lint / shellcheck (push) Successful in 7s
lint / secret scan (push) Failing after 3s

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]>
This commit is contained in:
Lucas Winther
2026-09-16 03:58:24 +02:00
co-authored by Claude Opus 5
parent 971034e630
commit e1f152ec3e
12 changed files with 1307 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
# 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 up` it 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.yml` at 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 `0700` or `0750`.
- **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`, not `pre_tasks`.** `pre_tasks` run before the role
creates the stack directory, and `ansible.builtin.file` stamps 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.