LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Docker Compose for Homelab: Multi-Service Stacks Done Right

dockerdocker-composehomelabcontainersself-hostingnetworkingdevops

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:

  1. Environment variables at runtime — set POSTGRES_PASSWORD in the shell before running Compose, rather than storing it in .env
  2. Docker secrets — native secret management for Swarm mode; not available in plain Compose
  3. External secret managers — Vault, AWS Secrets Manager, Doppler — inject at deploy time
  4. 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

Example 1: Media Server Stack

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