From 792f7cde4820a417cee55dafdeac28c152b39821 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 15:07:31 +0200 Subject: [PATCH] Add the host bootstrap: deploy user, key, docker access, stack root bootstrap.yml prepares a host as an admin via -u; new-deploy-key.sh makes the key pair and refuses to write inside the repo. CI deploys refuse the bootstrap playbook, since the dispatch API ignores the dropdown. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/deploy.yml | 4 + .github/workflows/deploy.yml | 4 + .gitlab-ci.yml | 3 + AGENTS.md | 3 +- README.md | 48 +++++++++- ansible/playbooks/bootstrap.yml | 124 +++++++++++++++++++++++++ docs/architecture.md | 12 +-- docs/bootstrap.md | 155 ++++++++++++++++++++++++++++++++ scripts/new-deploy-key.sh | 97 ++++++++++++++++++++ skills/adding-a-stack/SKILL.md | 2 +- 10 files changed, 443 insertions(+), 9 deletions(-) create mode 100644 ansible/playbooks/bootstrap.yml create mode 100644 docs/bootstrap.md create mode 100755 scripts/new-deploy-key.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e0263e2..ff32f1a 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -113,6 +113,10 @@ jobs: set -eu case "$STACK" in ''|*[!a-z0-9_-]*) echo "refusing STACK='$STACK'"; exit 1 ;; + # Has a playbook, so the test -f below would pass it, but it must + # run as an administrator — see docs/bootstrap.md. The dropdown + # leaves it out; the API does not. + bootstrap) echo "refusing STACK='$STACK': run it by hand"; exit 1 ;; esac case "$MODE" in check|sync|deploy) ;; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a3eef9..f6d915d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -97,6 +97,10 @@ jobs: set -eu case "$STACK" in ''|*[!a-z0-9_-]*) echo "refusing STACK='$STACK'"; exit 1 ;; + # Has a playbook, so the test -f below would pass it, but it must + # run as an administrator — see docs/bootstrap.md. The dropdown + # leaves it out; the API does not. + bootstrap) echo "refusing STACK='$STACK': run it by hand"; exit 1 ;; esac case "$MODE" in check|sync|deploy) ;; diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1fea04a..11dd99c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -100,6 +100,9 @@ ansible:ping: # run webapp's backup and migration against the wrong stack. - STACK="${STACK_FIXED:-${STACK:-}}" - case "$STACK" in ''|*[!a-z0-9_-]*) echo "refusing STACK='$STACK'"; exit 1;; esac + # bootstrap has a playbook but must run as an administrator, by hand -- see + # docs/bootstrap.md. + - case "$STACK" in bootstrap) echo "refusing STACK='$STACK': run it by hand"; exit 1;; esac # PULL reaches the same command line, unquoted, from the same untrusted place # as STACK, so it gets the same treatment: an unchecked value can smuggle in # extra `-e` overrides (stack_dest, ansible_host) and redirect a deploy. diff --git a/AGENTS.md b/AGENTS.md index 37818bf..6e87dd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,8 @@ stack needs something new; do not work around it with loose tasks. 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_src` + `stack_dest` + `stack_env`), `banner.yml` (not a stack at all), +`bootstrap.yml` (preparing a host; see `docs/bootstrap.md`). #### `stack_dirs` diff --git a/README.md b/README.md index 4274b05..400ae3a 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,15 @@ ansible/ ├── roles/compose_stack/ # the one shared "sync payload, bring stack up" role └── playbooks/ ├── site.yml # every stack, in dependency order + ├── bootstrap.yml # prepares a host; run once, by an admin └── .yml # one per stack, runnable on its own server/// # the payload: compose files and config, copied verbatim server/shared/ # payloads used by more than one host scripts/load-ssh-key.sh # produces the deploy key path (file / CI / vault) +scripts/new-deploy-key.sh # generates the deploy key pair scripts/check.sh # the full static gate — what CI runs skills/adding-a-stack/ # step-by-step guide to adding a service -docs/ # secrets, CI setup, connectivity, triggering deploys +docs/ # bootstrap, secrets, CI setup, connectivity, deploys AGENTS.md # the conventions and the role contract, in full ``` @@ -86,10 +88,15 @@ AGENTS.md # the conventions and the role contract, in full > lives in a world-writable directory, and a runner's build directory is exactly > that. +**A host that has never been deployed to needs preparing first** — a `deploy` +user, its key, the `docker` group, rsync, and a writable `/srv/stacks`. That is +`scripts/new-deploy-key.sh` and `playbooks/bootstrap.yml`, once per host; see +[docs/bootstrap.md](docs/bootstrap.md). + ```bash cd ansible ansible-galaxy install -r requirements.yml -export SSH_KEY_PATH=~/.ssh/your_deploy_key # see Secrets, below +export SSH_KEY_PATH=~/.ssh/deploy_key # see Secrets, below ``` Then check what a deploy *would* do before doing it: @@ -98,6 +105,40 @@ Then check what a deploy *would* do before doing it: ansible-playbook --check --diff playbooks/metrics.yml ``` +### Passwordless sudo + +Almost everything a deploy does runs as `deploy`, unescalated, and that is +deliberate: a bind mount created by root is one the container cannot write. +The exception is a `stack_dirs` entry that sets `owner` or `group`. Handing a +directory to another uid needs root, so the role escalates for exactly those +entries and nothing else. `metrics.yml` does it for Grafana (uid 472), because +an image that does not run as root cannot repair its own data directory from +the inside. + +Without it, those deploys stop here: + +``` +TASK [compose_stack : Create the extra directories the stack needs] +[ERROR]: Task failed: Missing sudo password +``` + +There is no password to supply — `deploy` has none. Grant `NOPASSWD` once, as +an administrator, either over SSH or on the host itself: + +```bash +cd ansible +ansible-playbook playbooks/bootstrap.yml -u -k -K -e deploy_sudo=true +ansible-playbook playbooks/bootstrap.yml -c local -K -e deploy_sudo=true +``` + +Leaving `-e deploy_sudo=true` off is the easy mistake: the run still ends green +and the only sign is `skipped=1` in the recap. Confirm it took with +`ssh deploy@ sudo -n true && echo ok`. + +This cannot come from CI, and that is the point: the runner holds only the +unprivileged deploy key. Why `-u` works, what `NOPASSWD:ALL` costs and how to +narrow it are in [docs/bootstrap.md](docs/bootstrap.md). + ### On Windows Ansible has no native Windows control node — `ansible-core` needs a POSIX system, @@ -262,6 +303,9 @@ is in [`skills/adding-a-stack/SKILL.md`](skills/adding-a-stack/SKILL.md). | A stack refuses to start, naming a variable | Its `.env` is missing on the host — that is the design working | | `Permission denied (publickey)` | The matching public key is not in the deploy user's `authorized_keys` | | `Connection timed out` | A network path problem, not a key problem — see [`docs/connectivity.md`](docs/connectivity.md) | +| `Failed to find required executable "rsync"` | `synchronize` needs rsync at **both** ends; read the PATH in the error to tell the runner from the host | +| `Missing sudo password` on *Create the extra directories* | The host was bootstrapped without `-e deploy_sudo=true` — see [Passwordless sudo](#passwordless-sudo) | +| `"/srv/stacks/" is not a directory` | A file is in the way; the role says which. A first `--check` skips Compose instead — see [`docs/bootstrap.md`](docs/bootstrap.md) | ## License diff --git a/ansible/playbooks/bootstrap.yml b/ansible/playbooks/bootstrap.yml new file mode 100644 index 0000000..f31830e --- /dev/null +++ b/ansible/playbooks/bootstrap.yml @@ -0,0 +1,124 @@ +--- +# Prepare a host for everything else in this repository: the deploy user, its +# key, its access to Docker, and the directory the stacks live in. +# +# The one playbook that cannot connect as the deploy user, because it is what +# creates it. Run it as an administrator who already has access: +# +# ansible-playbook playbooks/bootstrap.yml -u -k -K +# +# -u is what makes this work, and it works only because the deploy account is +# `remote_user` in ansible.cfg rather than ansible_user in the inventory. An +# inventory variable outranks -u; a config default does not. From the host +# itself, `-c local -K` skips SSH and the question with it. +# +# -k asks for the SSH password and -K for the sudo password; drop either if that +# account already uses a key or has passwordless sudo. SSH_KEY_PATH is not +# needed — an empty ansible_ssh_private_key_file is ignored by the ssh plugin, +# so group_vars/all.yml does not get in the way here either. +# +# Deliberately not imported by site.yml and not offered in the CI deploy +# dropdown: a converge run and the runner both authenticate as the deploy user, +# which is exactly what does not exist yet. +- name: Bootstrap a host for deployment + hosts: all + gather_facts: false + become: true + tags: [bootstrap] + + vars: + deploy_user: deploy + deploy_pubkey_file: ~/.ssh/deploy_key.pub + # The compose_stack role escalates only when a stack_dirs entry sets owner + # or group, which is how a bind mount gets the uid its container runs as. + # metrics and openbao do that, so a host running either wants + # -e deploy_sudo=true. Still off by default: this grants NOPASSWD:ALL, and a + # host with no such stack has no reason to carry it. + deploy_sudo: false + + tasks: + - name: Look for the public key on the control node + ansible.builtin.stat: + path: "{{ deploy_pubkey_file | expanduser }}" + register: bootstrap_pubkey + delegate_to: localhost + become: false + changed_when: false + + # Before the user is half-created, rather than after. + - name: Verify the public key exists + ansible.builtin.assert: + that: + - bootstrap_pubkey.stat.exists | default(false) + fail_msg: >- + no public key at {{ deploy_pubkey_file }} — + run scripts/new-deploy-key.sh first + quiet: true + + - name: Look for the docker group on the host + ansible.builtin.getent: + database: group + key: docker + fail_key: false + + # Without this the user task fails with "Group docker does not exist", which + # is true and says nothing about the actual problem. + - name: Verify Docker is installed + ansible.builtin.assert: + that: + - ansible_facts.getent_group.docker is defined + fail_msg: >- + no docker group on {{ inventory_hostname }} — install Docker first + quiet: true + + # The payload sync is ansible.posix.synchronize, which needs rsync at both + # ends. Installed rather than asserted, unlike Docker above: rsync is one + # package from the base repositories, where installing Docker means choosing + # repositories and versions and is a decision this playbook should not make + # on your behalf. + - name: Install rsync + ansible.builtin.package: + name: rsync + state: present + + # In the docker group because the role runs compose unescalated: that is what + # keeps the bind mounts from being created root-owned. + - name: Create the deploy user + ansible.builtin.user: + name: "{{ deploy_user }}" + groups: docker + append: true + shell: /bin/bash + create_home: true + + - name: Authorise the deploy key + ansible.posix.authorized_key: + user: "{{ deploy_user }}" + key: "{{ lookup('ansible.builtin.file', deploy_pubkey_file | expanduser) }}" + # Never exclusive. Rewriting the whole file would drop any other key that + # is currently somebody's only way onto this host. + exclusive: false + + # The role creates {{ stack_root }}/ unescalated, and /srv is + # root-owned, so without this the first deploy fails on a permission error + # that looks nothing like its cause. + - name: Create the stack root + ansible.builtin.file: + path: "{{ stack_root }}" + state: directory + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: "0755" + + - name: Allow the deploy user to escalate + ansible.builtin.copy: + dest: "/etc/sudoers.d/{{ deploy_user }}" + content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n" + owner: root + group: root + mode: "0440" + # A malformed sudoers file can lock everyone out of a host permanently. + # validate runs visudo against the candidate and refuses to install it + # if it does not parse. + validate: visudo -cf %s + when: deploy_sudo | bool diff --git a/docs/architecture.md b/docs/architecture.md index 0f0c638..8e806fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,11 +45,13 @@ 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. +`banner.yml` and `bootstrap.yml` are the deliberate exceptions. Neither is a +Compose stack at all: one drops a single shared file into another stack's +directory, the other prepares a host — a user, a key, a directory, a sudoers +file. Forcing either 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 are so few such playbooks is the +signal that the contract is holding. ## Two entry points, not two playbooks diff --git a/docs/bootstrap.md b/docs/bootstrap.md new file mode 100644 index 0000000..a9969b5 --- /dev/null +++ b/docs/bootstrap.md @@ -0,0 +1,155 @@ +# Preparing a host + +Everything else in this repository assumes a host that already has a `deploy` +user, a key it trusts, access to Docker, and a writable stack root. This is how +that gets made. It runs once per host. + +There is a chicken-and-egg problem in the middle of it: every other playbook +connects **as** `deploy`, so the playbook that creates `deploy` cannot. It +connects as an administrator you already have access with, and it is the only +thing in this repository that does. + +## 1. Generate the deploy key + +```bash +scripts/new-deploy-key.sh # ~/.ssh/deploy_key +scripts/new-deploy-key.sh ~/.ssh/other # somewhere else +``` + +`ed25519`, and no passphrase — nothing in CI or in a playbook run can type one, +and an encrypted key fails much later with an authentication error that says +nothing about a passphrase. + +The script prints the paths and the public key. It does not print the private +key unless you pass `--print-secret`, for the same reason +[`load-ssh-key.sh`](../scripts/load-ssh-key.sh) does not: key material should +stay out of terminals, logs and the process list. + +It refuses two things. It will not overwrite an existing key without `--force`, +because replacing a live deploy key locks you out of every host that still +trusts the old one. And it will not write anywhere inside this repository: +gitleaks scans the working tree **and** the full history, so a key committed +once stays leaked after you delete the file. + +One key serves every host and every stack. Rotating it is therefore an +all-hosts operation — replace the line in each `authorized_keys`, then update +the one CI secret. + +## 2. Prepare the host + +```bash +cd ansible +ansible-playbook playbooks/bootstrap.yml -u -k -K -e deploy_sudo=true +``` + +`deploy_sudo=true` is what grants the deploy user passwordless sudo, and it is +off by default. Leave it off only on a host where no stack sets `owner` or +`group` in `stack_dirs` — those entries are the only thing the role escalates +for, and `metrics.yml` and `openbao.yml` both have them. Omit it and the run +still ends green, with `skipped=1` in the recap as the only sign; the +consequence turns up much later, in a different playbook, as +`Missing sudo password`. + +`-u` is honoured because the deploy account is `remote_user` in `ansible.cfg` +and not `ansible_user` in the inventory. That distinction exists for this +playbook: an inventory variable outranks a command-line `-u`, so naming the +account there would make the flag a silent no-op and send the run in as the +user that does not exist yet — or, on a host already bootstrapped, as the one +that cannot escalate. + +Running it on the host itself avoids SSH altogether: + +```bash +cd ansible +ansible-playbook playbooks/bootstrap.yml -c local -K -e deploy_sudo=true +``` + +`-c local` executes as whoever invoked it and ignores `ansible_user` altogether. + +`-k` asks for that account's SSH password, `-K` for its sudo password; drop +either if it already uses a key or has passwordless sudo. `SSH_KEY_PATH` is not +needed here. The public key is read from whichever machine runs Ansible, so an +on-host run needs `deploy_pubkey_file` to point at a copy that is there. + +| Variable | Default | | +| --- | --- | --- | +| `deploy_user` | `deploy` | Must match `remote_user` in `ansible/ansible.cfg` | +| `deploy_pubkey_file` | `~/.ssh/deploy_key.pub` | Read from the control node | +| `deploy_sudo` | `false` | Passwordless sudo for the deploy user | + +What it does, and why each part matters: + +- **Creates `deploy` in the `docker` group.** The `compose_stack` role runs + Compose unescalated, which is what stops bind mounts being created + root-owned. Without group membership every Docker call fails on the socket. +- **Installs rsync.** The payload sync is `ansible.posix.synchronize`, which + shells out to rsync **at both ends**. Missing it on the target fails the first + deploy; missing it on the machine running Ansible fails the same task with the + same message, so read the PATH in the error to tell the two apart. +- **Installs the public key**, never exclusively — rewriting the whole + `authorized_keys` would drop a key that is currently somebody's only way in. +- **Creates `{{ stack_root }}` owned by `deploy`.** This is the one that bites + silently: the role creates `/srv/stacks/` unescalated and `/srv` is + root-owned, so skipping it produces a permission error on first deploy that + looks nothing like its cause. +- **Grants passwordless sudo only when you ask.** The role escalates when a + `stack_dirs` entry sets `owner` or `group`, which is how a bind mount gets the + uid its container runs as. `metrics.yml` and `openbao.yml` both need it, so + any host running either wants `-e deploy_sudo=true`. The sudoers file is + written through `visudo -cf`, because a malformed one can lock everyone out of + a host permanently. `NOPASSWD:ALL` is broad — narrowing it to the commands the + `file` module actually invokes is a reasonable hardening once a host settles. + +It is not imported by `site.yml` and not offered in the CI deploy dropdown. +Both authenticate as `deploy` — which on a fresh host does not exist yet, and on +a bootstrapped one is the unprivileged account this playbook exists to grant +privilege to. Either way it cannot bootstrap itself. Making CI able to would +mean storing an administrator's credential where the runner can reach it, a +larger and permanent grant in place of one interactive command. + +**`--check` does not work on a host that has never been bootstrapped.** It stops +at *Authorise the deploy key* with `Either user must exist or you must provide +full path to key file in check mode`: check mode did not really create the user, +so the module has no home directory to inspect. That is Ansible's limitation, +not a fault in the run. On an already-bootstrapped host check mode is clean. + +### rsync on the machine running Ansible + +The half this playbook cannot do for you. `synchronize` runs rsync locally and +pushes, so whatever drives the deploy needs the binary as well: + +- **A CI runner.** GitHub's hosted runners ship it. The GitLab jobs install it + in the `.ansible` `before_script`. The Gitea deploy installs it through the + toolchain action, because the act_runner images do not carry it. A runner + without it fails with `Failed to find required executable "rsync"` and a PATH + full of `/opt/hostedtoolcache/...`, which is the giveaway that the message is + about the runner and not about your host. +- **Your workstation**, if you deploy from it: `apt install rsync`, or the + equivalent. + +### The first deploy of a stack cannot be a check run + +`--check` creates nothing, so on a host that has never run a given stack there +is no project directory for Compose to inspect. The role notices and skips the +compose step with a note rather than failing, and the payload diff above it is +still reported — but the stack itself is only really created by a real deploy. + +## 3. Hand the key to CI + +```bash +base64 -w0 < ~/.ssh/deploy_key +``` + +Store it as `SSH_PRIVATE_KEY_B64`. Where that lives differs per platform — +[secrets.md](secrets.md) has the table, the other backends, and what not to use +on Windows. + +## 4. Confirm it before deploying anything + +Run the **connectivity** workflow first (`ansible:ping` on GitLab). +`SUCCESS => {"ping": "pong"}` proves +the runner decoded the key, reached port 22, authenticated, and ran Python on +the far side — everything a deploy needs except the playbook. + +If it fails, the message tells you which of three different problems you have; +[connectivity.md](connectivity.md) has the table. diff --git a/scripts/new-deploy-key.sh b/scripts/new-deploy-key.sh new file mode 100755 index 0000000..6d764f0 --- /dev/null +++ b/scripts/new-deploy-key.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Generate the Ansible deploy key pair. +# +# scripts/new-deploy-key.sh # ~/.ssh/deploy_key +# scripts/new-deploy-key.sh ~/.ssh/other # somewhere else +# scripts/new-deploy-key.sh --force # replace an existing key +# scripts/new-deploy-key.sh --print-secret # also print the value for CI +# +# Prints the paths and the public key. It does not print the private key unless +# you ask for it: load-ssh-key.sh keeps key material off stdout and out of logs +# and the process list, and this script holds the same line. +# +# The public key belongs in the deploy user's authorized_keys, which +# ansible/playbooks/bootstrap.yml does for you. The private key belongs in the +# CI secret SSH_PRIVATE_KEY_B64. See docs/bootstrap.md and docs/secrets.md. +set -euo pipefail +umask 077 + +log() { printf 'new-deploy-key: %s\n' "$*" >&2; } +die() { log "$*"; exit 1; } + +force=0 +print_secret=0 +key_path="" + +while [ $# -gt 0 ]; do + case "$1" in + --force) force=1 ;; + --print-secret) print_secret=1 ;; + -h|--help) sed -n '3,9p' "$0" | sed 's/^#[[:space:]]\{0,1\}//'; exit 0 ;; + -*) die "unknown option: $1" ;; + *) + [ -z "$key_path" ] || die "only one key path may be given" + key_path="$1" + ;; + esac + shift +done + +key_path="${key_path:-$HOME/.ssh/deploy_key}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +key_dir="$(dirname "$key_path")" +[ -d "$key_dir" ] || die "no such directory: $key_dir" +key_dir="$(cd "$key_dir" && pwd)" + +# gitleaks scans the working tree *and* the full history, and a key committed +# once stays leaked after the file is deleted. Refuse before that can happen. +case "$key_dir/" in + "$repo_root"/*) die "refusing to write a private key inside the repository: $repo_root" ;; +esac + +if [ -e "$key_path" ] && [ "$force" -ne 1 ]; then + die "$key_path already exists. Replacing it locks out every host that still + trusts the old key, so pass --force if that is what you want." +fi +# An `if`, not `[ ... ] && rm ...`: an AND-list whose test fails returns +# non-zero, which under `set -e` is a trap this repository has been bitten by +# before -- see the same note in .gitea/workflows/deploy.yml. +if [ "$force" -eq 1 ]; then + rm -f "$key_path" "$key_path.pub" +fi + +# ed25519, and no passphrase: nothing in CI or in a playbook run can type one, +# and an encrypted key fails later with an authentication error that says +# nothing about the passphrase. +ssh-keygen -t ed25519 -f "$key_path" -N "" -C "ansible-deploy@$(basename "$repo_root")" >/dev/null + +printf 'private key %s\n' "$key_path" +printf 'public key %s\n\n' "$key_path.pub" +cat "$key_path.pub" + +cat < -k -K \\ + -e deploy_pubkey_file=$key_path.pub + + 2. Store the private key as the CI secret SSH_PRIVATE_KEY_B64 (where, per + platform, is in docs/secrets.md): + + base64 -w0 < $key_path + + 3. Confirm it works before any deploy: the connectivity workflow on GitHub + or Gitea, the ansible:ping job on GitLab. +INSTRUCTIONS + +if [ "$print_secret" -eq 1 ]; then + printf '\nSSH_PRIVATE_KEY_B64:\n' + base64 -w0 < "$key_path" + printf '\n' +fi diff --git a/skills/adding-a-stack/SKILL.md b/skills/adding-a-stack/SKILL.md index 9adb585..287b0ee 100644 --- a/skills/adding-a-stack/SKILL.md +++ b/skills/adding-a-stack/SKILL.md @@ -45,7 +45,7 @@ files and one button. `AGENTS.md` holds the conventions and the full | If | Do | Example | | --- | --- | --- | -| It is not a Compose stack — one file into another stack's directory, or a system service | Write plain tasks. Do not force the role | `banner.yml` | +| It is not a Compose stack — one file into another stack's directory, host preparation, or a system service | Write plain tasks. Do not force the role | `banner.yml`, `bootstrap.yml` | | Something must happen between the payload landing and the stack starting (backup, migration) | Use the two entry points, `tasks_from: sync` then `tasks_from: up`, and gate the middle on `pull \| default('policy') == 'always'` | `webapp.yml` | | The payload is bind-mounted config, which Compose never recreates a container for | Add a `post_tasks` reload keyed on `compose_stack_synced` | `reverse-proxy.yml`, `metrics.yml` | | The compose file is not named `docker-compose.yml`, or there are several | `stack_files` | `webapp.yml` |