Docker Compose Security: The Complete Guide

Docker Compose makes it easy to deploy complex applications. Copy the docker-compose.yml from a GitHub README, run docker compose up -d, and you're done.

That ease is the problem. The default Compose configuration for most projects ships wide open — privileged containers, ports exposed to the internet, passwords in plaintext, no resource limits. Every one of those defaults is a security hole, and most self-hosters are running production services with all of them.

We scanned 16 popular Docker Compose projects and found 252 findings across 25 services. Every single project had at least one critical issue. This isn't negligence — it's the result of Docker's defaults optimizing for "works out of the box" over "secure out of the box."

This guide walks through the most common Docker Compose security misconfigurations, explains why each one matters, and gives you the exact YAML to fix it.

1. Privileged mode

Severity: Critical

# Insecure
services:
  app:
    image: myapp
    privileged: true

privileged: true gives the container full access to the host kernel. It can load kernel modules, access all devices, and modify host networking. It's the equivalent of running as root on the host machine.

Very few containers actually need this. It shows up in Compose files because it "fixes" permission errors during development, and then nobody removes it.

Fix:

services:
  app:
    image: myapp
    # Remove privileged: true entirely
    # If the container needs specific capabilities, grant only those:
    cap_add:
      - NET_BIND_SERVICE  # only if it needs to bind ports below 1024

If removing privileged breaks the container, identify exactly which capability it needs and add only that one with cap_add.

2. Exposed ports bound to all interfaces

Severity: Critical

# Insecure — binds to 0.0.0.0, accessible from the internet
services:
  postgres:
    image: postgres:16
    ports:
      - "5432:5432"

When you write "5432:5432", Docker binds that port to 0.0.0.0 — all network interfaces. If your server has a public IP, your database is now accessible from the internet. This is the single most common way self-hosted databases get compromised.

Fix:

services:
  postgres:
    image: postgres:16
    ports:
      - "127.0.0.1:5432:5432"  # localhost only

If only other containers need to reach this service, remove the ports section entirely and use Docker's internal networking instead. Containers on the same Compose network can already reach each other by service name.

services:
  postgres:
    image: postgres:16
    # No ports section — only reachable by other containers
  app:
    image: myapp
    environment:
      DATABASE_URL: postgres://user:pass@postgres:5432/db

3. Plaintext secrets in environment variables

Severity: Critical

# Insecure — password visible in the Compose file, Git history, and `docker inspect`
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: mysecretpassword

Environment variables set this way end up in three places: the Compose file (often committed to Git), the container's metadata (docker inspect), and /proc/<pid>/environ inside the container.

Fix — use Docker secrets or an env file:

# Option 1: .env file (keep .env in .gitignore)
services:
  postgres:
    image: postgres:16
    env_file:
      - .env

# Option 2: Docker secrets (Swarm mode or Compose v2.22+)
services:
  postgres:
    image: postgres:16
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

At minimum, move secrets to an .env file and add it to .gitignore. Docker secrets are better but require images that support the _FILE suffix convention.

4. No capability restrictions

Severity: Warning

# Default — container gets Docker's default capability set
services:
  nginx:
    image: nginx:alpine

By default, Docker grants containers a set of Linux capabilities including CHOWN, DAC_OVERRIDE, FOWNER, KILL, SETGID, SETUID, NET_RAW, and others. Most containers don't need most of these.

Fix — drop all, add back only what's needed:

services:
  nginx:
    image: nginx:alpine
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # needed to bind port 80/443
      - CHOWN             # needed for nginx startup
      - SETGID
      - SETUID

Start with cap_drop: [ALL] and add back capabilities one at a time until the container works. Most web applications need only NET_BIND_SERVICE. Databases typically need CHOWN, SETGID, SETUID, and DAC_OVERRIDE.

5. No privilege escalation prevention

Severity: Warning

# Default — processes inside the container can gain new privileges
services:
  app:
    image: myapp

Without no-new-privileges, a process inside the container can escalate privileges via setuid binaries or other mechanisms. Combined with other misconfigurations, this can lead to container escape.

Fix:

services:
  app:
    image: myapp
    security_opt:
      - no-new-privileges:true

