Files
config-public/AGENTS.md
T
Lucas WintherandClaude Opus 5.5 35b1378080
lint / yamllint + ansible-lint + syntax (push) Successful in 56s
lint / shellcheck (push) Successful in 7s
lint / secret scan (push) Successful in 2s
Add stackctl for running stacks by hand on a host
One shared script installed into /srv/stacks on every host, with per-host
groups in server/<host>/stack-groups. Tested in check.sh. Also two new
common mistakes in the adding-a-stack skill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 17:49:12 +02:00

15 KiB

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 and start are the two optional flags, and both are repository-wide. 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 — rendered from OpenBao by stack_secrets, or placed by hand. 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
stack_start {{ start | default(true) }} false syncs and starts nothing
stack_secrets {} Secrets to fetch from the vault and render to .env
stack_vault_addr {{ vault_addr | default('http://127.0.0.1:8200') }} The vault, as seen from the target host
stack_vault_mount {{ vault_kv_mount | default('kv') }} KV v2 mount point
stack_vault_token $VAULT_TOKEN Read from the control node's environment

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, stack_secrets), openbao.yml (owner-only escalation; a notice rather than a restart on compose_stack_synced), webapp-staging.yml (stack_src + stack_dest + stack_env), banner.yml (not a stack at all), stackctl.yml (not a stack either: one shared file and one per-host file into stack_root), bootstrap.yml (preparing a host; see docs/bootstrap.md).

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:

  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.

Not every service survives a restart unattended. openbao.yml keys a notice off the fact instead of a restart, because restarting OpenBao seals it and would fail every stack imported after it in a site.yml run.

stack_secrets

A stack's credentials can come from OpenBao on the host rather than from a .env someone placed by hand. Each entry names the environment variable and where its value lives:

    stack_secrets:
      SECRET_KEY:
        path: stacks/webapp
        field: secret_key

The role reads them at the end of the sync and writes {{ stack_dest }}/.env at 0600, so -e start=false and two-phase playbooks get the file too. Compose files are unchanged: keep reading the variable with ${VAR:?message}, which is still what catches a value that went missing.

The read runs on the target host, against 127.0.0.1:8200. That is what lets the vault be published nowhere, and it means a deploy works identically from a laptop and from a CI runner. It also means a host with no vault of its own needs vault_addr pointing at one it can reach. VAULT_TOKEN is read from the control node's environment and appears in neither this repository nor the inventory.

A sealed vault, an unset token, or a path that does not hold the field all fail before the stack starts, each naming what to fix. No task may print a value: keep no_log on anything that sees one, and keep the asserts that explain a failure off loops over the reads, where a failed item would carry the plaintext into the result. See docs/secrets.md.

Removing stack_secrets from a playbook stops the render on the next deploy; it does not delete the .env already on the host, which keeps serving its stale value until something else removes it.

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:

  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:

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:

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

Deploying without starting

start is the second repo-wide extra-var, and the mirror image of pull:

ansible-playbook playbooks/webapp.yml -e start=false

The payload is synced, stack_dirs and stack_networks are created, and the play stops before Compose — no container is created, started or recreated. It is for the case where something has to be put into a bind mount between the files landing and the stack opening them: a data directory copied across from a hand-deployed instance, a database restored from a dump. When you bring a hand-deployed service under Ansible this way, write the steps you used into the playbook's header comment, so the next migration has a recipe.

Prefer it to commenting a playbook out or running the role with tasks_from: sync by hand, and prefer the two entry points to it when the step in the middle is something Ansible should own — start=false is for work a person does, once. Nothing needs to read the flag: the role gates the Compose task on it in tasks/up.yml, so it applies to two-phase playbooks too.

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:

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.