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