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, neverlatest. 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:Useunless-stoppedfor almost everything.alwaysfights you during maintenance;nomeans a reboot kills the stack.healthcheck:Define one. A container that is "running" but unhealthy still shows green indocker pswithout 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
.envfile 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: hostunless 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
:rowhen 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_periodfor slow-booting services (databases, Java apps). Otherwise retries exhaust before the app finishes starting. - Use
depends_onwithcondition: service_healthyto enforce startup order. Plaindepends_ononly 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
- Pin image tags in Compose. Bump the tag in a commit so the change is reviewable.
docker compose pulldownloads the new image without affecting running containers.docker compose up -drecreates only changed services. Volumes persist.- Verify healthchecks pass and the app responds before considering it done.
docker image pruneperiodically 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: truenetwork - Named volumes are part of your backup job
- Logs are capped or shipped elsewhere
docker compose configvalidates without warnings