Docker Compose Production Best Practices
Compose is not just for local development. For small and midsize projects, it can be ten times simpler than K8s in production. This guide covers environment layering, health checks, dependency ordering, restart policies, log drivers, resource limits, security, and when to choose Swarm or standalone Compose.
Key Points
- Topic Focus: DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Many people still think of Docker Compose as a tool for starting a group of services locally for convenient integration testing, then immediately reach for K8s in production. I have worked on many small and midsize projects (one to three machines, QPS below 1,000, and teams of 3-10 people), and my conclusion is clear: in 90% of cases, running Compose in production is ten times simpler than K8s, and more reliable. K8s was designed for hundreds of microservices, multiple teams, and frequent rolling deployments. If your stack is simply a monolith, Postgres, Redis, and Nginx, moving it to K8s can consume a week just dealing with Helm charts, Ingress, PVCs, and CRDs. Compose can be deployed in half a day, and troubleshooting may require nothing more than docker logs.
The difference between something that runs and something production-ready comes down to a handful of steps. This guide covers the key practices for running Compose in production.
1. Separate environments with base and override files
The first production pitfall is mixing development and production configuration in one docker-compose.yml, where changing a value for one environment affects the other. The correct approach is to describe shared configuration in a base file and differences in override files, then layer them for each environment.
.
├── docker-compose.yml # base: service definitions, networks, and volumes
├── docker-compose.override.yml # default development config (loaded automatically by Compose)
└── docker-compose.prod.yml # production config (specified explicitly with -f)
Keep only the shared structure in docker-compose.yml:
services:
app:
build: .
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
db-data:
The development override is loaded automatically and mounts the source code for hot reload:
# docker-compose.override.yml
services:
app:
volumes:
- ./src:/app/src
command: pnpm dev
environment:
NODE_ENV: development
For production, layer the production file explicitly with -f:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# docker-compose.prod.yml
services:
app:
restart: unless-stopped
environment:
NODE_ENV: production
db:
restart: unless-stopped
Inject environment variables with env_file; do not hard-code them in YAML. Add .env to .gitignore and let each team member keep a local copy. In CI, inject values from secrets. This lets the same Compose file switch seamlessly between staging and production by changing only .env.
2. Health checks and dependency ordering: wait for the database before starting the app
By default, Compose depends_on guarantees only that a container has started, not that its service is ready. A typical failure occurs when the app starts faster than Postgres, cannot connect to the database, crashes, and enters a restart loop.
The solution is to add a health check to each dependency and declare condition: service_healthy on the dependent service:
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
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
app:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
Several details matter:
start_periodgives the service a warm-up window. Failures during this period do not count towardretries, which is especially important for slow-starting JVM services and databases.- Make
testrun a command that genuinely reflects application readiness rather than merely usingcurl localhost. Usepg_isreadyfor Postgres,pingfor Redis, andmysqladmin pingfor MySQL. - Note the
$$escaping: Compose treats$VARas variable interpolation at the Compose layer. To let the shell expand it inside the container, write$$VAR.
Once this is configured, deployment race conditions largely disappear.
3. Restart policies: unless-stopped is the default choice
Automatically recovering when a container exits is the minimum requirement for production. Of the four options, I recommend:
unless-stopped: Use this by default. A container restarts automatically after an unexpected exit, but if you manuallystopit, a daemon restart will not bring it back. This matches the expectation that something deliberately stopped should remain stopped.always: Always starts the container again after a daemon restart, including containers you manually stopped. It can make sense for persistent infrastructure such as databases and proxies, but the behavior can be confusing.on-failure: Restarts only after a nonzero exit code. This is suitable for batch jobs.- No policy: The container stays down, and the alert arrives right on schedule in the middle of the night.
services:
app:
restart: unless-stopped
Note that restart cannot fix a process that is still running but stuck. That is the health check's job. Both mechanisms are needed for a complete setup: a health check marks the container as unhealthy, but Docker does not restart it by default. To achieve actual self-healing, handle this in Swarm mode or with an external supervisor. With single-host Compose, you can add an autoheal sidecar that listens for unhealthy events and runs docker restart.
4. Logging: limit json-file or the disk will eventually fill up
Docker's default logging driver is json-file, with no size limit. After a service has run for several months, its log file can easily grow to tens of gigabytes. Eventually the machine runs out of inodes or disk space and everything fails. This is a painful production failure mode, so always configure limits:
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
This retains at most three 10 MB log files per container and rotates them. For a single-host project, this configuration is usually enough.
At a larger scale, consider an external logging driver:
fluentd/fluentbit: Send logs directly to an aggregation service such as Loki or Elasticsearch.journald: Route logs through systemd-journald and query them withjournalctl.gelf: Send logs to Graylog.
services:
app:
logging:
driver: fluentd
options:
fluentd-address: localhost:24224
tag: app-service
Choose according to your logging stack. For most small and midsize projects, json-file with size limits is the simplest option.
5. Resource limits: do not let one service exhaust the entire host
Containers share the host kernel. A runaway service, such as a Node process with a memory leak or a Python process loading a large model, can consume all host memory and trigger the Linux OOM killer to terminate processes unpredictably. Set limits through deploy.resources in Compose:
services:
app:
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
reservations:
memory: 256M
db:
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
There is a counterintuitive detail here: the documentation describes the deploy field as a Swarm feature, but docker compose up in non-Swarm mode has also honored limits and reservations since certain versions. If your version does not support them, fall back to the legacy fields:
services:
app:
mem_limit: 1g
cpus: 1.0
limits are hard ceilings; exceeding them causes an OOM kill or CPU throttling. reservations are soft guarantees that reserve a minimum amount. In production, I recommend setting limits and using reservations cautiously. Reservations cause the scheduler to reserve resources; on a single host, they provide little value and can unnecessarily reduce density.
6. Security: non-root, read-only, and least privilege
Most container escape incidents originate from running as root and mounting things that should never have been mounted. A production Compose configuration should apply three safeguards.
First, use a non-root user. Create a dedicated user in the Dockerfile:
RUN addgroup -S app && adduser -S app -G app
USER app
Or override the user in Compose:
services:
app:
user: "1000:1000"
Second, use a read-only filesystem. Application code should not change at runtime, so lock it down:
services:
app:
read_only: true
tmpfs:
- /tmp
- /app/.next/cache
Use tmpfs to provide in-memory storage for directories that must remain writable at runtime, such as temporary files and caches. This preserves the read-only filesystem while keeping the application functional.
Third, reduce capabilities. Docker grants several capabilities by default, and most applications do not need them:
services:
app:
cap_drop:
- ALL
cap_add:
- CHOWN # Add only what is genuinely required
security_opt:
- no-new-privileges:true
no-new-privileges prevents child processes from escalating privileges through setuid and provides security benefits at almost no cost.
Combined, a production-ready service definition looks like this:
services:
app:
image: myapp:1.4.2 # Use a fixed tag in production; never use latest
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: unless-stopped
env_file: .env.prod
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 30s
timeout: 5s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
depends_on:
db:
condition: service_healthy
7. Single-host Compose vs. Swarm vs. K8s: choose only the complexity you need
Now that the configuration is covered, consider the orchestration choice. There are three levels; move up only when necessary.
Single-host Compose: Run a group of services on one machine. This suits projects with tens or hundreds of thousands of daily active users when a single server can handle the load. The operational overhead is extremely low: restart after a failure, clear logs when the disk fills up, or upgrade with pull && up -d. Each task takes about ten minutes. This is sufficient for most small and midsize projects.
Swarm: Compose extended across multiple machines. A single docker swarm init command enables it, and the Compose file barely needs to change. It sounds ideal, but Swarm adds another failure point. You must maintain Raft consensus, manager nodes, and cross-host networking, while its ecosystem for monitoring, CI integration, and package management comparable to Helm is far less extensive than K8s. My assessment is direct: Swarm is an awkward middle ground. If one machine is no longer enough, you should usually move directly to K8s instead of stopping at Swarm.
K8s: The de facto standard for multi-host orchestration. It has a steep learning curve and many components (api-server, etcd, scheduler, kube-proxy, and more), but in return it provides declarative rolling deployments, automatic scaling with HPA, a rich operator ecosystem, cross-availability-zone scheduling, and multitenant isolation between teams. Adopt K8s when you genuinely need multiple machines, multiple teams, and frequent deployments.
A practical decision guide:
| Scenario | Choose |
|---|---|
| One host can handle the load, and the team is small | Single-host Compose |
| Multiple hosts are needed, but traffic is modest and you do not want to learn K8s | Single-host Compose on N machines + reverse-proxy load balancing (more reliable than Swarm) |
| Multiple teams and services require elastic scaling | K8s |
| Temporary multi-host needs, while avoiding both Swarm and K8s | Stay on single-host Compose for now, then migrate after validating the business |
One final point is often overlooked: many projects do not need multiple machines at all. A 16-core, 32 GB machine running Next.js, Postgres, and Redis can serve hundreds of thousands of daily active users when properly optimized. Fully use one machine before discussing horizontal expansion. Do not adopt K8s simply to look advanced.
Summary
Making Compose production-ready comes down to these practices:
- Separate environments with base and override files, and inject variables with
env_file, so one Compose definition works everywhere. - Use health checks with
depends_onandservice_healthyto eliminate startup race conditions. - Use
restart: unless-stoppedand log-size limits for recovery and protection against disk exhaustion. - Run as non-root, use a read-only filesystem, and apply
cap_drop: ALLto make each container a minimal executable unit. - Always set resource limits so one runaway service cannot take down everything else.
For orchestration, single-host Compose is the sweet spot for small and midsize projects, Swarm is an awkward middle ground, and K8s should be reserved for teams that genuinely need multi-host orchestration.
A final line for every team tempted to adopt K8s to appear professional: A tool is advanced not because it is complex, but because it precisely matches the scale of the problem. Running a monolith on K8s is not engineering skill; it is an excuse for overengineering.
Related
Related Content
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.
Article
Long-Running Tool Calls: The Terminal-State Protocol for Operation, Poll, and Cancel
After the initial request times out, the tool may still be running server-side; issuing a cancel does not mean the side effects have stopped. This article establishes an operation identity, polling watermark, cancellation race handling, and a single terminal-state acceptance point for a single long-running tool call.
Article
Agent Memory Erasure Propagation: From User Forgetting Request to Verifiable Purge
Deleting one row in the primary table won't clear vectors, summaries, caches, or restored backups. This article uses a deletion state machine, generational tombstones, derived lineage, and end-to-end canary verification to truly forget.