Post 1 told you about the 1,800-line compose file I was afraid to touch and the Portainer install whose on-disk config had been fiction for years. This post is the structure that replaced them. The layout is just the reasoning written down as directories, so I’ll give you both.
The prime directive, restated because everything follows from it: the repo is the only source of truth. Servers receive deploys. Nobody edits a file on a server. The moment you “quickly fix” something over SSH, your repo is a lie, the next deploy silently reverts the fix, and you get to debug the same outage twice. Ask me how I know.
The tree
compose/
_common/ # fragments shared by every host
traefik-internal.yml # the internal proxy, defined ONCE
traefik-public.yml # the edge variant
sso-outpost.yml # auth outpost, same everywhere
hermes/
compose.yml # app services (deployed by automation)
compose-infra.yml # infra services (deployed manually)
services/ # one file per service
vault.yml
homeassistant.yml
bender/ # same shape
url/ # same shape
traefik/dynamic/ # proxy middlewares + shared routes
secrets/ # encrypted .env files (post 4)
scripts/ # bootstrap, deploy, backup
docs/ # runbooks. write them. future-you forgets.
Three hosts, identical shape. Once your hands learn it on one box they know it on all of them.
The split that matters: infra vs apps
Every host runs two compose projects, and this boundary is the most load-bearing decision in the repo:
compose-infra.yml: the reverse proxy, the SSO outpost, the deploy agent. Deployed manually, by a dumb script over SSH.compose.yml: everything else. Deployed automatically on git push (post 9).
Why not automate everything? Because the deployer cannot manage itself. If the automation redeploys the stack that contains the automation, one bad push kills the deployer mid-deploy, and now you have no deployer, a half-applied change, and a recovery that starts with SSH’ing in like an animal. The same logic shields the proxy and the SSO: they’re what you need working in order to reach and fix everything else. The blast radius of “automation gone wrong” must end at the app layer.
One service, one file
compose.yml contains no services. It’s an include manifest:
name: hermes
include:
- path: services/vault.yml
- path: services/homeassistant.yml
- path: services/uptime-kuma.yml
Each service lives alone under services/. The payoffs are
immediate: adding a service is one file plus one include line,
reviewed as one commit (feat(hermes): add vault). Removing one is a
deletion. git log services/vault.yml is that service’s complete
history. And you never again scroll two thousand lines hunting for
the right environment: block.
The four service patterns
Every service file copies the nearest of four shapes. This is what makes service number sixty as easy as service number six: you’re not designing, you’re instantiating.
Pattern 1, internal-only. The default. Proxy labels, SSO middleware, nothing public:
services:
app:
image: ...
restart: unless-stopped
networks: [proxy]
volumes:
- ${USERDIR}/appdata/app:/config
labels:
- traefik.enable=true
- traefik.http.routers.app.rule=Host(`app.${INTERNAL_DOMAIN}`)
- traefik.http.routers.app.entrypoints=websecure
- traefik.http.routers.app.tls=true
- traefik.http.routers.app.middlewares=sso@file
- traefik.http.services.app.loadbalancer.server.port=8080
networks:
proxy:
external: true
Pattern 2, public-facing. The same file, plus a route in the edge’s inventory (post 7). Both routes, always. A public service with no internal route can’t be reached while your WAN is down, and an internal label with a forgotten edge route is how “I swear I deployed that” happens.
Pattern 3, VPN-shared. network_mode: service:gluetun, labels on
the network owner. Post 10 covers it in full.
Pattern 4, stateful-with-db. The app plus its own postgres or redis as siblings in the same file, with healthchecks so startup order is deterministic:
app-db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 30s
app:
depends_on:
app-db:
condition: service_healthy
Sibling, not shared: one database container per app that needs one. A communal postgres is a single point of failure with a guest list, and “upgrade postgres” becomes a negotiation among every tenant. Disk is cheaper than coordination.
Conventions that do the remembering
Each of these is boring. Each is also a debugging session you’ll never have:
- Containers: lowercase, hyphenated, matching the service name. No host prefixes; the directory already says which box.
- Volumes: bind mounts into
~/appdata/<service>/for anything stateful. Named volumes hide data inside Docker’s store where your backup job (post 12) can’t see it honestly. Anonymous volumes orphan data on recreate.ls ~/appdatashould BE your state inventory. - Env vars:
SCREAMING_SNAKE_CASE, prefixed with the service name.SONARR_API_KEY, notAPI_KEY. The day two apps want the same unprefixed name in a shared env file, one loses silently. - Image tags: stateful services pin a major version
(
postgres:16-alpine,traefik:v3.3). A surprise database major bump is a corruption story.:latestis fine for the *arrs and friends, whose upgrade culture is genuinely non-breaking.
Check your work without deploying
Compose validates without a daemon:
docker compose -f compose/hermes/compose.yml config --quiet && echo ok
Typos, broken includes, undefined variables: caught at the keyboard instead of as a failed deploy. Run it before every compose commit until your fingers do it for you.
Milestone reached: you have a repository that can describe every service on every host, and a layout that won’t fight you at scale.
Next: the part everyone gets wrong. Secrets that live IN the repo, encrypted, instead of beside it, leakable.