DEV Community

Rasika Dangamuwa
Rasika Dangamuwa

Posted on

Why Your Docker Compose Fails in Production: 5 Subtle Traps Every Developer Misses

Most developers know the feeling: you run docker compose up locally, everything starts smoothly in seconds, and you deploy to staging. Then, under actual workloads or automated restarts, containers enter endless crash loops, database migrations fail mid-flight, or files written inside the container become unreadable on the host.

Docker Compose makes orchestrating multi-container applications straightforward. However, configurations written for quick local testing often hide subtle race conditions, permission mismatches, and resource leaks.

Here are five common Docker Compose traps that bite engineering teams—and how to prevent each one.


1. Naive depends_on Without service_healthy

The most frequent cause of deployment startup crashes is assuming depends_on waits for a dependent service to be ready.

By default, depends_on only checks that the dependent container process has spawned, not that the service inside is accepting connections.

# ❌ INCORRECT: web-api crashes because postgres is still initializing
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_PASSWORD: secretpassword

  web-api:
    image: myapp/api:latest
    depends_on:
      - postgres
Enter fullscreen mode Exit fullscreen mode

When PostgreSQL starts, it creates system tables, runs recovery, and only then opens port 5432. If your API container starts immediately, its migration script throws ECONNREFUSED and exits.

To fix this, define a container healthcheck and require condition: service_healthy:

# ✅ CORRECT: waits for PostgreSQL readiness check
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_PASSWORD: secretpassword
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

  web-api:
    image: myapp/api:latest
    depends_on:
      postgres:
        condition: service_healthy
Enter fullscreen mode Exit fullscreen mode

2. Bind Mount File Permission Discrepancies

When mounting host directories into containers (volumes: ["./data:/app/data"]), Docker maps container UIDs directly to the host filesystem.

If your container process runs as root (UID 0), any files written to the bind mount will be owned by root on the host machine. If your process runs as an unprivileged user (such as node with UID 1000), but your host directory is owned by another UID, the container throws EACCES: permission denied on startup.

For production and staging environments:

  • Use named volumes for persistent application state (volumes: ["app-data:/app/data"]).
  • When mounting configuration files from the host, make them read-only with :ro.
  • Set explicit container user IDs when required: user: "${UID:-1000}:${GID:-1000}".

If you are assembling multi-tier stacks (e.g., Node/Go + PostgreSQL + Redis + Nginx) with properly configured volumes, networks, and healthcheck blocks, writing all these YAML definitions by hand is tedious. You can quickly generate and inspect a clean, standards-compliant Compose file using browser-based tools like Nutilz Docker Compose Generator before committing it to your repo.


3. Missing Memory and CPU Resource Limits

By default, Docker containers have no resource limits. If a background worker leaks memory or gets stuck in a CPU-heavy loop, it can starve neighboring containers. Even worse, the Linux kernel Out-Of-Memory (OOM) killer may terminate critical system processes or Docker itself.

Always specify explicit resource limits under the deploy.resources section:

services:
  web-api:
    image: myapp/api:latest
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 1024M
        reservations:
          cpus: "0.25"
          memory: 256M
Enter fullscreen mode Exit fullscreen mode

4. Unescaped $ Characters in Commands and Configs

Docker Compose interprets $ as variable interpolation from your .env file or environment.

If you store a hashed password, a regex pattern, or an Nginx variable inside your Compose file (such as $argon2id$ or $host), Compose resolves it as an empty variable and silently replaces it with nothing.

To pass a literal $ sign into a command or inline template:

  • Escape it with a double dollar sign: $$
# ❌ FAILS: $host is stripped by Compose
command: /bin/sh -c "echo $host"

# ✅ CORRECT: Escaped dollar sign evaluates inside the container shell
command: /bin/sh -c "echo $$host"
Enter fullscreen mode Exit fullscreen mode

5. restart: always vs restart: unless-stopped

Using restart: always on misconfigured containers causes infinite crash loops that can fill your disk with logs in minutes.

Prefer restart: unless-stopped and combine it with log rotation limits:

services:
  web-api:
    image: myapp/api:latest
    restart: unless-stopped
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
Enter fullscreen mode Exit fullscreen mode

Conclusion

Docker Compose is simple to write, but small syntax details make a huge difference in reliability, migration timing, and disk health. Gate service dependencies with service_healthy, enforce memory caps, and use named volumes for persistent data.

Whenever you are bootstrapping a new stack or validating container networking, bookmark nutilz.com/docker-compose-generator to quickly scaffold clean, production-ready Compose files.

Top comments (0)