If you’ve been self-hosting for more than a week, you’ve already written a docker-compose.yml file. Maybe you copied one from a GitHub README, fired it up, and it worked. Magic.

Then six months passed. You have 15 services, four compose files scattered across your home directory, containers with restart: always that silently fail, hardcoded passwords in plain text, and a vague sense of dread whenever you ssh into your server.

Sound familiar? This guide is for you.

These are the Docker Compose best practices that will make your self-hosted stack actually maintainable β€” whether you’re running two containers or twenty.

πŸ’‘ This article contains affiliate links. If you buy through them, we earn a small commission at no extra cost to you. Learn more.

1. Use a Consistent Directory Structure

Before touching a single YAML key, get your folder layout right. A predictable structure means you can find anything in seconds and reason about your stack without digging through logs.

/opt/stacks/
β”œβ”€β”€ nextcloud/
β”‚   β”œβ”€β”€ docker-compose.yml
β”‚   β”œβ”€β”€ .env
β”‚   └── data/          # persistent volumes (optional)
β”œβ”€β”€ jellyfin/
β”‚   β”œβ”€β”€ docker-compose.yml
β”‚   └── .env
β”œβ”€β”€ traefik/
β”‚   β”œβ”€β”€ docker-compose.yml
β”‚   β”œβ”€β”€ .env
β”‚   └── config/
β”‚       └── traefik.yml
└── monitoring/
    β”œβ”€β”€ docker-compose.yml
    └── .env

Each service or logical group gets its own folder. Keep docker-compose.yml and .env side-by-side. Mount data into clearly named subdirectories or use named Docker volumes (more on that below).

Why it matters: When something breaks at 2 AM, you want to run cd /opt/stacks/nextcloud && docker compose logs β€” not hunt for where you put things.


2. Never Hardcode Secrets in Compose Files

This is the most important habit to build. Your docker-compose.yml is likely committed to Git, shared in forums, or accidentally shown on a stream. Hardcoded passwords don’t stay private.

Bad:

1
2
environment:
  POSTGRES_PASSWORD: mysupersecretpassword123

Good β€” use a .env file:

1
2
environment:
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

And in your .env file (same directory, never committed to Git):

1
POSTGRES_PASSWORD=mysupersecretpassword123

Add .env to your .gitignore:

.env
*.env

For an extra layer, use Docker secrets for truly sensitive values (database passwords, API keys):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
version: "3.8"

secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  db:
    image: postgres:16
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

Store ./secrets/db_password.txt with tight permissions:

1
2
echo "mysupersecretpassword123" > ./secrets/db_password.txt
chmod 600 ./secrets/db_password.txt

Not every image supports _FILE variants, but PostgreSQL, MariaDB, and many popular images do. Check the image docs.


3. Always Pin Image Tags

image: nginx:latest is a timebomb. The next time you docker compose pull && docker compose up -d, you might get a breaking change. For self-hosted stacks, stability beats novelty.

Bad:

1
image: linuxserver/jellyfin:latest

Good:

1
image: linuxserver/jellyfin:10.10.3

When you want to update, do it deliberately: check the changelog, update the tag, test, and roll forward.

Practical middle ground: Use a digest pin for absolute reproducibility, or use major/minor tags when the project guarantees semantic versioning. For linuxserver.io images, their versioned tags are stable and well-maintained.

If you’re using Watchtower for automated updates, that’s a separate conversation β€” but even then, know what you’re automatically updating.


4. Define Explicit Networks

By default, Docker Compose creates a network per project and connects all services to it. That’s fine for simple stacks, but for a multi-service homelab it creates implicit dependencies and makes your security posture fuzzy.

Be explicit. Create named networks and only attach services to the networks they need:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
networks:
  frontend:        # exposed to reverse proxy
    name: frontend
  backend:         # internal only
    name: backend

services:
  app:
    image: myapp:1.2.3
    networks:
      - frontend
      - backend

  db:
    image: postgres:16
    networks:
      - backend      # db has NO frontend access

  traefik:
    image: traefik:v3.3
    networks:
      - frontend

For services that need to connect across compose stacks (e.g., Traefik reaching app containers), use external networks:

1
2
3
4
networks:
  traefik_net:
    external: true
    name: traefik_net

Create it once on the host:

1
docker network create traefik_net

Now all your stacks can attach to this shared network, and Traefik can route to them without each stack needing to know about each other’s internals.


5. Set Resource Limits

Runaway containers can saturate your server. Set memory and CPU limits so one misbehaving service can’t take down everything else:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
services:
  photoprism:
    image: photoprism/photoprism:240915
    deploy:
      resources:
        limits:
          memory: 2g
          cpus: "1.5"
        reservations:
          memory: 512m
          cpus: "0.25"

Guidelines for a typical homelab:

  • Databases (PostgreSQL, MariaDB): 512MB–1GB memory limit
  • Media servers (Jellyfin, Plex): limit CPU to avoid transcoding eating everything
  • Web apps: 256MB–512MB is usually plenty
  • Background workers: low CPU reservation, burst as needed

If you’re on a resource-constrained machine (like a Raspberry Pi or a small mini PC), these limits are even more important. A single container stuck in a memory leak won’t crash the whole machine.


6. Add Health Checks

Docker can tell you a container is “running,” but that just means the process started. Health checks let Docker know if the service inside is actually working, enabling proper dependency ordering and automatic restarts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  app:
    image: myapp:1.2.3
    depends_on:
      db:
        condition: service_healthy   # wait until db is actually ready

