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
+231
View File
@@ -0,0 +1,231 @@
# AGENTS.md
Instructions for LLMs and AI agents working in this repository. Humans should
read `README.md` first; this file assumes you already know what the repo is for.
This is the one instruction file. `CLAUDE.md` and `GEMINI.md` are pointers to it,
and `.claude/skills/` points at `skills/`. Put content here or in `skills/`,
never in a pointer — the whole arrangement exists so that every agent, and every
human, reads the same text.
## What this repository is
A pattern for deploying Docker Compose stacks to a small number of Linux hosts
with Ansible, published as a working example. Every service on every host is a
Compose stack living in a directory under `/srv/stacks/` on the target. This repo
holds the files that get copied there, and the playbooks that copy them.
| Host | Address | Runs |
| ------ | ------------------ | ----------------------------------- |
| `edge` | `edge.example.com` | The reverse proxy, metrics |
| `app` | `app.example.com` | The applications behind it, metrics |
Both use SSH user `deploy` on port 22.
Everything under `server/` and every host name is an **example**. `example.com`
is reserved by RFC 2606 and can never resolve to a real service. Replace them
with yours; the framework around them is the part meant to be reused.
## Vocabulary
- **host** — one machine (`edge`, `app`). The top-level directories under
`server/` and the inventory names must always agree, because the role resolves
a payload as `server/{{ inventory_hostname }}/{{ stack_name }}`.
- **stack** — one Docker Compose deployment on one host (`metrics`, `webapp`).
- **payload** — the files under `server/<host>/<stack>/`, copied to the host
verbatim. Payloads are *data*. Do not template them, do not restructure them,
and do not lint them as Ansible content.
## Skills
Step-by-step guides for recurring jobs live in `skills/<name>/SKILL.md`, outside
any vendor directory.
| Skill | Use when |
| --- | --- |
| `adding-a-stack` | Adding a new service, moving one to a different host, or bringing a hand-deployed service under Ansible and CI |
## Playbook rules
These are the conventions. Follow them; do not invent per-stack variations.
1. **One playbook per stack**, at `ansible/playbooks/<stack>.yml`. If the same
stack runs on several hosts it is still one playbook with a host group, not
one per host. `metrics.yml` is the example.
2. **Self-contained.** Every playbook must run standalone as
`ansible-playbook playbooks/<stack>.yml` with no required extra vars — `pull`
is the one optional flag. It declares its own `hosts`, its own vars, and
everything the stack needs (directories, networks, compose files). Never rely
on another playbook having run first. Where that means two playbooks create
the same directory, that is correct and the repetition is deliberate.
3. **Tag every play** with the stack name, so `site.yml --tags metrics` works.
4. **Reuse goes in `roles/compose_stack`, not in copy-pasted tasks.** If two
playbooks need the same logic, extend the role's variable contract rather than
adding a second role. Keep the role count at one unless there is a genuinely
different shape of work.
5. **Modules, not shell.** `community.docker.docker_compose_v2` rather than
`docker compose up`; `ansible.builtin.file` rather than `mkdir -m 777`. Reach
for `command`/`shell` only when no module exists, and then set `changed_when`
explicitly.
6. **Fully-qualified collection names** everywhere (`ansible.builtin.copy`, not
`copy`).
7. **Name every task**, sentence case, imperative: `Sync the metrics payload`,
not `sync files`.
8. **Idempotent.** A second run must report zero changes, and `--check --diff`
must not error.
9. **No secrets in the repository.** `.env` files stay on the hosts. Never commit
a key, a password, or a token — including inside a compose file, where no
exclude can help you. See `docs/secrets.md`.
### The `compose_stack` role contract
Playbooks configure the role through these variables. Extend this list when a
stack needs something new; do not work around it with loose tasks.
| Variable | Default | Purpose |
| ---------------- | ----------------------------------------------------- | ------------------------------------------- |
| `stack_name` | *required* | Stack identifier, used for logs and tags |
| `stack_src` | `server/{{ inventory_hostname }}/{{ stack_name }}` | Payload directory in this repository |
| `stack_dest` | `{{ stack_root }}/{{ stack_name }}` | Destination directory on the host |
| `stack_dirs` | `[]` | Directories to pre-create (see below) |
| `stack_networks` | `[]` | External Docker networks to ensure exist |
| `stack_files` | `[docker-compose.yml]` | Compose files passed to the module |
| `stack_exclude` | `[.env]` | Paths never synced to the host |
| `stack_prune` | `false` | Delete host files absent from the payload |
| `stack_env` | `{}` | Extra env for compose; `PWD` is always set |
| `stack_pull` | `{{ pull \| default('policy') }}` | Per-invocation override of the pull flag |
| `stack_build` | `policy` | `always` for stacks built from a Dockerfile |
| `stack_state` | `present` | `present`, `absent`, or `restarted` |
Worked examples for each: `static-site.yml` (the minimum), `reverse-proxy.yml`
(`stack_dirs`, reload), `metrics.yml` (`stack_src`, per-host maps, ownership),
`webapp.yml` (two phases, `stack_files`, `stack_build`), `webapp-staging.yml`
(`stack_src` + `stack_dest` + `stack_env`), `banner.yml` (not a stack at all).
#### `stack_dirs`
Entries take a `path` plus optional `mode`, `owner`, `group` and `recurse`. All
four are applied **only when given**, so a directory that already exists on the
host is never re-chmodded — several bind mounts are created root-owned by Docker
on first start, and Postgres refuses to start if its data directory is not
`0700`/`0750`.
The role escalates **only when an entry sets `owner` or `group`.** Everything
else is created as the deploy user, which is the whole point of pre-creating a
bind mount: a root-owned directory is exactly what you are trying to avoid. A
stack that genuinely needs another uid says so with `owner`/`group` — see
`metrics.yml`, which hands `grafana-data` to `472` with `recurse: true` and no
`mode`.
Put that in `stack_dirs` rather than a `pre_tasks` chown. `pre_tasks` run before
the role creates the stack directory, and `ansible.builtin.file` stamps the
attributes it was given onto every parent directory it creates on the way down —
so an escalated chown of a child leaves the stack directory itself owned by that
uid, and the payload sync, which runs as the deploy user, cannot write into it.
#### `compose_stack_synced`
After the sync the role sets this fact. Use it to fire a reload for payloads that
are bind-mounted configuration, which Compose never recreates a container for:
```yaml
post_tasks:
- name: Reload nginx so changed vhost and conf files take effect
ansible.builtin.command:
cmd: docker exec reverse-proxy nginx -s reload
when: compose_stack_synced | default(false)
changed_when: true
```
Without the `when`, every run reports a change and the stack is never idempotent.
#### Doing maintenance between sync and up
`roles: - role: compose_stack` syncs and then starts, which is what almost every
stack wants. When something has to happen in between — a backup, a schema
migration — use the two entry points rather than inventing a second mechanism:
```yaml
tasks:
- name: Sync the payload
ansible.builtin.include_role:
name: compose_stack
tasks_from: sync
- name: Migrate the database
# ... gated on an update run
- name: Start the stack
ansible.builtin.include_role:
name: compose_stack
tasks_from: up
```
Declare the `stack_*` vars at **play** level so both phases see them; inside
`roles:` they are scoped to that invocation and the second include will not see
them. `webapp.yml` is the worked example.
### Deploy mode versus update mode
Every playbook serves two purposes and must support both without being edited:
```bash
ansible-playbook playbooks/webapp.yml # bring the stack up
ansible-playbook playbooks/webapp.yml -e pull=always # pull newer images
```
`pull` is a single repo-wide extra-var passed straight through to
`community.docker.docker_compose_v2`. Valid values are `policy` (the default —
Compose decides, so nothing is fetched for images already present), `always`,
`missing` and `never`. Read it as `pull | default('policy')` at every use site so
the playbook still runs when the var is undefined.
This is also the hook for the heavier work a stack needs only when its images
actually move. Gate those tasks on the same flag rather than creating a separate
`*-update.yml` playbook:
```yaml
- name: Back up the database before migrating
ansible.builtin.command:
cmd: docker compose -f docker-compose.yml -f docker-compose.migrate.yml run --rm backup
chdir: /srv/stacks/webapp
when: pull | default('policy') == 'always'
changed_when: true
```
Two consequences worth stating plainly: **a default run must never destroy or
migrate anything**, and **an update run must be safe to repeat.**
## Safety
- **Never run a playbook against a real host unless the user asks in that turn.**
Run `--check --diff` first and say what it would change.
- **Never read or print the contents of `.env` files, vault files, or SSH keys.**
If a task genuinely needs one, ask.
- Do not `git push`, open pull requests, or commit unless asked.
## Verifying your work
No Ansible tooling is assumed to be installed in a fresh checkout. One command
runs everything CI runs:
```bash
make check # or: scripts/check.sh
```
It runs `yamllint` from the repository root, installs the Galaxy collections,
runs `ansible-lint` at the `production` profile, and syntax-checks every
playbook. Locally it reaches for `uvx` so the repo needs no virtualenv of its
own; set `RUNNER=installed` if the tools are already on `PATH`.
Two things worth knowing if you run the commands by hand instead:
- **Run every ansible command from the `ansible/` directory.** `ansible.cfg` is
only discovered in the current directory, and it is what puts `roles/` on the
roles path. From the repository root you get
`The role 'compose_stack' was not found`.
- **Install the collections before linting**, or `community.docker` and
`ansible.posix` resolve to nothing and the fully-qualified-name checks pass
vacuously.
Lint and syntax-check are the definition of "done" for a playbook. They are
static and safe; run them yourself rather than asking the user to.