Docker Compose Best Practices for Production-Ready Self-Hosting
A practical guide to hardening your Docker Compose stacks for production, covering healthchecks, secrets, resource limits, override files, and more.
A practical guide to hardening your Docker Compose stacks for production, covering healthchecks, secrets, resource limits, override files, and more.
Docker Compose is one of the most widely used tools in the self-hosting and homelab world. It allows you to define multi-container applications with a single YAML file and spin them up with one command. But while Compose is excellent for development and testing, running it in production requires additional hardening. Without proper configuration, your containers may silently fail, leak sensitive data, consume all host memory, or restart in chaotic loops.
This article covers proven Docker Compose best practices drawn from official Docker documentation, community guides, and real-world homelab deployments. Whether you are running a single Raspberry Pi server or a multi-node homelab, these patterns will help you build reliable, secure, and maintainable stacks.
Before diving into production hardening, make sure you have:
docker compose up, docker compose down, and editing YAML filesThe official Docker documentation recommends defining an additional Compose file for production-specific configuration. Instead of modifying your base compose.yaml, create a compose.production.yaml (or docker-compose.prod.yml) that contains only the overrides needed for your production environment.
# docker-compose.prod.yml
services:
web:
restart: always
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 256MYou then deploy by passing both files to Docker Compose:
docker compose -f compose.yaml -f compose.production.yaml up -dThis approach keeps your base file clean and portable, while the production override adds restart policies, resource limits, and environment-specific settings.
Never rely on manually restarting containers. Use the restart directive to define how your services recover from crashes or host reboots. For most production services, unless-stopped is the recommended choice:
services:
app:
image: myapp:latest
restart: unless-stoppedalways — Restarts regardless of exit code. Use cautiously as it can mask fatal errors.unless-stopped — Restarts unless the container was explicitly stopped by the user. This is the preferred option for most self-hosted services.on-failure:5 — Restarts only on non-zero exit codes, with a maximum retry count.Healthchecks allow Docker Compose to know whether a service is actually ready to serve traffic — not just whether the process is running. This is critical for services that depend on a database or another upstream service.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30sFor web applications, a health check might curl a dedicated endpoint:
services:
app:
image: myapp:latest
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40sCombine healthchecks with depends_on to enforce startup ordering:
services:
app:
depends_on:
db:
condition: service_healthyThis ensures your application container does not start until the database is actually ready to accept connections.
latest in ProductionUsing image: myapp:latest in a production Compose file is a common anti-pattern. The latest tag is ambiguous: it may point to different images at different points in time, breaking your deployment unexpectedly.
Always pin to a specific version tag:
services:
app:
image: myapp:2.1.0If you need flexibility between environments, use environment variables with sensible defaults:
services:
app:
image: myapp:${APP_VERSION:-latest}Then set APP_VERSION=2.1.0 in your production environment while keeping latest for development.
Without resource limits, a single container can consume all available CPU and memory on your host, starving other containers or crashing the server. In Compose file format v3, use the deploy.resources key:
services:
api:
image: myapi:1.0.0
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
reservations:
cpus: "0.5"
memory: 512MNote that when running docker compose (not Docker Swarm), the deploy section is only advisory. On single-host deployments, you may need to fall back to the Compose v2 mem_limit and cpus syntax if your version supports it, or run Compose in Swarm mode to enforce limits. Check the official documentation for exact syntax depending on your Docker Engine version.
Environment variables are convenient, but they are visible to anyone who can run docker inspect on the container. For sensitive data like database passwords, API keys, and TLS certificates, use Docker Secrets.
First, create a secret file locally:
echo "MyS3cur3P@ssword" > ./secrets/db_password.txtThen define and reference the secret in your Compose file:
services:
db:
image: postgres:16-alpine
secrets:
- db_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txtDocker mounts the secret as a file at /run/secrets/<secret_name> inside the container. This is more secure than environment variables because secrets are not visible via docker inspect and are stored in the Docker internal data store rather than plain text.
Important: Never commit your .env or secret files to version control. Add them to .gitignore:
.env
secrets/Bind mounts directly map a host directory into the container. They are convenient for development but can cause permission issues and are harder to back up in a structured way. Named volumes are managed by Docker and are the production-recommended approach.
services:
db:
image: postgres:16-alpine
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:Named volumes are automatically created, backed up via Docker volume commands, and isolated from the host filesystem.
If you must use a bind mount for configuration files, make the mount read-only to prevent the container from modifying it:
volumes:
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./acme.json:/acme.jsonBy default, Docker Compose creates a single network for all services in a stack. For production, define multiple named networks and isolate services based on their communication needs.
services:
traefik:
image: traefik:v3.3
networks:
- proxy
app:
image: myapp:latest
networks:
- proxy
- backend
db:
image: postgres:16-alpine
networks:
- backend
networks:
proxy:
name: proxy_network
backend:
internal: trueIn this pattern:
proxy network is shared with Traefik (or your reverse proxy) for incoming traffic.backend network is marked internal: true, meaning it has no external access — your database is never exposed to the internet.Docker Compose automatically loads compose.override.yml if it exists when you run docker compose up. This is ideal for development, but in production you should explicitly specify your files to avoid accidental overrides.
For a clean multi-environment setup, structure your files like this:
compose.yaml # Base configuration for all environments
compose.override.yml # Development overrides (auto-loaded)
compose.production.yml # Production overrides (explicitly passed)Deploy to production with:
docker compose -f compose.yaml -f compose.production.yml up -dThis pattern lets you keep development conveniences (like bind mounts for hot reload) out of your production deployment.
Mounting the Docker socket (/var/run/docker.sock) into a container gives that container root-level access to the Docker daemon. Many self-hosted tools like Traefik, Portainer, and Watchtower require this to discover or manage other containers.
If you must mount the socket, always mount it read-only:
services:
traefik:
image: traefik:v3.3
volumes:
- /var/run/docker.sock:/var/run/docker.sock:roThe :ro suffix prevents the container from writing to the socket, mitigating (but not eliminating) the security risk.
Ensure the command used in the healthcheck test exists inside the container. For example, wget may not be installed in minimal Alpine-based images. Use CMD-SHELL with simple shell commands instead.
On a single-host Docker Compose deployment (not Swarm), deploy.resources.limits may not be enforced. Verify your Docker Engine version and consider running docker compose in Swarm mode if you need strict enforcement.
Check the logs with docker compose logs <service-name>. The issue is often a missing dependency, a database that is not ready, or a misconfigured environment variable.
Running Docker Compose in production is absolutely achievable for self-hosters and homelab enthusiasts. The key is to adopt the same discipline that larger infrastructure teams use: pin versions, set healthchecks, isolate networks, manage secrets properly, and use override files to keep environments clean.
Start by applying the patterns in this guide one at a time. Add healthchecks to your most critical services first, then introduce resource limits, then harden your secrets management. Before long, your homelab will be running with production-grade reliability — without needing Kubernetes.
Remember Docker's own guidance: define your app with Compose in development, then use production-specific override files for deployment. Your future self (and anyone else managing your homelab) will thank you.