Files
config-public/docs/architecture.md
T
Lucas WintherandClaude Opus 5 e1f152ec3e
lint / yamllint + ansible-lint + syntax (push) Successful in 2m52s
lint / shellcheck (push) Successful in 7s
lint / secret scan (push) Failing after 3s
Add the documentation and the agent instructions
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]>
2026-09-16 03:58:24 +02:00

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 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.