Files
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

11 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 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:

  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:

  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.

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.