One directory per service under /opt/stacks, each with its own compose.yml and .env, one shared external network for the reverse proxy, and the whole tree in git. That layout scales from 3 services to 40 without becoming a hairball, survives any single app's bad day, and makes your server reproducible from a git clone. Everything else in this post is refinement of those four decisions.
The layout
/opt/stacks/
caddy/
compose.yml
Caddyfile
immich/
compose.yml
.env # gitignored — secrets and local paths
.env.example # committed — documents every variable
jellyfin/
compose.yml
paperless/
compose.yml
.env
.env.example
The unit of a stack is "things that restart together": Immich's server, its Postgres, and its Redis belong in one compose.yml because they're meaningless apart. Jellyfin gets its own. This carries on from the first-server setup — same directories, more discipline.
Why not one big compose file
A single 30-service compose.yml fails in slow motion. docker compose up -d evaluates every service on every run, so one bad image reference blocks unrelated deploys; one malformed line takes the whole file hostage; git log on the monolith tells you nothing about which app changed; and a botched edit's blast radius is your entire homelab rather than one app. Per-service stacks make every operation scoped: update Immich, restart Immich, break Immich — Jellyfin never notices. The cost is running docker compose up -d in more than one directory — which is for d in /opt/stacks/*/; do docker compose --project-directory "$d" up -d; done after a reboot or restore — and cross-stack networking, which the next section solves properly anyway. docker compose ls keeps the inventory honest either way.
One external network for the proxy
Create the shared network once, outside any compose file:
docker network create proxy
Then every web-facing service joins it and stops publishing ports:
# /opt/stacks/jellyfin/compose.yml
services:
jellyfin:
image: jellyfin/jellyfin:10.10.6
networks: [proxy, default]
volumes:
- ./config:/config
- /mnt/media:/media:ro
restart: unless-stopped
networks:
proxy:
external: true
Only the reverse proxy publishes 80 and 443; it reaches jellyfin:8096 by container name over the shared network. Databases stay off the proxy network entirely — each stack's default network keeps Postgres reachable by its own app and nothing else. This kills the classic homelab smell of thirty published ports, means firewall rules protect two ports instead of thirty, and gives every service TLS through Caddy or Traefik — the trade-offs between those are their own post.
Env files: commit the questions, not the answers
.env holds everything machine-specific or secret: image tags, host paths, passwords, PUID/PGID, TZ. It's gitignored with chmod 600; next to it lives a committed .env.example naming every variable with dummy values, so future-you knows what a fresh deploy needs. This is the honest secrets posture for a single-node homelab — Docker's native secrets require Swarm mode, and most workarounds add ceremony without adding security on a box where root reads everything anyway. If you want secrets encrypted in the repo (worth it once the repo leaves your house), sops with an age key encrypts .env files and diffs cleanly. One rule either way: no secret ever appears in compose.yml itself, because that file is the one you'll paste into a forum post at 1am.
Pin tags; update on purpose
image: postgres:latest is a time bomb with your data in it — a major Postgres version jump via casual docker compose pull will refuse to start against the old data directory, and that's the good failure mode. Pin stateful services to at least the minor version (postgres:16.6, immich-server:v1.135.3) and pin anything that has ever broken you to the patch. The update loop that works: a notifier — Diun, or Renovate opening PRs against your stacks repo — tells you a new version exists; you read the changelog (Immich in particular earns its breaking-changes reputation); you bump the tag in git; docker compose pull && docker compose up -d in that one directory. Auto-updaters like Watchtower are fine for stateless odds and ends, but keep them away from databases — the full argument is in updating containers without fires.
The keys that save you at 2am
Three Compose features earn permanent residence. restart: unless-stopped on everything, so a power cut isn't a morning of manual starts. Healthchecks plus conditional dependencies, so apps stop losing races against their databases:
services:
db:
image: postgres:16.6
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperless"]
interval: 5s
retries: 5
paperless:
depends_on:
db:
condition: service_healthy
And log limits, because Docker's default json-file driver grows without bound and a chatty container will eat your disk in a month — set max-size: "10m" and max-file: "3" per service, or once for the whole daemon in /etc/docker/daemon.json and stop thinking about it. A YAML anchor block (x-defaults) holds the restart policy and log options in one place per file instead of five.
What I'd do
Adopt the layout wholesale: /opt/stacks, one directory per restart-unit, proxy network created day one, ports published only by the proxy, .env gitignored with a committed example, tags pinned, logs capped, healthchecks on every database. Then put the tree in a private git repo and treat every change as a commit — six months from now the question is never "what's running", it's "what changed Tuesday", and git log answers it in one line. The whole pattern costs an afternoon to retrofit onto an existing server, and it's the difference between a homelab you operate and one you excavate.