Docker

Docker Compose Best Practices: 12 Rules I Follow After Running 40+ Containers

12 battle-tested Docker Compose best practices after running 40+ containers. Real configs, resource limits, health checks, and security hardening.

AU

Author

Marcus Chen

Disclosure: This article may contain affiliate links. If you purchase through these links, we may earn a commission at no additional cost to you. We only recommend products we have personally tested or thoroughly researched.

Key Takeaways

  • Use named volumes over bind mounts for anything that stores data you care about
  • Always set resource limits - one rogue container can freeze your entire homelab
  • Health checks are not optional - they're the difference between "running" and "actually working"
  • Use .env files for all configuration that changes between environments
  • Network segmentation prevents a compromised container from reaching everything
  • Read-only containers and no-new-privileges should be your default, not your exception

After running 40+ Docker containers across three Proxmox hosts for the better part of four years, I've developed a set of rules for Docker Compose files. These aren't theoretical best practices I read in a blog post - they're patterns I arrived at after breaking things spectacularly at 2 AM and swearing "never again."

Here's the thing nobody tells you about Docker Compose: the syntax is trivial. Any junior admin can write a working compose file in five minutes. Writing one that survives a power outage, a runaway process, and a disk full notification at 3 AM without losing data - that takes a bit more thought.

This guide covers the 12 rules I follow for every compose file in my homelab. If you're already comfortable with Docker basics (if not, start with my Docker for Homelabs guide), these practices will save you from the mistakes I've already made.


1. Use Named Volumes, Not Bind Mounts (For Data You Care About)

This is the hill I will die on. Bind mounts (mapping a host path like /home/user/data:/data) are fine for development. For anything running in production on your homelab, use named volumes.

Why? Bind mounts have no lifecycle management. You can't back them up through Docker. You can't move them between hosts easily. And when you accidentally rm -rf the parent directory at midnight (don't ask), your data is gone.

Named volumes are managed by Docker. You can inspect them, back them up with docker run --rm -v myvolume:/data -v $(pwd):/backup alpine tar czf /backup/myvolume.tar.gz /data, and they persist even if you remove the container.

# Good - named volume

services:

postgres:

image: postgres:16

volumes:

  • postgres_data:/var/lib/postgresql/data

volumes:

postgres_data:

# Fine for dev, risky for production - bind mount

services:

postgres:

image: postgres:16

volumes:

  • ./data/postgres:/var/lib/postgresql/data

One exception: configuration files. I use bind mounts for config files that I edit frequently on the host. For example, Nginx configs or Prometheus rules. These are version-controlled anyway, so losing them isn't catastrophic.


2. Always Set Resource Limits

Every container in my homelab has memory and CPU limits. No exceptions. Here's why: I once had a Jellyfin transcode job eat all 32GB of RAM on a host, freezing every other container including my DNS server. The entire network went dark because one container was greedy.

Resource limits prevent this. A container hit its memory limit gets OOM-killed - painful for that one service, but the rest of your infrastructure stays alive.

services:

jellyfin:

image: jellyfin/jellyfin:latest

deploy:

resources:

limits:

memory: 4G

cpus: '2.0'

reservations:

memory: 1G

cpus: '0.5'

My general rule of thumb for a homelab:

  • Web apps (Nextcloud, Gitea, etc.): 512MB-1GB limit
  • Databases (PostgreSQL, MariaDB): 1-2GB limit
  • Media servers (Jellyfin, Plex): 2-4GB limit
  • Monitoring (Prometheus, Grafana): 1-2GB limit
  • Lightweight utilities (Uptime Kuma, Pi-hole): 256MB limit

The reservations field is equally important - it tells Docker to guarantee that minimum amount of resources for the container. Without it, a busy container might get starved by its neighbors.


3. Health Checks Are Not Optional

I used to skip health checks. "The container is running, so it's fine." Wrong. A container can be running and completely broken - the process is alive but the application is hung, the database connection pool is exhausted, or the disk is full.

A health check tells Docker (and your monitoring stack) whether the application inside the container is actually working, not just whether the process exists.

services:

postgres:

image: postgres:16

healthcheck:

test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]

interval: 30s

timeout: 10s

retries: 3

start_period: 30s

nextcloud:

image: nextcloud:latest

healthcheck:

test: ["CMD", "curl", "-f", "http://localhost:80/status.php"]

interval: 30s

timeout: 10s

retries: 3

start_period: 60s

The start_period is critical. It tells Docker to not count failures during startup against the retry counter. Without it, a slow-starting container gets killed before it has a chance to initialize.

Pair health checks with depends_on conditions to control startup order:

services:

app:

image: myapp:latest

depends_on:

postgres:

condition: service_healthy

postgres:

image: postgres:16

healthcheck:

test: ["CMD-SHELL", "pg_isready -U postgres"]

interval: 10s

timeout: 5s

retries: 5

This way, app won't start until PostgreSQL is actually ready - not just "started."


4. Use .env Files for Configuration

Hardcoding values in your compose file is a recipe for trouble. When you want to move from your test server to production, or share the file with someone, you're stuck doing find-and-replace.

Instead, use environment variables with a .env file:

# docker-compose.yml

services:

postgres:

image: postgres:16

environment:

POSTGRES_USER: ${DB_USER}

POSTGRES_PASSWORD: ${DB_PASSWORD}

POSTGRES_DB: ${DB_NAME}

volumes:

  • ${DB_VOLUME:-postgres_data}:/var/lib/postgresql/data
# .env

DB_USER=homelab

DB_PASSWORD=s3cur3-p@ssw0rd

DB_NAME=nextcloud

DB_VOLUME=nextcloud_db

A few rules for .env files:

  • Never commit .env to git. Add it to .gitignore. Use .env.example with placeholder values instead.
  • Use defaults for non-sensitive values: ${DB_VOLUME:-postgres_data} uses postgres_data if the variable isn't set.
  • One .env per environment. I have .env.production, .env.staging, and .env.dev for services that run in multiple environments.

5. Network Segmentation: Don't Put Everything on One Network

By default, Docker Compose creates a single network for all services in a compose file. Every container can talk to every other container. This is fine for a simple stack, but dangerous for anything exposed to the internet.

I create separate networks for different trust levels:

services:

nginx:

image: nginx:alpine

networks:

  • frontend
  • backend

app:

image: myapp:latest

networks:

  • backend

postgres:

image: postgres:16

networks:

  • backend

networks:

frontend:

driver: bridge

backend:

driver: bridge

internal: true # No external access

The internal: true flag on the backend network means containers on that network can't reach the internet. Only the nginx proxy (which sits on both networks) can talk to the backend services. If someone compromises your web app, they can't use it to exfiltrate data to an external server.

For a homelab, I use three network tiers:

  • frontend: Services exposed through a reverse proxy (nginx, Traefik, Caddy)
  • backend: Internal services and databases
  • monitoring: Prometheus, Grafana, alertmanager

6. Set Restart Policies

Your homelab server reboots for kernel updates. Your containers should come back automatically.

services:

nextcloud:

image: nextcloud:latest

restart: unless-stopped

The four options:

  • no: Never restart (default - and the worst default ever)
  • always: Restart always, including on manual stop
  • on-failure: Only restart on non-zero exit code
  • unless-stopped: Restart unless manually stopped

I use unless-stopped for everything. It handles reboots gracefully but lets me intentionally stop a container for maintenance without Docker stubbornly restarting it.

One gotcha: restart: always combined with a container that crashes on startup creates an infinite restart loop that fills your logs and burns CPU. Always test your compose file before setting restart policies.


7. Use Profiles for Optional Services

Docker Compose profiles let you selectively start services. This is perfect for optional services you don't always need.

services:

app:

image: myapp:latest

restart: unless-stopped

postgres:

image: postgres:16

restart: unless-stopped

debug-tools:

image: busybox

profiles:

  • debug

command: sleep infinity

adminer:

image: adminer

profiles:

  • admin

ports:

  • "8080:8080"
# Start only core services

docker compose up -d

# Start with debug tools

docker compose --profile debug up -d

# Start with admin panel

docker compose --profile admin up -d

I use this for development tools (adminer, pgadmin, portainer) that I only need when troubleshooting. They don't run by default, but I can spin them up instantly when needed.


8. Logging: Don't Let Logs Eat Your Disk

