Running a single Docker container from the command line is easy. Running twenty interconnected services across a media stack, a monitoring stack, a reverse proxy, and a handful of self-hosted web apps — and being able to tear them all down, migrate them to a new machine, and bring them back up in minutes — is a different problem entirely. That’s exactly the problem Docker Compose was built to solve.
This guide is for people who already know basic Docker but want to level up: understanding the full anatomy of a Compose file, getting networking and healthchecks right, building real-world homelab stacks, and knowing the patterns that separate a stack that just works from one you can trust.
Why Docker Compose for Homelabs?
Before justifying Compose, it’s worth understanding what you’re escaping. A typical three-service stack without Compose might look like this buried in a shell script or a personal wiki:
1
2
3
4
5
6
7
8
9
|
docker run -d \
--name jellyfin \
--restart unless-stopped \
-e PUID=1000 -e PGID=1000 \
-v /mnt/media:/media \
-v /opt/jellyfin/config:/config \
-p 8096:8096 \
--network media-net \
jellyfin/jellyfin:latest
|
Multiply that by ten services, add shared networks, inter-service dependencies, and environment-specific variables, and you have a management nightmare. Changes require finding the right incantation, re-running commands manually, and hoping you got all the flags right.
Compose turns that into a single YAML file that you can read, version-control, diff, and share. The entire state of a stack — every service, network, volume, and environment variable — lives in one place.
Key benefits for homelabs:
- Declarative and reproducible.
docker compose up -d always produces the same result. No mental state required.
- Version controlled. Your stack configuration lives in git. Rolling back means
git checkout plus docker compose up -d.
- Dependency management. Compose handles service startup ordering, health dependencies, and inter-service networking automatically.
- Portable. Moving a stack to a new host is
rsync plus docker compose up -d. No custom scripts to reverse-engineer.
- Easier iteration. Change one line, run
docker compose up -d, and only the affected service is recreated.
Compose vs. Kubernetes / K3s
The honest answer: for most homelabs, Compose is the right tool. Kubernetes is exceptionally powerful, but it brings enormous operational complexity. A three-node K3s cluster needs persistent storage configuration (Longhorn, NFS, local-path provisioner), ingress controllers, secret management (Sealed Secrets, External Secrets), and a mental model an order of magnitude more complex than Compose.
Reach for Kubernetes when you need multi-node scheduling, self-healing across physical hosts, rolling deployments with zero downtime at scale, or you’re practicing for a job that uses it. For a single NUC or a two-server homelab running Jellyfin, Grafana, and a few web apps, Compose is simpler, faster to iterate on, and easier to understand at 2 AM when something is broken.
Compose File Anatomy
A Compose file is a YAML document, typically named docker-compose.yml (or compose.yml — both are recognized). At the top level, it contains four main sections: services, networks, volumes, and secrets.
A note on the version: field: In Compose V2 (the current version, shipped as docker compose rather than the legacy docker-compose), the top-level version: key is deprecated and ignored. You can omit it entirely. If you see it in old examples, that’s fine — Compose will just warn you.
Services
Every container you want to run is a service. Here’s a heavily annotated example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
|
services:
myapp:
# The image to pull. Pin to a specific digest in production.
image: nginx:1.27-alpine
# Give the container a predictable name instead of the auto-generated one.
container_name: myapp
# Restart policy. 'unless-stopped' is the homelab sweet spot:
# restart after crashes and after Docker daemon restarts, but
# respect manual `docker compose stop`.
restart: unless-stopped
# Other options:
# always — restart unconditionally (even after manual stop)
# on-failure — restart only on non-zero exit code
# on-failure:5 — restart on failure, max 5 attempts
# no — never restart (default)
# Environment variables.
environment:
APP_ENV: production
LOG_LEVEL: info
# Or load from a file. These two approaches can coexist.
env_file:
- .env
- .env.local # Overrides .env if keys conflict
# Port mapping: HOST_IP:HOST_PORT:CONTAINER_PORT
ports:
- "8080:80" # Bind to all interfaces
- "127.0.0.1:9090:9090" # Bind only to localhost — safer for admin UIs
# Volumes: HOST_PATH:CONTAINER_PATH:OPTIONS
volumes:
- ./config/nginx.conf:/etc/nginx/nginx.conf:ro # Bind mount, read-only
- app_data:/var/www/html # Named volume
- type: tmpfs # RAM-backed, lost on stop
target: /tmp
# Labels — used by Traefik, Watchtower, and others for metadata.
labels:
traefik.enable: "true"
traefik.http.routers.myapp.rule: "Host(`app.example.com`)"
traefik.http.routers.myapp.entrypoints: websecure
traefik.http.services.myapp.loadbalancer.server.port: "80"
# Networks this service is attached to.
networks:
- frontend
- backend
# Dependencies — this service won't start until 'db' is healthy.
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
frontend:
backend:
volumes:
app_data:
db_data:
|
Environment Management
How you handle environment variables in Compose is one of the most important decisions you’ll make, both for security and for flexibility across environments.
The .env File
When you run docker compose up, Compose automatically loads a file named .env in the same directory and makes those variables available for substitution in the Compose file itself (not inside containers, unless you also reference them in environment:).
1
2
3
4
5
6
7
8
9
10
11
12
|
# .env
COMPOSE_PROJECT_NAME=myapp
POSTGRES_DB=appdb
POSTGRES_USER=appuser
POSTGRES_PASSWORD=supersecretpassword
GRAFANA_ADMIN_PASSWORD=anothersecret
# Image tags — pin these for reproducibility
POSTGRES_TAG=16-alpine
GRAFANA_TAG=10.4.0
|
Then in docker-compose.yml:
1
2
3
4
5
6
7
|
services:
db:
image: postgres:${POSTGRES_TAG}
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
What goes in .env vs docker-compose.yml:
.env: secrets, host-specific paths, image tags, anything that changes between machines or environments
docker-compose.yml: service topology, port mappings, volume definitions, network structure, labels — things that are the same everywhere
Add .env to your .gitignore. Commit a .env.example with placeholder values so others know what variables are needed.
Multiple Environment Files
For different environments, use --env-file:
1
2
3
4
5
|
# Development
docker compose --env-file .env.dev up -d
# Production
docker compose --env-file .env.prod up -d
|
Compose Profiles
Profiles let you optionally include services without having to comment them in and out:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
services:
app:
image: myapp:latest
# No profiles — always starts
debug-proxy:
image: mitmproxy/mitmproxy
profiles:
- debug
# Only starts when 'debug' profile is active
mail-catcher:
image: mailhog/mailhog
profiles:
- dev
|
1
2
3
4
5
6
7
8
|
# Normal start — only 'app' runs
docker compose up -d
# Start with debug tools
docker compose --profile debug up -d
# Multiple profiles
docker compose --profile debug --profile dev up -d
|
Override Files
docker-compose.override.yml is automatically merged with docker-compose.yml when present. This is the standard mechanism for dev/prod differences:
1
2
3
4
5
6
7
8
9
10
11
12
|
# docker-compose.override.yml (for local dev — NOT committed to git for prod machines)
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app # Live source code mount
environment:
DEBUG: "true"
ports:
- "5678:5678" # Debugger port
|
For production, you can explicitly exclude the override file, or use a separate override:
1
2
3
4
5
|
# Explicitly use only the base file
docker compose -f docker-compose.yml up -d
# Use a production override
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
Keeping Secrets Out of Compose Files
For sensitive credentials, the options in order of security:
- Environment variables at runtime — set
POSTGRES_PASSWORD in the shell before running Compose, rather than storing it in .env
- Docker secrets — native secret management for Swarm mode; not available in plain Compose
- External secret managers — Vault, AWS Secrets Manager, Doppler — inject at deploy time
- Minimum viable approach —
.env file on disk with chmod 600, not committed to git
For a homelab, option 4 is usually sufficient if you’re the only one with access to the machine.
Networking in Compose
Networking is where many Compose configurations go wrong. Getting it right means understanding exactly what Compose does by default, and why you should override most of it.
The Default Network
If you define no networks in your Compose file, Compose creates a single bridge network named <project>_default and attaches all services to it. Every service can reach every other service by its service name as a hostname. That’s convenient, but it means all your services are on the same flat network with no isolation between them.
Why You Should Always Define Networks Explicitly
Explicit networks give you:
- Isolation — a compromised container on the
frontend network can’t directly reach your database on the backend network
- Clarity — the file documents exactly which services can talk to which
- Cross-stack connectivity — you can attach a service to an
external network created by another Compose stack
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
services:
traefik:
image: traefik:v3.0
networks:
- traefik_public # External network shared across stacks
- internal
app:
image: myapp:latest
networks:
- internal
- db_net
db:
image: postgres:16
networks:
- db_net # Only the DB and app can talk here
# Traefik and other services on 'internal' cannot reach the DB
networks:
traefik_public:
external: true # Must already exist; created by the Traefik stack
internal:
driver: bridge
db_net:
driver: bridge
internal: true # No outbound internet access from this network
|
Inter-Service DNS Resolution
Within a Compose network, each service is reachable by its service name as a DNS hostname. If your service is named db, other services on the same network reach it as db:5432. The container name is also registered, but the service name is what you should use — it stays consistent regardless of container naming.
1
2
3
4
5
6
7
8
9
|
services:
app:
environment:
DATABASE_URL: "postgresql://user:pass@db:5432/mydb"
REDIS_URL: "redis://cache:6379"
db:
image: postgres:16
cache:
image: redis:7-alpine
|
External Networks
The killer feature for multi-stack homelabs: external networks let separate Compose stacks communicate without merging their Compose files.
1
2
|
# Create the shared network once
docker network create traefik_public
|
Then in your Traefik stack:
1
2
3
|
networks:
traefik_public:
external: true
|
And in any other stack that needs Traefik to proxy it:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
services:
myapp:
networks:
- default
- traefik_public
labels:
traefik.enable: "true"
traefik.http.routers.myapp.rule: "Host(`myapp.example.com`)"
traefik.docker.network: traefik_public
networks:
traefik_public:
external: true
|
Special Network Modes
1
2
3
4
5
6
7
8
9
10
11
|
services:
# Use the host's network namespace directly.
# No port mapping needed; the service binds directly to the host.
# Useful for high-performance or low-latency needs.
high_perf_service:
network_mode: host
# No network access at all.
# Useful for batch processing containers that don't need networking.
isolated_job:
network_mode: none
|
Healthchecks
Healthchecks are the mechanism by which Docker knows whether a container is actually ready to serve traffic — not just that the process started. This distinction is critical for depends_on to work correctly.
Writing Healthchecks
A healthcheck is a command Docker runs inside the container at regular intervals. Exit code 0 means healthy; anything else means unhealthy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
services:
# PostgreSQL — use pg_isready
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s # How often to run the check
timeout: 5s # Max time before marking as failed
retries: 5 # Consecutive failures before marking unhealthy
start_period: 30s # Grace period — failures don't count during startup
# Redis — use redis-cli ping
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
# HTTP service — use wget (more commonly available in alpine images than curl)
webserver:
image: nginx:alpine
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
# Or with curl if available
api:
image: myapi:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
|
depends_on with Conditions
The full power of depends_on requires healthchecks:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
services:
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy # Wait until DB healthcheck passes
cache:
condition: service_healthy # Wait until Redis healthcheck passes
migrations:
condition: service_completed_successfully # Wait for one-shot init task
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
migrations:
image: myapp:latest
command: ["python", "manage.py", "migrate"]
depends_on:
db:
condition: service_healthy
restart: "no"
|
Important: condition: service_started (the default) only waits for the container process to exist, not for it to be ready. This is the source of the classic “my app crashes because the database isn’t ready yet” bug. Always use service_healthy with a proper healthcheck for any service that needs to wait for another service to be ready.
Checking Health Status
1
2
3
4
5
6
7
8
|
# See health status in a table
docker compose ps
# Detailed health info including last check output
docker inspect <container_name> | jq '.[0].State.Health'
# Watch healthcheck history
docker inspect <container_name> --format='{{json .State.Health.Log}}' | jq
|
Real-World Homelab Stack Examples
A complete Jellyfin + qBittorrent + Prowlarr + Radarr + Sonarr stack with a shared media volume and proper network isolation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
|
# /opt/stacks/media/docker-compose.yml
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-America/New_York}
volumes:
- ${CONFIG_ROOT}/jellyfin:/config
- ${MEDIA_ROOT}/movies:/data/movies:ro
- ${MEDIA_ROOT}/tv:/data/tv:ro
- ${MEDIA_ROOT}/music:/data/music:ro
- /dev/shm:/dev/shm # Transcoding buffer in RAM
ports:
- "8096:8096"
devices:
- /dev/dri:/dev/dri # Intel/AMD GPU for hardware transcoding
networks:
- media_net
- traefik_public
labels:
traefik.enable: "true"
traefik.http.routers.jellyfin.rule: "Host(`jellyfin.${DOMAIN}`)"
traefik.http.routers.jellyfin.entrypoints: websecure
traefik.http.services.jellyfin.loadbalancer.server.port: "8096"
traefik.docker.network: traefik_public
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
restart: unless-stopped
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-America/New_York}
- WEBUI_PORT=8080
volumes:
- ${CONFIG_ROOT}/qbittorrent:/config
- ${MEDIA_ROOT}/downloads:/downloads
ports:
- "127.0.0.1:8080:8080" # Web UI on localhost only
- "6881:6881" # Torrent port (TCP)
- "6881:6881/udp" # Torrent port (UDP)
networks:
- media_net
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
restart: unless-stopped
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-America/New_York}
volumes:
- ${CONFIG_ROOT}/prowlarr:/config
ports:
- "127.0.0.1:9696:9696"
networks:
- media_net
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
restart: unless-stopped
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-America/New_York}
volumes:
- ${CONFIG_ROOT}/radarr:/config
- ${MEDIA_ROOT}:/data # Full access: radarr moves files from downloads to movies
ports:
- "127.0.0.1:7878:7878"
networks:
- media_net
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
restart: unless-stopped
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-America/New_York}
volumes:
- ${CONFIG_ROOT}/sonarr:/config
- ${MEDIA_ROOT}:/data
ports:
- "127.0.0.1:8989:8989"
networks:
- media_net
networks:
media_net:
driver: bridge
traefik_public:
external: true
# .env for this stack
# PUID=1000
# PGID=1000
# TZ=America/New_York
# DOMAIN=home.example.com
# CONFIG_ROOT=/opt/appdata
# MEDIA_ROOT=/mnt/nas/media
|
A note on PUID/PGID: linuxserver.io images use these environment variables to run the internal process as a specific user on the host, matching file ownership on bind mounts. Set them to the UID and GID of your media user (id youruser to check).
Example 2: Monitoring Stack
Prometheus + Grafana + Node Exporter — the standard homelab observability trio.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
|
# /opt/stacks/monitoring/docker-compose.yml
services:
prometheus:
image: prom/prometheus:v2.51.0
container_name: prometheus
restart: unless-stopped
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle' # Allows hot reload via POST /-/reload
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./rules:/etc/prometheus/rules:ro
- prometheus_data:/prometheus
ports:
- "127.0.0.1:9090:9090"
networks:
- monitoring
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
grafana:
image: grafana/grafana:10.4.0
container_name: grafana
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_DOMAIN: ${DOMAIN}
GF_SERVER_ROOT_URL: "https://grafana.${DOMAIN}"
GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-simple-json-datasource
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
ports:
- "127.0.0.1:3000:3000"
networks:
- monitoring
- traefik_public
depends_on:
prometheus:
condition: service_healthy
labels:
traefik.enable: "true"
traefik.http.routers.grafana.rule: "Host(`grafana.${DOMAIN}`)"
traefik.http.routers.grafana.entrypoints: websecure
traefik.http.services.grafana.loadbalancer.server.port: "3000"
traefik.docker.network: traefik_public
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
node_exporter:
image: prom/node-exporter:v1.7.0
container_name: node_exporter
restart: unless-stopped
command:
- '--path.rootfs=/host'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
volumes:
- /:/host:ro,rslave # Read-only access to the host filesystem
network_mode: host # Needs host networking to see all interfaces
pid: host # Needs host PID namespace for process metrics
alertmanager:
image: prom/alertmanager:v0.27.0
container_name: alertmanager
restart: unless-stopped
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
volumes:
- ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager
ports:
- "127.0.0.1:9093:9093"
networks:
- monitoring
networks:
monitoring:
driver: bridge
traefik_public:
external: true
volumes:
prometheus_data:
grafana_data:
alertmanager_data:
|
And a minimal prometheus.yml to go with it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "rules/*.yml"
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: node
static_configs:
- targets: ["node_exporter:9100"]
- job_name: cadvisor
static_configs:
- targets: ["cadvisor:8080"]
|
Example 3: Traefik Reverse Proxy + Sample App
A complete Traefik stack with automatic HTTPS and a sample application that registers itself via labels.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
# /opt/stacks/traefik/docker-compose.yml
services:
traefik:
image: traefik:v3.0
container_name: traefik
restart: unless-stopped
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=traefik_public"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--log.level=INFO"
- "--accesslog=true"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_certs:/certs
networks:
- traefik_public
labels:
traefik.enable: "true"
# Dashboard router
traefik.http.routers.traefik-dashboard.rule: "Host(`traefik.${DOMAIN}`)"
traefik.http.routers.traefik-dashboard.entrypoints: websecure
traefik.http.routers.traefik-dashboard.service: api@internal
traefik.http.routers.traefik-dashboard.tls.certresolver: letsencrypt
# Protect dashboard with basic auth
traefik.http.routers.traefik-dashboard.middlewares: dashboard-auth
traefik.http.middlewares.dashboard-auth.basicauth.users: "${TRAEFIK_BASIC_AUTH}"
networks:
traefik_public:
name: traefik_public # Explicit name so other stacks can reference it
volumes:
traefik_certs:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
# /opt/stacks/myapp/docker-compose.yml — any app registering with Traefik
services:
app:
image: nginxdemos/hello:latest
container_name: hello-app
restart: unless-stopped
networks:
- default
- traefik_public
labels:
traefik.enable: "true"
traefik.docker.network: traefik_public
traefik.http.routers.hello.rule: "Host(`hello.${DOMAIN}`)"
traefik.http.routers.hello.entrypoints: websecure
traefik.http.routers.hello.tls.certresolver: letsencrypt
traefik.http.services.hello.loadbalancer.server.port: "80"
networks:
traefik_public:
external: true
|
Managing Multiple Stacks
As your homelab grows, you’ll have many independent stacks. The key is a consistent directory structure:
/opt/stacks/
├── traefik/
│ ├── docker-compose.yml
│ └── .env
├── monitoring/
│ ├── docker-compose.yml
│ ├── prometheus.yml
│ ├── alertmanager.yml
│ ├── rules/
│ │ └── node.yml
│ ├── grafana/
│ │ └── provisioning/
│ └── .env
├── media/
│ ├── docker-compose.yml
│ └── .env
└── nextcloud/
├── docker-compose.yml
└── .env
One directory per stack. Each directory contains everything needed to bring that stack up — no implicit dependencies on files outside the directory.
Project Names
Compose derives the project name from the directory name by default. If your stack is in /opt/stacks/monitoring, the project is monitoring and all containers are prefixed accordingly (monitoring-prometheus-1, etc.). You can override this:
1
2
3
4
5
|
# Explicit project name
docker compose -p monitoring up -d
# Or in .env
COMPOSE_PROJECT_NAME=monitoring
|
A Makefile for Stack Management
A simple Makefile at /opt/stacks/Makefile makes managing multiple stacks ergonomic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
STACKS := traefik monitoring media nextcloud
.PHONY: up down pull logs ps $(STACKS)
up:
@for stack in $(STACKS); do \
echo "==> Starting $$stack"; \
docker compose -f $$stack/docker-compose.yml --env-file $$stack/.env -p $$stack up -d; \
done
down:
@for stack in $(STACKS); do \
echo "==> Stopping $$stack"; \
docker compose -f $$stack/docker-compose.yml --env-file $$stack/.env -p $$stack down; \
done
pull:
@for stack in $(STACKS); do \
echo "==> Pulling $$stack"; \
docker compose -f $$stack/docker-compose.yml --env-file $$stack/.env -p $$stack pull; \
done
update:
make pull && make up
ps:
@docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
logs:
@docker compose -f $(STACK)/docker-compose.yml -p $(STACK) logs -f --tail=100
# make logs STACK=monitoring
|
Essential Compose Commands
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
# Start all services in the background
docker compose up -d
# Start and rebuild images
docker compose up -d --build
# Recreate containers even if nothing changed
docker compose up -d --force-recreate
# Pull latest images and recreate (rolling update)
docker compose pull && docker compose up -d
# Stop and remove containers (keeps volumes and images)
docker compose down
# Stop and remove everything including named volumes — DESTRUCTIVE
docker compose down -v
# Stop without removing
docker compose stop
# Restart a specific service
docker compose restart myapp
# Follow logs from all services
docker compose logs -f
# Follow logs from a specific service, last 100 lines
docker compose logs -f --tail=100 myapp
# Run a command in a running container
docker compose exec myapp bash
docker compose exec db psql -U postgres
# Run a one-off command in a new container, remove it when done
docker compose run --rm myapp python manage.py createsuperuser
docker compose run --rm db pg_dump -U postgres mydb > backup.sql
# See running processes
docker compose ps
docker compose top
# Validate and print the fully merged config (great for debugging)
docker compose config
# Show resource usage
docker stats $(docker compose ps -q)
|
Data Persistence Patterns
Named Volumes vs Bind Mounts
Named volumes are managed by Docker and stored in Docker’s data directory (/var/lib/docker/volumes/). Use them for application data that doesn’t need direct host access — databases, Grafana data, application state.
1
2
3
|
volumes:
postgres_data: # Named volume — Docker manages it
driver: local
|
Bind mounts directly map a host path into the container. Use them for config files you want to edit directly, media files managed outside Docker, or development source code.
1
2
3
|
volumes:
- /opt/appdata/myapp:/config # Bind mount
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
The practical rule: if you need to read or edit the files directly from the host, use a bind mount. If the container owns the data and you just need it to persist, use a named volume.
Backup Strategy
Named volumes:
1
2
3
4
5
6
7
8
9
10
11
|
# Backup a named volume to a tar archive
docker run --rm \
-v monitoring_prometheus_data:/source:ro \
-v /backups:/backup \
alpine tar czf /backup/prometheus_data_$(date +%Y%m%d).tar.gz -C /source .
# Restore
docker run --rm \
-v monitoring_prometheus_data:/target \
-v /backups:/backup \
alpine tar xzf /backup/prometheus_data_20260325.tar.gz -C /target
|
Bind mounts: use rsync or Restic directly on the host:
1
2
3
4
5
|
# Simple rsync backup
rsync -av --delete /opt/appdata/ /mnt/backup/appdata/
# Restic to Backblaze B2
restic -r b2:mybucket:appdata backup /opt/appdata
|
Moving Stacks Between Hosts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
# On the source host: export each named volume
for vol in monitoring_prometheus_data monitoring_grafana_data; do
docker run --rm \
-v ${vol}:/source:ro \
-v $(pwd):/backup \
alpine tar czf /backup/${vol}.tar.gz -C /source .
done
# Transfer the Compose files and volume archives
rsync -av /opt/stacks/monitoring/ newhost:/opt/stacks/monitoring/
rsync -av *.tar.gz newhost:/tmp/volumes/
# On the destination host: create volumes and import data
docker compose up --no-start # Creates volumes without starting containers
for archive in /tmp/volumes/*.tar.gz; do
volname=$(basename $archive .tar.gz)
docker run --rm \
-v ${volname}:/target \
-v /tmp/volumes:/backup \
alpine tar xzf /backup/$(basename $archive) -C /target
done
docker compose up -d
|
Permission Gotchas
The most common issue with bind mounts is file ownership. The UID/GID inside the container must match the ownership of the files on the host, or the container process gets permission errors.
For linuxserver.io images, set PUID and PGID to the host user that owns the files:
1
2
3
|
# Find your UID/GID
id youruser
# uid=1000(youruser) gid=1000(youruser)
|
For official images that don’t have this mechanism, either chown the host directory to match the UID the container runs as, or use named volumes (Docker handles permissions automatically).
Production-Readiness Tips
Resource Limits
Prevent a runaway container from taking down your host:
1
2
3
4
5
6
7
8
9
10
11
|
services:
myapp:
image: myapp:latest
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
|
Note: deploy.resources is the current syntax; the older mem_limit and cpus top-level keys are still supported in Compose V2 but deploy.resources is preferred.
Logging Configuration
By default, Docker uses the json-file driver with no size limits — your disk will eventually fill up. Set global defaults in /etc/docker/daemon.json:
1
2
3
4
5
6
7
|
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
|
Or per-service in Compose:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
services:
myapp:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Or ship to Loki
myservice:
logging:
driver: loki
options:
loki-url: "http://localhost:3100/loki/api/v1/push"
loki-batch-size: "400"
|
Watchtower for Automatic Updates
Watchtower monitors running containers and automatically updates them when new images are published:
1
2
3
4
5
6
7
8
9
10
11
12
|
services:
watchtower:
image: containrrr/watchtower
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_CLEANUP: "true" # Remove old images after update
WATCHTOWER_SCHEDULE: "0 0 4 * * *" # Run at 4 AM daily (cron format)
WATCHTOWER_NOTIFICATIONS: shoutrrr
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFY_URL}"
|
Use Watchtower with caution on production-critical services. Pin your images to specific tags (not latest) for anything important, and use Watchtower’s label-based opt-in/opt-out:
1
2
3
4
5
6
7
8
9
10
|
services:
# Watchtower will update this
my_disposable_app:
labels:
com.centurylinklabs.watchtower.enable: "true"
# Watchtower will NOT update this
critical_database:
labels:
com.centurylinklabs.watchtower.enable: "false"
|
Security Options
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
services:
myapp:
image: myapp:latest
# Don't allow the process to gain new privileges
security_opt:
- no-new-privileges:true
# Drop all capabilities, add back only what's needed
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Only if binding to ports < 1024
# Run as a specific non-root user
user: "1000:1000"
# Make the root filesystem read-only
read_only: true
# Provide writable directories for the app's runtime needs
tmpfs:
- /tmp
- /var/run
|
Troubleshooting
Container Won’t Start
1
2
3
4
5
6
7
8
|
# Check the logs immediately
docker compose logs myapp
# Get detailed container state including exit code
docker inspect myapp | jq '.[0].State'
# If the container exits immediately, override the entrypoint to get a shell
docker compose run --rm --entrypoint sh myapp
|
Common causes: missing environment variables, missing files referenced as volumes, ports already in use, permission errors on bind mounts.
Networking Issues
1
2
3
4
5
6
7
8
9
10
11
12
|
# Check network configuration
docker network inspect <network_name>
# Verify which networks a container is on
docker inspect myapp | jq '.[0].NetworkSettings.Networks'
# Test connectivity between services
docker compose exec myapp ping db
docker compose exec myapp nc -zv db 5432
# DNS resolution check
docker compose exec myapp nslookup db
|
Volume Permission Errors
1
2
3
4
5
6
|
# Check what UID the container process runs as
docker compose exec myapp id
docker compose exec myapp ls -la /path/to/volume
# Fix permissions on the host bind mount
sudo chown -R 1000:1000 /opt/appdata/myapp
|
Port Conflicts
1
2
3
4
5
6
|
# Find what's using a port
ss -tlnp | grep :8080
lsof -i :8080
# Or if another container is using it
docker ps --format "{{.Names}}\t{{.Ports}}" | grep 8080
|
“depends_on Doesn’t Wait for the DB”
The root cause: depends_on without a condition only waits for the container to start, not for the application inside to be ready. The fix is healthchecks plus condition: service_healthy as shown in the Healthchecks section above.
If you can’t add a healthcheck to a service, the application-level workaround is a retry loop in your entrypoint script:
1
2
3
4
5
6
7
|
#!/bin/sh
# wait-for-db.sh
until pg_isready -h db -U "${POSTGRES_USER}"; do
echo "Waiting for postgres..."
sleep 2
done
exec "$@"
|
1
2
3
4
|
services:
app:
entrypoint: ["/wait-for-db.sh"]
command: ["python", "manage.py", "runserver"]
|
This is a fallback. Healthchecks are the correct solution.
Diagnosing Slow Startup
1
2
3
4
5
|
# Watch container events in real time
docker events --filter container=myapp
# Time how long a service takes to become healthy
watch -n 1 'docker inspect myapp | jq ".[0].State.Health.Status"'
|
Putting It All Together
Docker Compose is the right foundation for a homelab. It gives you the declarative, reproducible, version-controlled infrastructure you need without the operational weight of a Kubernetes cluster. A well-structured Compose setup — explicit networks, proper healthchecks, .env-managed secrets, and a consistent directory layout — can grow with you from a single machine running five containers to a small cluster running dozens of services.
The patterns in this guide aren’t theoretical. They’re the ones that hold up over years of running a real homelab: migrating between machines, recovering from disk failures, experimenting with new services without breaking the ones that matter, and being able to look at a Compose file six months later and understand exactly what it does.
Version control your stacks. Write healthchecks. Use explicit networks. Keep secrets out of your YAML. The rest is just iteration.
Related posts: Traefik: The Complete Guide · Docker Best Practices · Monitoring & Observability · Securing the Home Lab
Comments