This is safe to add to virtually every container. It prevents processes from gaining privileges they didn't start with, which blocks a whole class of escalation attacks.

6. Running as root

Severity: Warning

# Default — container runs as root (UID 0)
services:
  app:
    image: myapp

Most Docker images default to running as root. If an attacker exploits a vulnerability in the application, they have root inside the container — which, combined with other misconfigurations, can lead to host compromise.

Fix:

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

Many images support running as non-root out of the box. Check the image documentation. For images that require root during startup (to bind ports or set permissions), the entrypoint typically drops privileges before starting the main process — in that case, user: may not be appropriate, but no-new-privileges still is.

7. Writable root filesystem

Severity: Warning

# Default — container can write anywhere in its filesystem
services:
  nginx:
    image: nginx:alpine

A writable root filesystem means an attacker who gains code execution can modify binaries, install tools, or plant backdoors inside the container.

Fix:

services:
  nginx:
    image: nginx:alpine
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run

Set read_only: true and mount tmpfs volumes for directories the application needs to write to. Common write paths: /tmp, /var/run (PID files), /var/cache, and application-specific data directories.

8. No resource limits

Severity: Warning

# Default — container can consume unlimited host resources
services:
  app:
    image: myapp

Without limits, a single container can consume all available memory and crash the host — either from a bug, a memory leak, or a deliberate denial-of-service. This takes down every other container with it.

Fix:

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

Set memory limits based on your application's actual usage. Check docker stats to see current consumption, then set the limit with some headroom. CPU limits prevent a single container from starving others.

9. No network segmentation

Severity: Warning

# Default — all services share one network and can reach each other
services:
  frontend:
    image: nginx
  backend:
    image: myapp
  database:
    image: postgres

By default, all services in a Compose file share a single network. The frontend can talk directly to the database. If the frontend is compromised, the attacker has a direct path to your data.

Fix:

services:
  frontend:
    image: nginx
    networks:
      - frontend

  backend:
    image: myapp
    networks:
      - frontend
      - backend

  database:
    image: postgres
    networks:
      - backend

networks:
  frontend:
  backend:

Put services on separate networks so they can only reach what they need. The frontend talks to the backend, the backend talks to the database, but the frontend can't reach the database directly.

Putting it all together

Here's a before and after for a typical web application stack:

Before (insecure defaults):

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
  app:
    image: myapp
    environment:
      DB_PASSWORD: secret123
  postgres:
    image: postgres:16
    ports:
      - "5432:5432"
    environment:
      POSTGRES_PASSWORD: secret123

After (hardened):

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE, CHOWN, SETGID, SETUID]
    security_opt: [no-new-privileges:true]
    read_only: true
    tmpfs: [/tmp, /var/cache/nginx, /var/run]
    deploy:
      resources:
        limits:
          memory: 128M
    networks: [frontend]

  app:
    image: myapp
    user: "1000:1000"
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    read_only: true
    tmpfs: [/tmp]
    env_file: [.env]
    deploy:
      resources:
        limits:
          memory: 512M
    networks: [frontend, backend]

  postgres:
    image: postgres:16
    # No ports — only reachable by app via Docker network
    cap_drop: [ALL]
    cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
    security_opt: [no-new-privileges:true]
    env_file: [.env]
    volumes:
      - pgdata:/var/lib/postgresql/data
    deploy:
      resources:
        limits:
          memory: 256M
    networks: [backend]

networks:
  frontend:
  backend:

volumes:
  pgdata:

Same application. Same behavior. Dramatically smaller attack surface.

Automate it with dockguard

Hardening a Compose file by hand works, but it's tedious and error-prone — especially when you're managing multiple stacks. dockguard scans your Compose files and generates hardened versions automatically:

# Scan and see findings
dockguard docker-compose.yml

# Scan and generate a hardened version
dockguard docker-compose.yml --fix

dockguard knows which capabilities each service actually needs. It won't blindly drop CHOWN from Postgres or remove the ports from your reverse proxy. The output is a drop-in replacement you can review and deploy.

Free, open source, single binary, no account needed.

github.com/narrowcastdev/dockguard

Checklist


Docker Compose defaults optimize for convenience. Security is your job. The fixes above take 10 minutes per stack and close the gaps that matter most.