Docker's default logging driver writes JSON to /var/lib/docker/containers/. If a container produces verbose logs (and they all do eventually), this directory will grow until your disk is full. And when your disk is full, Docker stops. Everything stops.

Set log rotation globally in /etc/docker/daemon.json:

{

"log-driver": "json-file",

"log-opts": {

"max-size": "10m",

"max-file": "3"

}

}

Or per-service in your compose file:

services:

app:

image: myapp:latest

logging:

driver: json-file

options:

max-size: "10m"

max-file: "3"

This keeps at most 30MB of logs per container (3 files x 10MB each). Older logs are automatically rotated out.

For my monitoring stack, I use a different approach - I ship logs to Loki with the Loki Docker driver plugin:

services:

app:

image: myapp:latest

logging:

driver: loki

options:

loki-url: "http://loki:3100/loki/api/v1/push"

This gives me centralized log search through Grafana without filling up local disk.


9. Pin Your Image Versions

image: postgres:latest is a ticking time bomb. One day, latest is PostgreSQL 15. The next time you pull, it's PostgreSQL 16 with breaking changes and your database won't start.

Always pin to a specific version:

services:

postgres:

image: postgres:16.4-alpine

My versioning strategy:

  • Production databases: Pin to minor version (postgres:16.4-alpine)
  • Web applications: Pin to major version (nextcloud:29)
  • Utilities: Pin to major version (nginx:1.27-alpine)
  • Development only: latest is fine

The -alpine suffix is worth using when available. Alpine-based images are typically 5-10x smaller than their Debian equivalents, which means faster pulls, less disk usage, and a smaller attack surface.


10. Security: Read-Only Containers and No New Privileges

Most containers don't need to write to their own filesystem. Making them read-only prevents an attacker who compromises the container from modifying binaries, installing tools, or writing malware.

services:

nginx:

image: nginx:alpine

read_only: true

tmpfs:

  • /var/cache/nginx
  • /var/run
  • /tmp

security_opt:

  • no-new-privileges:true

The tmpfs mounts give the container writable directories where it actually needs them (temp files, caches, PID files) without making the entire filesystem writable.

no-new-privileges:true prevents a process inside the container from gaining more privileges than its parent. This blocks privilege escalation attacks.

Not every container works with read_only: true. Some applications write to unexpected locations. Start with it enabled, check the logs for permission errors, and add specific tmpfs mounts as needed.

For containers that need to run as root (like some database images), drop capabilities you don't need:

services:

postgres:

image: postgres:16

cap_drop:

  • ALL

cap_add:

  • CHOWN
  • SETUID
  • SETGID
  • FOWNER

This follows the principle of least privilege - the container only gets the specific Linux capabilities it needs, not all of them.


11. Organize Your Compose Files by Stack

I used to have one massive docker-compose.yml with 30 services. It was a nightmare to maintain. Now I organize by logical stack:

homelab/

├── media/

│ ├── docker-compose.yml # Jellyfin, Radarr, Sonarr, Prowlarr

│ └── .env

├── monitoring/

│ ├── docker-compose.yml # Prometheus, Grafana, Alertmanager, Loki

│ └── .env

├── nextcloud/

│ ├── docker-compose.yml # Nextcloud, PostgreSQL, Redis

│ └── .env

├── networking/

│ ├── docker-compose.yml # Pi-hole, Unbound, WireGuard

│ └── .env

└── reverse-proxy/

├── docker-compose.yml # Nginx Proxy Manager or Caddy

└── .env

Each stack has its own compose file, its own .env, and its own network. I can update, restart, or troubleshoot one stack without affecting the others.

If containers across stacks need to communicate (like a monitoring agent in the media stack talking to Prometheus), use external networks:

# monitoring/docker-compose.yml

networks:

monitoring:

driver: bridge

# media/docker-compose.yml

networks:

monitoring:

external: true


12. Use Multi-Stage Builds for Custom Images

When you need to build your own images (and you will eventually), multi-stage builds keep them small and secure.

# Build stage

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./

RUN npm ci --only=production

COPY . .

RUN npm run build

# Production stage

FROM node:20-alpine

WORKDIR /app

COPY --from=builder /app/dist ./dist

COPY --from=builder /app/node_modules ./node_modules

USER node

