#DevOps

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
Author @admin·Published July 26, 2026·Updated July 26, 2026

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.

text
.
├── 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:

yaml
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:

yaml
# 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:

bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
yaml
# 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:

yaml
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_period gives the service a warm-up window. Failures during this period do not count toward retries, which is especially important for slow-starting JVM services and databases.
  • Make test run a command that genuinely reflects application readiness rather than merely using curl localhost. Use pg_isready for Postgres, ping for Redis, and mysqladmin ping for MySQL.
  • Note the $$ escaping: Compose treats $VAR as 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 manually stop it, 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.
yaml
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:

yaml
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 with journalctl.
  • gelf: Send logs to Graylog.
yaml
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:

yaml
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:

yaml
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:

dockerfile
RUN addgroup -S app && adduser -S app -G app
USER app

Or override the user in Compose:

yaml
services:
  app:
    user: "1000:1000"

Second, use a read-only filesystem. Application code should not change at runtime, so lock it down:

yaml
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:

yaml
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:

yaml
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:

ScenarioChoose
One host can handle the load, and the team is smallSingle-host Compose
Multiple hosts are needed, but traffic is modest and you do not want to learn K8sSingle-host Compose on N machines + reverse-proxy load balancing (more reliable than Swarm)
Multiple teams and services require elastic scalingK8s
Temporary multi-host needs, while avoiding both Swarm and K8sStay 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:

  1. Separate environments with base and override files, and inject variables with env_file, so one Compose definition works everywhere.
  2. Use health checks with depends_on and service_healthy to eliminate startup race conditions.
  3. Use restart: unless-stopped and log-size limits for recovery and protection against disk exhaustion.
  4. Run as non-root, use a read-only filesystem, and apply cap_drop: ALL to make each container a minimal executable unit.
  5. 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