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.
+105
View File
@@ -0,0 +1,105 @@
# When CI cannot reach a host
A deploy job that **hangs and then times out** is a different problem from one
that fails, and the distinction tells you where to look.
| What you see | What it means | Where the problem is |
| --- | --- | --- |
| `Permission denied (publickey)` | You reached sshd and it rejected the key | The key, or `authorized_keys` |
| `Connection refused` | You reached the host; nothing is listening on that port | sshd is down, or on another port |
| `Connection timed out` | The packets vanished in transit | The network path |
Only the third is a network problem. No amount of re-pasting the deploy key will
fix it, and it is worth being sure which one you have before spending an
afternoon on the wrong thing.
## The probe
`connectivity.yml` has two jobs.
**`ping`** uses the real deploy key and asks Ansible to talk to every host in the
inventory. Success looks like `SUCCESS => {"ping": "pong"}` for each one, which
proves the runner decoded the key, reached port 22, authenticated, and ran Python
on the far side. That is everything a deploy needs except the playbook. Run this
first, always.
**`probe`** uses no credentials at all and answers a narrower question: *where do
the packets stop?* Run it when `ping` times out. It reports:
| Section | Question |
| --- | --- |
| 0 | Does the runner have working DNS and outbound HTTPS at all? And what is its egress address? |
| 1 | Does each target host answer with an SSH banner? |
| 2 | Does SSH to a known-good third party work — is outbound 22 blocked wholesale? |
| 3 | For each host and port: open, refused, or filtered? |
## Reading the result
| What you see | What it means |
| --- | --- |
| Section 0 has no DNS or no external HTTPS | The runner has no usable egress at all. Talk to whoever runs it. |
| Section 2 gets `SSH-2.0-...` but section 1 does not | Outbound 22 works generally; your hosts are dropping this runner specifically. Take the egress address to your firewall or cloud security group. |
| Section 2 also gets no reply | Outbound 22 is blocked wholesale on the runner's network. |
| Every port but 443 `filtered` in section 3 | An egress allowlist. No alternative SSH port will help — see below. |
Two details in the probe are deliberate and worth keeping if you rewrite it.
**It asks for the SSH banner, not just a TCP connect.** A bare connect can be
answered by a transparent proxy that then says nothing, which looks like success
and is not. A real sshd greets first, so `SSH-2.0-...` is the only honest proof
you reached the actual server.
**It distinguishes `refused` from `filtered`.** "Refused" means you reached the
host and nothing was listening, so that port is *allowed out*. "Filtered" means
the packets vanished. That difference is what tells you whether you are looking
at a service problem or a network policy, and it is the one thing a plain
`timeout` in a deploy log never tells you.
## Fixing it
### Move the runner, not the port
If you find an egress allowlist, resist the urge to move sshd to a different
port. An allowlist permits a *set* of destination ports; it is not a block on 22,
so there is nowhere to move to. Test this rather than assuming it — the probe's
section 3 will tell you in a minute.
The reliable fix is a runner that sits somewhere with a working path to the
hosts. A CI runner only makes **outbound HTTPS** connections to its coordinator,
which is why this works where an inbound exception would need a policy change and
someone else's timeline.
```bash
# GitHub: Settings > Actions > Runners > New self-hosted runner
# GitLab:
sudo gitlab-runner register --url https://gitlab.example.com --token <token>
# Gitea:
act_runner register --instance https://gitea.example.com --token <token> \
--labels deploy-runner:docker://docker.gitea.com/runner-images:ubuntu-latest
```
Then point the workflows at it — `DEPLOY_RUNNER` on GitHub, the `tags:` block in
`.gitlab-ci.yml` on GitLab, and the `runs-on:` label in `.gitea/workflows/` on
Gitea, which has to be edited in the file because Gitea takes no expression
there. All three are described in the CI setup docs.
Two prerequisites, easy to forget: the host firewall or cloud security group must
allow SSH **from the runner's address**, and the deploy key must be authorised
for the deploy user on each host.
### Or ask for an exception
Ask whoever runs the network to allow the runner's egress address outbound to
your hosts on port 22. Less work on your side, but it depends on someone else's
policy and timeline, and it has to be renewed whenever the runner moves.
### In the meantime
Deploy from a workstation that can reach the hosts. The playbooks are identical
either way — that is the point of the `SSH_KEY_PATH` seam.
## A note on ICMP
Many cloud providers drop ICMP by default, so a failed `ping` to a host proves
nothing on its own. The probe does not test it for that reason. Judge
reachability by the SSH banner and the port states.
+170
View File
@@ -0,0 +1,170 @@
# Secrets
Three separate things, handled three different ways. The split matters: the first
is needed to *reach* a host, the second is needed by the software *on* it, and
the third is a mistake.
## 1. The deploy SSH key
Ansible knows exactly one thing about credentials: **`SSH_KEY_PATH`**, a path to
a `0600` private key. `inventory/group_vars/all.yml` feeds it to
`ansible_ssh_private_key_file` and nothing else in the repository touches key
material.
Everything about *where the key comes from* lives in `scripts/load-ssh-key.sh`,
which prints a path and never prints the key:
```bash
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh)" # env: already on disk
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh ci)" # ci: from a CI secret
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh vault)" # vault: from Vault / OpenBao
```
Add a backend there rather than teaching a playbook about secrets. That seam is
the reason the same playbooks run unchanged from a laptop, from GitHub Actions,
from GitLab CI and from Gitea Actions.
Whichever backend you use, the matching **public** key must be in the deploy
user's `~/.ssh/authorized_keys` on every host, or the key is useless.
### Storing it in CI
Store the key **base64-encoded**, as a single line:
```bash
base64 -w0 < ~/.ssh/deploy_key # Linux
base64 < ~/.ssh/deploy_key | tr -d '\n' # macOS
```
```powershell
# Windows PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("$HOME\.ssh\deploy_key"))
```
On Windows use exactly that. `certutil -encode` wraps its output in a header and
line breaks, and `Get-Content` re-encodes the bytes — both produce something that
looks like base64 and decodes to an unusable key. Do not re-wrap the output
either; it is one long line by design.
Then set it as `SSH_PRIVATE_KEY_B64`:
| Platform | Where | Notes |
| --- | --- | --- |
| GitHub | Settings → Environments → `production` → Secrets | Scoped to the environment, so only jobs naming it can read it |
| GitLab | Settings → CI/CD → Variables | Mark **Masked** and **Protected** |
| Gitea | Settings → Actions → Secrets | No environments and no masking controls: any run in the repository can read it. See [ci-gitea.md](ci-gitea.md) |
Why base64 rather than the raw key? GitLab can only *mask* a value that is a
single line with no whitespace, and an OpenSSH private key is neither. GitHub
redacts secrets from logs regardless, but a single-line value survives being
passed between steps without surprises. One encoding satisfies both.
A raw `SSH_PRIVATE_KEY` still works — as a GitLab File-type variable, as a
GitHub multi-line secret, or as a path on disk. The loader accepts all three. It
just cannot be masked on GitLab, so the key can appear in a job log if something
echoes it.
### Storing it in a vault
`vault` reads a HashiCorp Vault or OpenBao KV v2 secret. The expected layout,
overridable with `VAULT_KV_MOUNT` / `VAULT_SECRET_PATH` / `VAULT_SECRET_FIELD`
(or the `BAO_*` spellings):
```
kv/infra/ansible/deploy-key field: private_key
```
A read-only policy is enough:
```hcl
path "kv/data/infra/ansible/deploy-key" {
capabilities = ["read"]
}
```
**Keep the vault off the deploy path.** If the vault runs on a host this
repository deploys to, and it seals on reboot, then a deploy that fetches its key
from the vault cannot run — including the deploy that would fix that host. Host
it elsewhere, or auto-unseal it. A CI secret is the dependable route.
## 2. Per-stack `.env` files
Stack secrets live on the hosts, not here.
The role excludes `.env` from every sync (`stack_exclude`), so editing one in
this repository has no effect, and a host's `.env` is never overwritten by a
deploy. To change one, edit it on the host and redeploy.
Each example stack that needs credentials ships a `.env.example` listing the
variable names with no values, and reads them in its compose file with Compose's
required-variable syntax:
```yaml
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set it in .env on the host}
```
That is worth copying. Without the `:?`, a missing variable expands to the empty
string and Postgres starts with **no password at all** — a failure that looks
like success until someone finds it. With it, Compose refuses to start and names
the variable.
`webapp.yml` goes one step further and asserts the file exists on the host before
doing anything, so the error arrives before the payload is synced rather than
halfway through starting the stack:
```yaml
pre_tasks:
- name: Look for the webapp .env on the host
ansible.builtin.stat:
path: "{{ webapp_dir }}/.env"
register: webapp_env
changed_when: false
- name: Verify the webapp .env is present on the host
ansible.builtin.assert:
that:
- webapp_env.stat.exists
fail_msg: >-
{{ webapp_dir }}/.env is missing on {{ inventory_hostname }}.
quiet: true
```
Do this for any stack whose compose file has no safe defaults.
## 3. Secrets committed to the repository
There is no third category. This section exists because it is the failure mode
this layout is designed to prevent, and it is worth being blunt about why.
A credential written directly into a compose file:
- **cannot be excluded from the sync.** `stack_exclude` keeps `.env` off the
wire; it can do nothing about a value inside `docker-compose.yml`.
- **is in every clone, forever.** Git history is the point of git. Deleting the
file in a later commit changes nothing about the credential.
- **cannot be rotated without a deploy**, so it usually is not rotated at all.
- **escapes its blast radius.** A personal access token carries its creator's
access to everything they can reach, not just the project it was added to. A
cloud application secret outlives the VM it was used on.
If you find one, the fix is to **rotate the credential**, not to delete the file.
Removing it stops new clones carrying it and changes nothing else. Then move it
to a `.env` on the host and read it with `${VAR:?}`.
### The guard
The `lint` workflow — [`.github/workflows/lint.yml`](../.github/workflows/lint.yml),
[`.gitea/workflows/lint.yml`](../.gitea/workflows/lint.yml) and the `lint` job in
`.gitlab-ci.yml` — runs [gitleaks](https://github.com/gitleaks/gitleaks) on every
push, over the working tree **and** the full history: a secret that was committed
and then removed is still leaked, and the scan should say so. `.gitleaks.toml`
allowlists only `*.env.example` files and this document.
On GitHub it runs the upstream Docker image rather than the marketplace action on
purpose: the action requires a licence key for organisation-owned repositories
and silently does nothing without one, which is the worst possible behaviour for
a secret scanner. The Gitea port runs the pinned release binary instead, because
a job there is itself a container with no Docker socket.
Do not add an allowlist entry to make a finding go away. Rotate the credential.
+195
View File
@@ -0,0 +1,195 @@
# Triggering a deploy from another repository
The case: an application repository builds a new image, pushes it, and then asks
*this* repository to redeploy the stack that runs it.
```
application repo config repo
────────────────────── ──────────────────────────
build → push :latest ─── trigger ───► deploy
stack=webapp └─ ansible-playbook
pull=always playbooks/webapp.yml
-e pull=always
```
`pull=always` is not optional here. The image tag has not changed — only its
contents have — and Compose is perfectly happy with the image it already has. A
trigger that forgets it produces a deploy that does nothing and reports success.
## GitHub Actions
`deploy.yml` accepts a `repository_dispatch` event of type `deploy`.
### Caller
```yaml
- name: Redeploy the stack
env:
GH_TOKEN: ${{ secrets.CONFIG_REPO_TOKEN }}
run: |
gh api repos/OWNER/config/dispatches \
--field event_type=deploy \
--field 'client_payload[stack]=webapp' \
--field 'client_payload[pull]=always'
```
`CONFIG_REPO_TOKEN` is a fine-grained personal access token, or a GitHub App
installation token, with **Contents: read and write** on the config repository.
The default `GITHUB_TOKEN` cannot dispatch to another repository.
### What happens downstream
`deploy.yml` validates the payload before doing anything:
1. rejects `stack` unless it matches `[a-z0-9_-]+`, and `pull` unless it is one
of `policy`, `always`, `missing`, `never`,
2. fails if `ansible/playbooks/<stack>.yml` does not exist,
3. materialises the SSH key with `scripts/load-ssh-key.sh`,
4. runs the playbook.
The values arrive from another repository and end up on a command line, so they
are treated as hostile. See "Why untrusted input is validated" in
[ci-github-actions.md](ci-github-actions.md).
### The caller cannot wait
A `repository_dispatch` returns as soon as GitHub accepts the event. The response
contains no run id, so the caller cannot poll for a result either. If the build
pipeline must go red when the deploy fails, you have two options:
- **Put the deploy in the same repository** as the build and use `workflow_call`,
which does propagate failure.
- **Poll.** After dispatching, list workflow runs for `deploy.yml` created after
your dispatch timestamp and wait on the newest. This is fiddly and racy; only
do it if you genuinely need the coupling.
Most of the time you do not. A deploy that fails is visible in the config
repository's Actions tab and can notify on failure there.
## GitLab CI
`deploy:triggered` exists only in pipelines that were triggered from outside, so
it can never fire on a push here.
### Multi-project trigger (preferred)
In the upstream project:
```yaml
deploy-config:
stage: deploy
trigger:
project: your-group/config
branch: main # must be a protected branch — see below
strategy: depend # upstream waits and mirrors the result
variables:
STACK: webapp
PULL: always
```
`strategy: depend` is what makes the build pipeline report the *deployment's*
result. Drop it and the trigger job goes green the moment the request is
accepted, whether or not the deploy worked.
This authenticates with `CI_JOB_TOKEN`, so **the config project must allowlist
the upstream project** under Settings → CI/CD → Job token permissions. Recent
GitLab versions deny cross-project job-token access by default, and the failure
is a **404** rather than a permissions error — which is the most common cause of
"the trigger does nothing".
### Trigger token
For callers that are not GitLab CI — a webhook, a cron box, a person with
`curl`. Create the token under Settings → CI/CD → Pipeline trigger tokens.
```bash
curl --request POST \
--form token="$DEPLOY_TRIGGER_TOKEN" \
--form ref=main \
--form "variables[STACK]=webapp" \
--form "variables[PULL]=always" \
"https://gitlab.example.com/api/v4/projects/your-group%2Fconfig/trigger/pipeline"
```
The project path must be URL-encoded (`%2F` for the slash), or use the numeric
project ID. The response is the created pipeline as JSON; `.web_url` takes you
straight to it.
### One-time setup
1. **Allowlist the caller** under Job token permissions, or the trigger 404s.
2. **Protect the target branch**, and mark the deploy variables Protected.
Protected variables only exist on protected refs; trigger a feature branch and
the key comes back empty.
3. **Give the upstream project Developer access**, or the job token is refused
regardless of the allowlist.
## Gitea Actions
Gitea has no `repository_dispatch`. The way in is the **workflow dispatch API**,
which fills in the same form a person would use under Actions → deploy → Run
workflow. It needs Gitea 1.24 or newer.
### Caller
```bash
curl -X POST \
-H "Authorization: token $CONFIG_REPO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"ref": "main",
"inputs": {"stack": "webapp", "mode": "deploy", "pull": "always"}
}' \
https://gitea.example.com/api/v1/repos/OWNER/config/actions/workflows/deploy.yml/dispatches
```
Three things are easy to get wrong here:
- **`ref` is required**, and it is the ref the playbooks are read from as well as
the one the run is attributed to. Point it at your default branch.
- **`mode` falls back to `check`.** Omit it and the trigger runs
`--check --diff`, reports what it would have done, and goes green having
deployed nothing. This is the Gitea equivalent of forgetting `pull=always`, and
it fails just as quietly. (The workflow applies that fallback itself: an
input's declared `default:` belongs to the dispatch form, not to the API.)
- **The job's own `GITEA_TOKEN` cannot do this.** It is scoped to the repository
the job runs in. `CONFIG_REPO_TOKEN` is an access token belonging to a user
with write access to the config repository — Settings → Applications → Access
tokens, with the `write:repository` scope — stored as a secret in the calling
repository.
The workflow name in the path is the file name, `deploy.yml`.
### The caller can wait, if it wants to
Add `?return_run_details=true` and the response carries the run's id and URL, so
a caller can poll `/api/v1/repos/OWNER/config/actions/runs/<id>` until it
finishes and fail its own pipeline on the result. That is more than GitHub's
`repository_dispatch` offers, and more than most callers need: a deploy that
fails is visible in the config repository's Actions tab and can notify from
there.
### What happens downstream
Exactly what happens on GitHub — the same validation is in
`.gitea/workflows/deploy.yml`, and it matters more here. A `type: choice` input
constrains the *form* and not the API, so a dispatch call may put any string in
`stack`.
## Before you enable this
**A trigger is a deploy.** Anyone holding the token, and any repository on the
allowlist, can deploy any stack in this repository. The validation guard stops
them running *arbitrary commands*; it does not stop them deploying a stack they
had no business touching. Scope the allowlist deliberately, and rotate the token
when someone leaves.
**Confirm the playbook exists first.** The downstream job fails the pipeline by
design when it does not, which is correct but makes for a confusing first
experience if you enable the trigger before converting the stack.
## Checking it worked
The Ansible recap at the end of the job log is the real answer. `changed=0` means
the images had not moved — which, for a trigger that fired after a successful
build, almost always means `pull=always` was not passed.