bootstrap.yml prepares a host as an admin via -u; new-deploy-key.sh makes the key pair and refuses to write inside the repo. CI deploys refuse the bootstrap playbook, since the dispatch API ignores the dropdown. Co-Authored-By: Claude Opus 5.5 <[email protected]>
7.7 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 and bootstrap.yml are the deliberate exceptions. Neither is a
Compose stack at all: one drops a single shared file into another stack's
directory, the other prepares a host — a user, a key, a directory, a sudoers
file. Forcing either 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 are so few such playbooks 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.
Starting is a flag too
start is the same idea pointed at a different question. pull asks which
images; start asks whether to run Compose at all. -e start=false lands the
payload, creates the directories and the networks, and stops.
The alternative — a one-off playbook that syncs and does not start, or a
commented-out roles: block during a migration — is a copy of the real playbook
that exists only while somebody is mid-task, which is exactly when a copy is most
likely to be deployed by accident. A flag leaves one playbook, and leaves it
honest.
The role gates the Compose task itself rather than the up.yml include, because
a two-phase playbook calls tasks_from: up directly and would otherwise slip
past the flag.
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.