Docker Compose for Homelab Stacks

Design Compose files that survive reboots, upgrades, and your future self reading them six months from now.

Why Compose Over Plain `docker run`

A long docker run command is a one-shot artifact. The next time you upgrade or reboot, you are reconstructing it from shell history and luck. Compose files are declarative, version-controllable, and reproducible — the exact properties a homelab needs when you touch a stack once a quarter.

Anatomy of a Production-Ready Service

Every service in your stack should have these fields, even if some are inherited from defaults. Be explicit; future-you will thank present-you.

  • image: Pin to a specific tag, never latest. Use the digest (image@sha256:...) for security-sensitive containers.
  • container_name: Set explicitly so logs, exec, and DNS lookups are predictable. Without it, Compose autogenerates names that break scripts.
  • restart: Use unless-stopped for almost everything. always fights you during maintenance; no means a reboot kills the stack.
  • healthcheck: Define one. A container that is "running" but unhealthy still shows green in docker ps without this.
  • volumes: Named volumes for state, bind mounts for config. Never store data in the container filesystem.
  • networks: Attach to specific named networks. The default bridge is shared and loses you isolation.

A Real Stack: Reverse Proxy + App + Database

This pattern repeats across nearly every homelab service: a public-facing proxy fronting an app that talks to a private database. Compose makes the network topology obvious.

services:
  caddy:
    image: caddy:2.8-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - edge

  app:
    image: ghcr.io/example/app:1.4.2
    container_name: app
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
      LOG_LEVEL: info
    depends_on:
      db:
        condition: service_healthy
    networks:
      - edge
      - internal
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3

  db:
    image: postgres:16-alpine
    container_name: db
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  caddy_data:
  caddy_config:
  db_data:

networks:
  edge:
  internal:
    internal: true

Note that the internal network has internal: true — the database cannot reach the internet even if compromised. Only app bridges the two networks.

Secrets and Environment

  • Never commit secrets. Use a .env file alongside your Compose file and add it to .gitignore. Reference values with $${VAR_NAME}.
  • Use Docker secrets for highly sensitive values when running in Swarm mode. For standalone Compose, file-based env injection is the practical floor.
  • Keep a sample env file (.env.example) checked in with empty placeholders so the deployment shape is documented.

Networks: When to Split, When to Merge

Compose creates one network per project by default. For most stacks that is fine, but deliberate network design pays off:

  • Shared edge network: Define an external network for your reverse proxy and attach every public-facing service to it. Lets you run separate Compose projects that all sit behind one Caddy/Traefik instance.
  • Internal-only networks: Databases, caches, and background workers should live on networks marked internal: true. They get no NAT to the host or internet.
  • Avoid network_mode: host unless you genuinely need it (Pi-hole, some VPN clients). It bypasses all of Docker's network isolation.
# Create the shared edge network once
docker network create edge

# Then in each stack:
networks:
  edge:
    external: true

Volumes: Named vs Bind Mounts

  • Named volumes for opaque state: databases, app data, cache directories. Docker manages them and they survive container recreation.
  • Bind mounts for things you want to read/edit on the host: config files, Caddyfiles, custom scripts. Use :ro when the container does not need to write.
  • Backup story matters. Named volumes live in /var/lib/docker/volumes/ — include this in your backup job, or migrate to bind mounts under a known directory for simpler restic/borg targeting.

Healthchecks That Actually Help

A bad healthcheck is worse than none — it lies about state. Some rules:

  • Test the real path the app serves. A TCP probe on port 8080 says nothing about whether the app is wedged on a database connection.
  • Set start_period for slow-booting services (databases, Java apps). Otherwise retries exhaust before the app finishes starting.
  • Use depends_on with condition: service_healthy to enforce startup order. Plain depends_on only waits for the container to start, not to be ready.

Resource Limits

A single runaway container can starve the host. Set sane ceilings:

services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    deploy:
      resources:
        limits:
          cpus: "4.0"
          memory: 4G
        reservations:
          memory: 1G

For non-Swarm Compose, use mem_limit, cpus, and pids_limit at the top level of the service. Limits are insurance, not optimization — set them generously, just not unbounded.

Logging

Docker's default json-file driver grows unbounded. Cap it per service or set defaults in /etc/docker/daemon.json:

logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

For centralized logs, point the driver at Loki, Fluentd, or syslog. See the Loki + Promtail guide for the receiving side.

Upgrades Without Drama

  1. Pin image tags in Compose. Bump the tag in a commit so the change is reviewable.
  2. docker compose pull downloads the new image without affecting running containers.
  3. docker compose up -d recreates only changed services. Volumes persist.
  4. Verify healthchecks pass and the app responds before considering it done.
  5. docker image prune periodically to reclaim disk from old layers.

Common Pitfalls

  • Using latest: Reboots silently upgrade you. One day the stack stops working and you cannot tell why.
  • No backups of named volumes: A bad upgrade or a corrupted database erases everything. Test restores quarterly.
  • Privileged containers: Almost never necessary. Use capabilities (cap_add) and specific device mounts instead.
  • Hard-coded host paths: Makes the Compose file non-portable. Use relative paths or env vars ($${DATA_DIR}/config).

Validation Checklist

  • Every service has a pinned tag, restart policy, and healthcheck
  • Secrets are in .env (gitignored), not the Compose file
  • Sensitive backends live on an internal: true network
  • Named volumes are part of your backup job
  • Logs are capped or shipped elsewhere
  • docker compose config validates without warnings

- Crafted by Axiom|Spectre