Blog / / 4 min read

One Compose project per directory

How I lay out the Docker side of my homelab so every stack can be deployed, validated and understood on its own.

  • docker
  • homelab
  • ci-cd

My first homelab repo had a folder called docker-compose/ with a bunch of files like streaming.yml and gitea.yml sitting next to each other, plus a shared .env at the top. To deploy something I’d scp the file to the host as compose.yml and run it there.

It worked. It also meant that the thing running on the server and the thing in my repo slowly stopped matching, and I never really knew which one was right.

So when I started homelab-docker, I gave myself one rule: every top level folder is one project, and it has everything it needs.

The layout

<project>/
  compose.yml      # always named compose.yml
  .env.example     # every key documented, fake values
  .env             # real values, never committed
  README.md        # short, points at the wiki page
  Dockerfile       # only if it builds an image
  data/            # tracked config that the stack reads

That’s it. To deploy anything it’s the same three steps every time:

cd <project>
cp .env.example .env && $EDITOR .env
docker compose up -d

Right now that’s about a dozen projects: my Gitea server (local only, reachable from the homelab network or VPN) and its runners, nightly backups, Nginx Proxy Manager with Authelia in front of it for SSO, LibreChat, a Lancache for game downloads, the media stack, Kavita, a Fabric Minecraft server, node exporter and cAdvisor for monitoring, an AirPrint bridge for my printer, LocalSend, and a WireGuard setup with keepalived for failover.

The rules that made it work

Secrets never go in the repo. Every .env is gitignored. Every project ships a .env.example that lists every key with a comment and a fake value. The example file is the documentation for what the stack needs.

Runtime state is gitignored too. Database files, caches, Minecraft world saves, runner state. Only configuration gets tracked. If I can’t rebuild it from the repo plus a backup, something is wrong.

Shared stuff lives in one place. A few stacks need the same config, like the Authelia and nginx snippets. Those live in a root data/ folder instead of being copied around.

Not everything has to be a container. LocalSend doesn’t ship an image, so that folder installs a pinned binary and a systemd user unit instead. It still lives in the same repo with the same layout, just without a compose.yml. I’d rather bend the rule a little than run some sketchy unofficial image.

One shared network. Most stacks join an external Docker network called linares-net, so the reverse proxy can reach them by container name. A few use host networking where they have to.

CI catches the dumb mistakes

The repo has a validate workflow that runs on every push, on my own Gitea runners. It does three things:

  1. Runs docker compose config -q against every */compose.yml, so a typo or a bad indent fails the push instead of failing on the server.
  2. Lints all the YAML with yamllint.
  3. Runs shellcheck on the backup scripts, since those get deployed by CI and stop containers as root. That’s the one place I really don’t want a quoting bug.

The compose check is a tiny loop:

for f in */compose.yml; do
  if docker compose -f "$f" config -q; then
    echo "OK   $f"
  else
    echo "FAIL $f"; fail=1
  fi
done

It’s not fancy, but it means a bad indent fails in CI instead of on the server.

Renovate also runs against the repo and opens PRs when images update, which beats me remembering to check release pages.

Docs live in the wiki

Every project’s README is basically a stub that says “see the wiki.” The real operational docs, like how to restore a backup, why a container stops itself, or what port something needs, live on my private Gitea wiki for the repo. I tried keeping long READMEs in each folder and they always went stale. A wiki is easier to edit when I’m in the middle of fixing something, which is exactly when I learn the stuff worth writing down.

What I’d tell past me

Pick a layout and be boring about it. The best part of this setup isn’t any one stack, it’s that every stack looks the same. When something breaks at night I don’t have to remember how I set that one up, because I set them all up the same way.