Without condition: service_healthy, depends_on only waits for the container to start β€” not for the service inside to be ready. This is the cause of a huge number of “my app can’t connect to the database on startup” issues.

Common health check patterns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# HTTP service
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
  interval: 30s
  timeout: 10s
  retries: 3

# Redis
healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 10s
  timeout: 3s
  retries: 3

# Generic TCP port check
healthcheck:
  test: ["CMD-SHELL", "nc -z localhost 9200 || exit 1"]
  interval: 15s
  timeout: 5s
  retries: 4

7. Use Named Volumes for Persistent Data

Bind mounts (./data:/var/lib/data) work, but named Docker volumes are cleaner for databases and application state:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
volumes:
  postgres_data:
  redis_data:

services:
  db:
    image: postgres:16
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

Named volumes vs bind mounts β€” when to use which:

Use caseRecommendation
Database dataNamed volume
App config files (you edit them)Bind mount
Media files (photos, videos)Bind mount to a specific path
Log filesNamed volume or bind mount
TLS certificatesBind mount (you manage them)

Inspect and back up named volumes:

1
2
3
4
5
6
7
8
# List volumes
docker volume ls

# Back up a named volume
docker run --rm \
  -v postgres_data:/source:ro \
  -v $(pwd):/backup \
  alpine tar czf /backup/postgres_data_$(date +%Y%m%d).tar.gz -C /source .

8. Configure Proper Logging

By default, Docker uses the json-file driver with no size limit. Left unchecked, container logs will fill your disk.

Set a global default in /etc/docker/daemon.json:

1
2
3
4
5
6
7
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Restart Docker: sudo systemctl restart docker

Or set it per service in your compose file:

1
2
3
4
5
6
7
8
services:
  app:
    image: myapp:1.2.3
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

If you’re running a centralized log stack (Loki, Graylog), you can switch the driver:

1
2
3
4
5
logging:
  driver: loki
  options:
    loki-url: "http://loki:3100/loki/api/v1/push"
    loki-batch-size: "400"

9. A Complete Example: Nextcloud Stack Done Right

Here’s what all these best practices look like combined in a real-world Nextcloud setup:

  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
110
111
112
113
114
115
116
117
118
119
# /opt/stacks/nextcloud/docker-compose.yml

networks:
  nextcloud_internal:
    name: nextcloud_internal
  traefik_net:
    external: true
    name: traefik_net

volumes:
  nextcloud_data:
  postgres_data:
  redis_data:

secrets:
  db_password:
    file: ./secrets/db_password.txt
  nextcloud_admin_password:
    file: ./secrets/nextcloud_admin_password.txt

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    networks:
      - nextcloud_internal
    volumes:
      - postgres_data:/var/lib/postgresql/data
    secrets:
      - db_password
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s
    deploy:
      resources:
        limits:
          memory: 512m
    logging:
      driver: json-file
      options:
        max-size: "5m"
        max-file: "2"

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    networks:
      - nextcloud_internal
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 128m
    logging:
      driver: json-file
      options:
        max-size: "2m"
        max-file: "2"

  nextcloud:
    image: nextcloud:30.0.4-apache
    restart: unless-stopped
    networks:
      - nextcloud_internal
      - traefik_net
    volumes:
      - nextcloud_data:/var/www/html
    secrets:
      - db_password
      - nextcloud_admin_password
    environment:
      POSTGRES_HOST: db
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
      NEXTCLOUD_ADMIN_PASSWORD_FILE: /run/secrets/nextcloud_admin_password
      NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_DOMAIN}
      REDIS_HOST: redis
      PHP_MEMORY_LIMIT: 512M
      PHP_UPLOAD_LIMIT: 10G
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/status.php"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.nextcloud.rule=Host(`${NEXTCLOUD_DOMAIN}`)"
      - "traefik.http.routers.nextcloud.entrypoints=websecure"
      - "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt"
    deploy:
      resources:
        limits:
          memory: 1g
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

And the corresponding .env:

1
2
3
4
5
# /opt/stacks/nextcloud/.env
POSTGRES_DB=nextcloud
POSTGRES_USER=nextcloud
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_DOMAIN=nextcloud.yourdomain.com

Secrets files in ./secrets/ with chmod 600, and .env in .gitignore. Clean, reproducible, maintainable.


10. Quick Reference: Restart Policies

PolicyBehaviorWhen to use
noNever restartDev/testing only
alwaysAlways restart (even on manual stop)System services
unless-stoppedRestart unless manually stoppedMost services
on-failureOnly restart on non-zero exitBatch jobs, init containers

Use unless-stopped as your default. It survives server reboots but won’t fight you when you run docker compose down intentionally.


Wrapping Up

The difference between a fragile homelab and a rock-solid one usually comes down to discipline in the compose files: no hardcoded secrets, explicit networks, pinned tags, health checks, and resource limits.

None of these practices are difficult β€” they’re just habits. The next time you spin up a new service, run through this checklist:

  • Secrets in .env or Docker secrets, not in YAML
  • Image tag pinned
  • Explicit networks defined
  • Health checks configured
  • depends_on with condition: service_healthy where needed
  • Resource limits set
  • Log rotation configured
  • restart: unless-stopped

Your future self β€” at 2 AM, staring at a broken stack β€” will thank you.


Want to go deeper? Our upcoming guides on Traefik reverse proxy setup and automated Docker updates with Watchtower will cover these topics in detail β€” stay tuned.