Blog

Managing My Home Server Stack with Docker Compose

The /opt/stack directory tree on a home server

Once I crossed three services, I stopped writing long docker run commands. Everything went into Compose, for exactly one reason: when I switch machines, I copy the directory, edit .env, and it boots. This post doesn't follow "concept chapters" — it walks down the directory tree. Wherever your eyes land, that's the config I'm explaining.

There was a week when I kept the startup commands for five services in my notes app, held together by line-continuation backslashes. Then a disk went bad, I reinstalled, the notes survived, and the paths and volume names no longer matched. Recovery felt like archaeology. From that day on I decided: order has to live on the disk, not just in my head.

Start at the door of /opt/stack

/opt/stack/
  README.md
  proxy/
  immich/
  gitea/
  _shared/

The README.md is written in plain language: which services exist, what domains they use, where backups land, what I changed last night. Three months from now you won't remember "why Gitea skips port 3000 and goes through the reverse proxy".

_shared/ holds .env.example and shared notes, not real secrets. Real secrets live in each project's .env, with tight permissions, and never touch git.

One subdirectory per service — no shared mega-compose unless there's a hard dependency. Mega-files give you brutal merge conflicts and coarse rollbacks. At home scale, "multiple files + a shared network" beats "single-file hero".

Into proxy/: the reverse proxy gets its own stack

/opt/stack/proxy/
  docker-compose.yml
  # optional: dynamic config, cert cache directory notes

This is usually Traefik or Caddy around here. It joins the external network proxy, and other services join as needed. You create the network once:

docker network create proxy

Principle: only services that actually need to be proxied join proxy. Database containers stay on the project's internal network and never touch proxy. A public scan hitting the reverse proxy is not the same as a scan hitting the Postgres port — provided you didn't hang the database off the same face-exposed network.

I always compose up the proxy stack first and compose down it last. Business containers can be poked and prodded freely, but keep the entry layer stable or every domain 502s at once. In the tree it sits level with the apps; in your head, treat it as infrastructure.

Into immich/: a typical compose skeleton

/opt/stack/immich/
  docker-compose.yml
  .env

The skeleton looks like this (illustrative):

services:
  app:
    image: example/app:1.2.3
    restart: unless-stopped
    env_file: .env
    networks:
      - internal
      - proxy
    volumes:
      - /data/app:/data
    labels:
      - traefik.enable=true
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  db:
    image: postgres:16
    networks:
      - internal
    volumes:
      - /data/app/db:/var/lib/postgresql/data

networks:
  internal:
  proxy:
    external: true

Item by item, in directory context:

  • Pin the image tag. Use latest sparingly. Updating is a deliberate act, not a lottery.
  • Absolute /data/... paths. Data lives outside the compose directory, so a reinstall can restore the stack and the data separately.
  • env_file: .env. Secrets stay out of the yml, and the yml can safely go into private git.
  • restart: unless-stopped. Services come back on their own after a host reboot; things you stopped by hand don't get force-started.
  • Cap the logging. The defaults can fill a small disk. Cap it up front.

Timezone goes in environment or .env, uniformly:

environment:
  TZ: Asia/Shanghai

Into gitea/: same tree, different data weight

/opt/stack/gitea/
  docker-compose.yml
  .env

Gitea's repo directories get heavy. The convention stays the same: compose in /opt/stack/gitea, bare repos in /data/gitea. Some people tuck data under ./data inside the project — on a move it tangles up with the git repo itself. I prefer physical separation.

depends_on guarantees start order, not readiness. If the database has to be solid, use a healthcheck plus a conditional dependency, or accept the "fail first, retry later" loop that unless-stopped gives you. Don't believe for a second that writing depends_on means never seeing connection refused.

Into _shared/ and README: conventions beat tools

/opt/stack/_shared/
  .env.example

.env.example lists variable names and fake values, with comments saying where to get each key and how often to rotate it. On a fresh machine: cp .env.example .env, fill in real values. Far more civilized than digging secrets out of chat history.

The README always carries one table: service name, compose path, data path, whether it's in proxy, and the backup command entry. The tree answers "where"; the table answers "how to operate".

Some people think the layout is too fragmented. The fragmentation is deliberate: when one service blows up, you enter exactly that folder and fix it, instead of dredging through two thousand lines of yml. The worst thing that can happen to a homelab isn't "not elegant enough" — it's not being able to find the line you need to change at 2 a.m.

Updating under this tree

Stand in a service directory:

cd /opt/stack/immich
docker compose pull
docker compose up -d
docker compose logs -f --tail=100

If something breaks, flip the tag in compose back to the previous version and up -d again. Stagger system apt upgrade away from bulk container pulls. Don't do both on a Friday night — when it breaks, you need sleep.

I don't run watchtower full-auto updates on critical services. A homelab can afford to be lazy; it can't afford to be brainless. The tree is still there, versions are diffable in the yml, and that's what gives rollback a handle to grab.

Moving the whole tree to another machine

One weekend I rehearsed it:

  1. Install Docker on the new machine, create the proxy network
  2. rsync /opt/stack and the matching /data volumes
  3. Edit each project's .env — domains and secrets
  4. Bring each up with compose up -d, proxy first, then the apps

The failure points usually live in the tree: uid permissions that don't match, absolute paths hardcoded to the old host, a forgotten external network, a database major-version jump. One rehearsal is worth ten pages of "migration-ready architecture". I even compose down on the old machine deliberately before bringing things up on the new one, just to make sure I'm not living in the illusion that two hosts can write the same network storage at once.

The backup boundary follows the tree too: compose and .env go into private git (mind the secrets); data volumes back up per service — databases with logical backups or the official tools, file trees with restic. Back up only the volume files and not "how to start the thing", and restore becomes a guessing game.

Compose isn't the silver bullet of orchestration; it's an order tool at home scale. Clean directories, layered networks, pinned versions, a move you've actually rehearsed — that stack follows you across disks, hosts, and cities. When you're lost, walk /opt/stack from the top again. It's faster than starting from a tutorial search. As long as the tree is still there, you still know where your life's data lives.

← Back to Blog