CMD ["node", "dist/index.js"]

The build stage has all the development dependencies (compilers, build tools, dev packages). The final image only contains the compiled application and production dependencies. This can reduce image size by 70-90%.

In your compose file, reference the build context:

services:

myapp:

build:

context: ./myapp

dockerfile: Dockerfile

target: production # Use a specific build stage

image: myapp:latest


Recommended Hardware for Your Docker Homelab

Running 40+ containers doesn't require enterprise hardware. But it does require reliable hardware. Here's what I recommend:

As an Amazon Associate, we earn from qualifying purchases.

alt="Crucial 32GB DDR5 RAM Kit"

style="width: 120px; height: 120px; object-fit: contain; border-radius: 8px; background: white; padding: 8px;"

loading="lazy" />

As an Amazon Associate, we earn from qualifying purchases.

alt="APC Back-UPS Pro BX1500M"

style="width: 120px; height: 120px; object-fit: contain; border-radius: 8px; background: white; padding: 8px;"

loading="lazy" />

As an Amazon Associate, we earn from qualifying purchases.


Complete Example: A Production-Ready Compose File

Here's a real-world example that combines all 12 practices. This is a simplified version of my Nextcloud stack:

# nextcloud/docker-compose.yml

services:

nextcloud:

image: nextcloud:29-fpm-alpine

restart: unless-stopped

read_only: true

tmpfs:

  • /tmp
  • /var/www/html/config
  • /var/www/html/data

security_opt:

  • no-new-privileges:true

cap_drop:

  • ALL

cap_add:

  • CHOWN
  • SETUID
  • SETGID
  • FOWNER

depends_on:

postgres:

condition: service_healthy

redis:

condition: service_healthy

environment:

  • POSTGRES_HOST=postgres
  • POSTGRES_DB=${DB_NAME}
  • POSTGRES_USER=${DB_USER}
  • POSTGRES_PASSWORD=${DB_PASSWORD}
  • REDIS_HOST=redis

volumes:

  • nextcloud_data:/var/www/html
  • ./php-uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro

networks:

  • frontend
  • backend

deploy:

resources:

limits:

memory: 2G

cpus: '1.0'

healthcheck:

test: ["CMD", "curl", "-f", "http://localhost:9000/status.php"]

interval: 30s

timeout: 10s

retries: 3

start_period: 60s

logging:

driver: json-file

options:

max-size: "10m"

max-file: "3"

nginx:

image: nginx:1.27-alpine

restart: unless-stopped

read_only: true

tmpfs:

  • /var/cache/nginx
  • /var/run
  • /tmp

security_opt:

  • no-new-privileges:true

depends_on:

nextcloud:

condition: service_healthy

volumes:

  • ./nginx.conf:/etc/nginx/nginx.conf:ro
  • nextcloud_data:/var/www/html:ro

ports:

  • "8080:80"

networks:

  • frontend

deploy:

resources:

limits:

memory: 256M

cpus: '0.5'

postgres:

image: postgres:16.4-alpine

restart: unless-stopped

security_opt:

  • no-new-privileges:true

cap_drop:

  • ALL

cap_add:

  • CHOWN
  • SETUID
  • SETGID
  • FOWNER

environment:

  • POSTGRES_DB=${DB_NAME}
  • POSTGRES_USER=${DB_USER}
  • POSTGRES_PASSWORD=${DB_PASSWORD}

volumes:

  • postgres_data:/var/lib/postgresql/data

networks:

  • backend

deploy:

resources:

limits:

memory: 1G

cpus: '0.5'

healthcheck:

test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]

interval: 10s

timeout: 5s

retries: 5

start_period: 30s

redis:

image: redis:7-alpine

restart: unless-stopped

read_only: true

security_opt:

  • no-new-privileges:true

volumes:

  • redis_data:/data

networks:

  • backend

deploy:

resources:

limits:

memory: 256M

cpus: '0.25'

healthcheck:

test: ["CMD", "redis-cli", "ping"]

interval: 10s

timeout: 5s

retries: 3

volumes:

nextcloud_data:

postgres_data:

redis_data:

networks:

frontend:

driver: bridge

backend:

driver: bridge

internal: true

