Resolve the conflict between ADR-011 (tags-not-digests) and the security work (digest pinning) with one coherent rule that respects ADR-011's stateless/stateful split: - Stateful → pin `tag@digest` (readable tag + integrity digest): legible diffs AND tamper-evidence. Snapshots cover broken updates; the digest covers swapped images. - Stateless → rolling tags (latest/stable); digest-pinning would defeat the rolling design. Integrity rests on official/verified images + disposability. Aligned across ADR-011 (decision 2), ADR-004 (image management), ADR-002 (supply-chain row), accepted-risk R1, the service checklist, and TODO 15.6. TODO 16.7 marked decided. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.6 KiB
ADR-004 — Docker and Compose service model
Context
All services run as Docker containers managed via Docker Compose. This document defines how services are structured, deployed, and maintained.
Core principles
- No hand-edited files on hosts: all Compose files are rendered by Ansible from Jinja2 templates. If a file exists on a host, it was put there by Ansible.
- Compose per service: each service (or tightly coupled service group) gets its own Compose file and directory under a standard path.
- Variables drive differences: the same template renders differently per host
via
group_varsandhost_vars. No host-specific templates.
Directory layout on hosts
/opt/services/
├── servicename/
│ ├── docker-compose.yml # rendered by Ansible, never edited manually
│ ├── .env # rendered by Ansible from vault variables
│ └── data/ # persistent volumes (bind mounts)
│ └── ...
All services live under /opt/services/. The path is defined in
group_vars/all/vars.yml as services__base_dir.
Service-role standard
Every service has its own self-contained role — one service, one role. Shared roles serving multiple services are no longer used (see "Why not a shared engine" below). Each service role contains a standard set of files:
| File | Purpose |
|---|---|
tasks/main.yml |
The standard deploy mechanics (below) |
templates/docker-compose.yml.j2 |
The Compose definition |
templates/env.j2 |
.env rendered from vault variables |
defaults/main.yml |
Tuneables, rolename__ namespace |
README.md |
Purpose, variables, usage (role convention) |
SECURITY.md |
Per-service security record — see ADR-002 and docs/security/service-security-template.md |
meta/main.yml, molecule/default/ |
Metadata + Debian 13 test scenario |
Standard deploy mechanics
Every service role's tasks/main.yml follows the same sequence, so all roles are
uniform and predictable:
- Create
/opt/services/<service>/directory - Render
docker-compose.ymlfromtemplates/docker-compose.yml.j2 - Render
.envfromtemplates/env.j2(secrets from vault variables) - Run
docker compose up -d --remove-orphansviaansible.builtin.command - Optionally run
docker compose pullbefore up (controlled by a variable)
Why not a shared engine
A shared compose_service engine role — service roles delegating the mechanics to
one place — is intentionally not built. Duplicating the ~5 standard tasks per
role is accepted in favour of legible, self-contained roles a reader can understand
without indirection, and AI authorship makes the duplication cheap to generate
uniformly from this standard.
Revisit trigger: extract a shared engine role if maintaining the duplicated mechanics across service roles becomes painful — a pattern change that means editing many roles, or drift between them that this standard alone isn't preventing.
Docker daemon configuration
Managed by the docker_host role. Key settings:
"log-driver": "json-file"with size limits (prevents disk exhaustion)"iptables": false— firewall managed entirely by nftables (see ADR-002)- TCP socket disabled — Unix socket only (
/var/run/docker.sock) - User namespace remapping: evaluated per use case, not enabled by default
Networking
- Each service Compose file defines its own named network(s)
- Services that need to communicate are placed on a shared named network
defined in a dedicated
docker-compose.networks.yml(if cross-service networking is needed on a host) - External port publishing is explicit and matches nftables rules
Image management
- Image pinning follows the tiered model in ADR-011: stateful services pin
tag@digest(readable tag + integrity digest); stateless services use rolling tags (latest/stable), refreshed deliberately and watched by DIUN - Bare
latestis therefore acceptable only on the stateless tier; the stateful tier is always pinned - Image updates are a deliberate operation: update the tag/digest variable, run deploy
Persistent data
- Bind mounts preferred over named volumes for data that must be backed up
- All bind mount paths are under
/opt/services/<name>/data/ - Backup strategy is defined separately (not in scope of this repo)
Decision
Docker Compose was chosen over Kubernetes/Swarm because:
- Appropriate complexity level for 2–5 hosts with independent service sets
- Compose files are human-readable and easily auditable
- No distributed state to manage
- Straightforward to back up and restore