The title was written with YAML syntax, so gitleaks failed to load the
config before it reached [extend] or [allowlist]:
FTL unable to load gitleaks config, err: While parsing config: toml:
expected character =
Verified with the pinned binary the Gitea lint workflow uses (v8.30.1):
`gitleaks detect --source=. --config=.gitleaks.toml --redact --verbose
--no-banner` now scans the working tree and all history and reports no
leaks.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
compose-stack-deploy
A small, complete pattern for deploying Docker Compose stacks to a handful of Linux hosts with Ansible — and for letting CI do it safely, one stack at a time, on purpose rather than on every push.
It is deliberately not a framework. There is one Ansible role, about 90 lines of tasks, and a convention about where files live. Everything else in this repository is documentation and six worked examples.
server/app/webapp/ → rsync → /srv/stacks/webapp/ on app
ansible/playbooks/webapp.yml docker compose up -d
Is this for you?
It fits if you have somewhere between two and twenty services on a few hosts,
each one a docker-compose.yml, and you are currently deploying them by hand or
with a shell script you no longer trust.
It is the wrong tool if you want orchestration, scheduling, or rolling updates. That is Kubernetes or Nomad, and you should use one of those.
The idea, in three parts
Payloads are data. A stack's compose file and its configuration live under
server/<host>/<stack>/ and are copied to the host verbatim. Nothing is
templated. You can read the file in the repo and know exactly what is on the
host, and you can docker compose up it by hand in an emergency.
One role, one contract. Everything a stack can need — networks to create, directories to pre-create, which compose files, what to exclude from the sync, whether to pull — is a variable on a single role. A new service is usually four lines:
- name: Deploy the static site
hosts: app
gather_facts: false
tags: [static-site]
roles:
- role: compose_stack
vars:
stack_name: static-site
stack_networks: [proxy-net]
One key, one seam. Ansible knows exactly one thing about credentials:
SSH_KEY_PATH points at a 0600 private key. A single script decides where that
key comes from — a file, a CI secret, a vault — so adding a backend never touches
a playbook.
Layout
ansible/
├── ansible.cfg # read only from this directory — see below
├── requirements.yml # Galaxy collections
├── inventory/
│ ├── hosts.yml
│ └── group_vars/all.yml # connection settings, stack_root, the pull default
├── roles/compose_stack/ # the one shared "sync payload, bring stack up" role
└── playbooks/
├── site.yml # every stack, in dependency order
└── <stack>.yml # one per stack, runnable on its own
server/<host>/<stack>/ # 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/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
AGENTS.md # the conventions and the role contract, in full
Getting started
Run every ansible command from the
ansible/directory.ansible.cfgis only discovered in the current working directory, and it is what points at the inventory and putsroles/on the roles path. From the repository root you will getThe role 'compose_stack' was not found.Alternatively export
ANSIBLE_CONFIG=/path/to/repo/ansible/ansible.cfg, which works from anywhere. CI has to do this: Ansible ignores anansible.cfgthat lives in a world-writable directory, and a runner's build directory is exactly that.
cd ansible
ansible-galaxy install -r requirements.yml
export SSH_KEY_PATH=~/.ssh/your_deploy_key # see Secrets, below
Then check what a deploy would do before doing it:
ansible-playbook --check --diff playbooks/metrics.yml
On Windows
Ansible has no native Windows control node — ansible-core needs a POSIX system,
and the scripts here are shell scripts. Use WSL2 and run everything inside
it; the commands above then work unchanged.
Two things that will cost you an afternoon if you skip them:
- Clone into the WSL filesystem (
~/config), not/mnt/c/.... On the Windows drivechmod 600does not stick, and OpenSSH refuses a private key whose permissions are too open. It is also markedly slower. - Never open a private key or a shell script in Notepad. It rewrites the file
with CRLF line endings, which makes SSH reject the key and makes deployed
scripts fail on the host with
bad interpreter: /bin/bash^M. The.gitattributeshere keeps LF endings on checkout, but it cannot protect a file you edit outside git.
Deploying
# One stack
ansible-playbook playbooks/metrics.yml
# Everything, in dependency order
ansible-playbook playbooks/site.yml
# One stack out of site.yml
ansible-playbook playbooks/site.yml --tags metrics
Deploy versus update
This distinction is the one piece of the design worth internalising.
A plain run brings the stack up with the images already on the host. It never pulls, migrates, backs up, or destroys anything. Run it as often as you like.
ansible-playbook playbooks/webapp.yml -e pull=always
pull=always fetches newer images and recreates changed containers. It is also
the switch for the heavier per-stack work: webapp only runs its backup and
database migration on an update run.
If your images are tagged latest, a plain deploy will not pick up a new
build — Compose is content with the image it already has. Use pull=always when
you mean to update.
Verify with --check --diff first. These are production servers.
The examples
Six stacks, each chosen to demonstrate one thing. Read them in this order.
| Stack | Hosts | What it shows |
|---|---|---|
static-site |
app | The minimum: a name and a network. Copy this one. |
reverse-proxy |
edge | Pre-creating bind mounts; reloading after a config-only change |
metrics |
edge, app | One payload shared by two hosts; per-host variable maps; directories owned by a container's uid |
webapp |
app | Backup and migrate between sync and up, gated on an update run; a second compose file; building from a Dockerfile; requiring a host-side .env |
webapp-staging |
app | A second instance of the same payload, elsewhere, with different settings |
banner |
edge, app | The escape hatch: something that is not a Compose stack at all |
server/ is data — see server/README.md.
Secrets
Three separate things, handled three different ways. Full detail in
docs/secrets.md.
1. The deploy SSH key. Ansible only ever reads SSH_KEY_PATH.
scripts/load-ssh-key.sh is the only thing that knows where the key comes from;
it prints a path and never prints key material.
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh)" # key already on disk
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh ci)" # from a CI secret
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh vault)" # from Vault / OpenBao
2. Per-stack .env files. Stack secrets live on the hosts, not here. The
role excludes .env from every sync, so editing one in the repo has no effect
and a host's .env is never overwritten. The example stacks use Compose's
${VAR:?message} syntax, so a stack refuses to start with a missing credential
rather than booting with an empty password.
3. Nothing else. There is no third category, and that is the point. A secret written directly into a compose file cannot be excluded from the sync, cannot be rotated without a commit, and is in every clone of the repository forever. CI runs gitleaks over the working tree and the history on every push to keep it that way.
CI/CD
All three platforms are maintained, and all three do the same two things.
| GitHub Actions | GitLab CI | Gitea Actions | |
|---|---|---|---|
| Static gate | lint.yml |
lint job |
lint.yml |
| Deploy | deploy.yml |
per-stack buttons | deploy.yml |
| Diagnostics | connectivity.yml |
ansible:ping |
connectivity.yml |
| Setup guide | ci-github-actions.md |
ci-gitlab.md |
ci-gitea.md |
Gitea reads .gitea/workflows/ instead of .github/workflows/ — it stops at
the first workflow directory that exists — so the two never both run. Delete the
platforms you are not using rather than letting them rot.
The rules that matter, on all three:
- The lint gate runs on every push and must pass. It needs no access to any
host. It is
scripts/check.sh— the same command you run locally — so it cannot drift from what you tested. - Deploys are never automatic. Merging changes what would be deployed; a person still decides when. Every deploy path is manual or externally triggered.
- Check before deploy. All three expose a check mode that runs
--check --diffand touches nothing. - To update rather than deploy, set the pull policy to
always. - Untrusted input is validated. The stack name and pull policy reach a shell command and, for externally triggered deploys, come from another repository. Both are matched against a fixed pattern before anything runs.
Another repository can ask this one to redeploy a stack after building a new
image — see docs/triggering-deploys.md.
Adding a stack
- Put the compose file and its configuration in
server/<host>/<stack>/. - Add
ansible/playbooks/<stack>.yml. Copystatic-site.yml. - Add it to
site.yml, in dependency order. - Add it to CI: the
stackdropdown in.github/workflows/deploy.ymland in.gitea/workflows/deploy.yml, and acheck:/deploy:pair in.gitlab-ci.yml. Skip this and the stack works from a workstation but is invisible in CI, which is how services end up hand-deployed. make check, then--check --diffagainst the host.
The long version, with a table of which contract variable solves which problem,
is in skills/adding-a-stack/SKILL.md.
AGENTS.md has the conventions and the full role contract.
Troubleshooting
| Symptom | Cause |
|---|---|
The role 'compose_stack' was not found |
Not running from the ansible/ directory |
SSH_KEY_PATH does not point at a readable file |
Key not loaded, or a CI job that cannot see the secret |
| A deploy succeeds but the service is unchanged | Image tag is latest and you did not pass -e pull=always |
| Config file copied but nothing happens | Bind-mounted config; the stack needs a reload, which the playbook does only when the sync changed something |
no payload directory at ... |
stack_src does not match where the files actually are |
| 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 |
License
MIT. See LICENSE.