Every rule from this guide is applied here. Named volumes, resource limits, health checks, read-only containers, security options, restart policies, logging limits, network segmentation, and pinned image versions.


Common Mistakes I See (And Have Made)

Running everything as root. Many Docker images default to root. Check if the image supports a non-root user (most do) and set USER in your Dockerfile or user: in your compose file. Not backing up volumes. Named volumes are great, but they're not backups. Set up automated volume backups - I wrote about my backup strategy if you need a starting point. Ignoring disk space. Docker images, volumes, and logs accumulate fast. Run docker system prune -a --volumes periodically (but understand what it deletes first). Monitor disk usage with something like Uptime Kuma. Using docker-compose (hyphenated). Docker Compose V1 (docker-compose) is deprecated. Use docker compose (space, no hyphen) with Docker Compose V2. It's faster, has better error messages, and is actively maintained. One compose file to rule them all. Split by logical stack. You'll thank yourself when you need to restart just the monitoring stack without taking down your media server. Not using Docker Compose watch mode. Compose V2 introduced a watch mode that automatically rebuilds and restarts containers when source files change. It's fantastic for development:
docker compose watch

Configure it in your compose file:

services:

app:

build: .

develop:

watch:

  • action: rebuild

path: ./package.json

  • action: sync

target: /app/src

path: ./src

This syncs source code changes into the container without a full rebuild, and triggers a rebuild when dependencies change. It's the hot-reload experience you get with Docker Desktop, available in any environment.

Forgetting to clean up dangling images. Every docker compose build creates new images. The old ones become "dangling" - invisible but still consuming disk space. Set up a cron job to prune them:
# Add to crontab - runs every Sunday at 3 AM

0 3 * * 0 docker image prune -f >> /var/log/docker-prune.log 2>&1

I've seen homelab servers lose 50-100GB to forgotten build artifacts. A weekly prune keeps things tidy.


Bonus: My Pre-Flight Checklist

Before I deploy any new compose stack, I run through this checklist:

  1. All images are version-pinned - no latest tags in production
  2. Resource limits set on every service - memory at minimum, CPU if needed
  3. Health checks defined for all services that support them
  4. Restart policy set to unless-stopped
  5. Volumes are named - no bind mounts for persistent data
  6. Networks are segmented - frontend/backend separation at minimum
  7. Security options applied - read_only, no-new-privileges, capability drops
  8. Logging configured - max-size and max-file set
  9. .env file created - no hardcoded secrets in the compose file
  10. Compose file in version control - .env excluded via .gitignore

This takes about five minutes per stack. It's saved me hours of debugging and at least one catastrophic data loss.


FAQ

Should I use Docker Compose in production?

Yes, with caveats. Docker Compose is perfectly fine for a homelab or a single-server deployment. If you're running multiple servers and need automatic failover, look at Docker Swarm or Kubernetes. But for most homelabs, Compose is the right tool - simple, declarative, and easy to back up.

How do I update containers without losing data?

Use docker compose pull to fetch new images, then docker compose up -d. Docker Compose only recreates containers whose images have changed. Named volumes persist across container recreations, so your data is safe. Always back up before major version upgrades.

What's the difference between depends_on and health checks?

depends_on without a condition only controls startup order - it doesn't wait for the service to be ready. depends_on with condition: service_healthy waits for the health check to pass. Always use the health check condition for services that need time to initialize (databases, APIs).

How do I share compose files between servers?

Keep your compose files in a Git repository. Use .env files for server-specific configuration (passwords, ports, paths). The .env file stays on each server and is excluded from Git via .gitignore.

How many containers can one server handle?

It depends on the workload, not the container count. I've run 40+ containers on a single Beelink SER5 with 32GB RAM. The bottleneck is usually RAM, then disk I/O for databases. Monitor resource usage and set limits accordingly.


What to Do Next

If you're setting up Docker Compose for the first time, start with three things: named volumes, restart policies, and resource limits. Get those right, and you've already avoided 80% of the problems I see in homelab forums.

Once you're comfortable, add health checks and network segmentation. These are the practices that separate a "working" setup from a "reliable" setup.

For more Docker content, check out my guides on Docker networking and choosing between VMs and containers. If you're planning your homelab network, the DNS guide covers the foundation every Docker host needs.