5 Docker Compose Mistakes That Will Bite You in Production

5 Docker Compose Mistakes That Will Bite You in Production

You've got your docker-compose.yml working on your laptop. Containers come up, the app responds, everything looks fine. But "works on my machine" and "survives in production" are two very different things. Here are five Docker Compose mistakes that quietly set you up for a bad day, and how to fix each one.

1. Binding to 0.0.0.0 Instead of 127.0.0.1

This is the one that catches people off guard. When you write ports: "8080:80" in your Compose file, Docker binds that port to all network interfaces by default. That means your database admin panel, your debug endpoint, your internal API, all of it is reachable from the public internet.

You might think your firewall has you covered, but Docker manipulates iptables directly, bypassing UFW and firewalld rules entirely.

The fix:

ports:
  - "127.0.0.1:8080:80"

Bind to localhost explicitly. If the service only needs to talk to other containers, skip ports entirely and use Docker's internal networking. Services on the same Compose network can reach each other by service name without exposing anything to the host.

2. Running Everything as Root

By default, most Docker images run processes as root inside the container. That feels harmless because "it's just a container," but container escapes are a real attack vector. If someone breaks out, they land on your host as root.

The fix is straightforward. Add a USER directive in your Dockerfile:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

Or set it in your Compose file:

services:
  app:
    image: myapp:latest
    user: "1000:1000"

Not every image supports running as non-root out of the box, but most modern ones do. Check the image documentation and test it. The 10 minutes you spend now saves you a very bad conversation with your security team later.

3. Storing Secrets in docker-compose.yml

We've all done it. You need a database password, so you drop it into the environment block:

environment:
  - DB_PASSWORD=supersecret123

That password is now in your git history forever. Even if you remove it later, anyone with repo access can find it.

Better options exist. Use an .env file that's listed in .gitignore:

environment:
  - DB_PASSWORD=${DB_PASSWORD}
# .env (never committed)
DB_PASSWORD=supersecret123

For production, consider Docker secrets (if you're on Swarm) or mount credentials from a secrets manager. The key principle: secrets should never live in version control, period.

4. No Resource Limits

Without explicit limits, a single misbehaving container can consume all available RAM or CPU on your host, taking down every other service with it. A memory leak in one app becomes a full system outage.

Always set limits:

services:
  app:
    image: myapp:latest
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"

If you're on an older Compose version or not using Swarm, the legacy syntax works too: mem_limit: 512m and cpus: 1.0 directly under the service. Either way, set something.

Start conservative and monitor. It's easier to increase limits than to recover from an OOM-killed database at 3 AM.

5. No Health Checks

docker compose up -d reports success the moment a container starts. It doesn't check whether your app is actually ready to serve traffic. Your container might be up but stuck in a boot loop, waiting on a missing dependency, or crashing silently.

Add health checks:

services:
  api:
    image: myapi:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

With health checks in place, docker compose ps shows you the real state of your stack. You can also use depends_on with conditions to make sure dependent services wait for healthy upstreams:

depends_on:
  db:
    condition: service_healthy

This turns your Compose file from "start everything and hope" into "start everything in the right order and verify it works."

Troubleshooting

Port still reachable after binding to 127.0.0.1: Make sure you don't have a reverse proxy or load balancer forwarding traffic to that port. Also verify no other Compose file on the same host is binding the same port to 0.0.0.0.

Container won't start as non-root: Check file permissions on mounted volumes. Files created by root on the host won't be writable by your non-root container user. Run chown -R 1000:1000 on the volume directory before starting.

Health check keeps failing: Verify the health endpoint exists and responds inside the container. Run docker exec <container> curl -f http://localhost:8080/health manually to debug.

One Last Thing

None of these mistakes are exotic. They're the defaults you forget to override. The good news is they're all fixable in minutes, and once you build the habit, every future Compose file starts from a better baseline.

If you want to see these practices in action, Elestio applies them to every managed service deployment: non-root users, internal networking, health checks, and resource limits come configured out of the box.

Thanks for reading. See you in the next one.