Docker Compose Security: Complete Hardening Guide for 2026

Docker Compose Security

Docker Compose makes it incredibly easy to spin up multi-container applications with a single command. But that convenience comes with a risk: developers often prioritize getting things running over getting things secure. The result is production environments riddled with exposed ports, plaintext secrets, privileged containers, and no network isolation whatsoever.

This guide covers everything you need to secure a Docker Compose setup β€” from network segmentation and secret management to resource limits and read-only filesystems β€” with real docker-compose.yml examples you can apply today.

Why Docker Compose Security Deserves Its Own Attention

Most Docker security guides focus on individual containers. But Docker Compose introduces an additional layer of complexity:

  • Multiple services interact with each other
  • A single docker-compose.yml file defines the entire stack
  • Misconfiguration in one service can expose the entire application
  • Developers frequently reuse development configs in production

A single insecure line in your Compose file β€” such as mounting the Docker socket or using network_mode: host β€” can undermine every other security measure you have in place.

Let us go through each area systematically.

1. Never Use network_mode: host

One of the most dangerous settings in Docker Compose is network_mode: host. This removes all network isolation between the container and the host, meaning the container shares the host’s network stack directly.

# ❌ DANGEROUS β€” removes all network isolation
services:
  webapp:
    image: myapp:latest
    network_mode: host

With network_mode: host, a compromised container can bind to any port on the host, intercept traffic, and reach services that were never intended to be exposed.

Always use bridge networks instead:

# βœ… CORRECT β€” explicit bridge network with isolation
services:
  webapp:
    image: myapp:latest
    networks:
      - app_net

networks:
  app_net:
    driver: bridge

2. Segment Your Network by Function

By default, all services in a Docker Compose file share the same network and can reach each other freely. This violates the principle of least privilege at the network level.

The correct approach is to create separate networks for each tier of your application.

version: "3.9"

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend_net
    depends_on:
      - app

  app:
    image: myapp:latest
    networks:
      - frontend_net
      - backend_net
    depends_on:
      - db
      - redis

  db:
    image: postgres:15-alpine
    networks:
      - backend_net  # Only reachable by app, not by nginx

  redis:
    image: redis:7-alpine
    networks:
      - backend_net  # Only reachable by app

networks:
  frontend_net:
    driver: bridge
  backend_net:
    driver: bridge
    internal: true  # No outbound internet access for backend services

With internal: true on the backend network, your database and cache services have no direct internet access β€” even if an attacker compromises one of them.

3. Run Containers as Non-Root Users

Just as with standalone containers, services in Docker Compose should never run as root. Set the user directive in your Compose file or ensure your Dockerfile defines a non-root user.

Option A β€” Set user in docker-compose.yml:

services:
  app:
    image: myapp:latest
    user: "1001:1001"  # UID:GID

Option B β€” Define the user in your Dockerfile (recommended):

FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY . .
USER appuser
CMD ["node", "server.js"]

Then reference it in Compose normally β€” the non-root user is baked into the image.

Verify after starting your stack:

docker compose exec app whoami
# Expected: appuser (not root)

4. Use Docker Secrets for Sensitive Data

Hardcoding passwords and API keys directly into docker-compose.yml is one of the most common and dangerous mistakes. These values end up in version control, CI/CD logs, and container inspect output.

# ❌ NEVER do this
services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD: mysecretpassword

Use Docker Secrets instead (Docker Swarm mode):

# Create secrets from the command line
echo "mysecretpassword" | docker secret create db_password -
echo "myapikey123" | docker secret create api_key -
# docker-compose.yml with secrets
version: "3.9"

services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

  app:
    image: myapp:latest
    environment:
      API_KEY_FILE: /run/secrets/api_key
    secrets:
      - api_key

secrets:
  db_password:
    external: true
  api_key:
    external: true

For non-Swarm development environments, use a .env file β€” but make absolutely sure it is listed in .gitignore:

# .env
POSTGRES_PASSWORD=mysecretpassword
API_KEY=myapikey123
# docker-compose.yml referencing .env
services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# .gitignore
.env
*.env

5. Set Resource Limits for Every Service

Without resource limits, a single misbehaving or compromised service can starve the entire host of CPU and memory β€” an effective Denial of Service attack from within your own stack.

version: "3.9"

services:
  app:
    image: myapp:latest
    deploy:
      resources:
        limits:
          cpus: "0.50"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 256M

  db:
    image: postgres:15-alpine
    deploy:
      resources:
        limits:
          cpus: "1.00"
          memory: 1G
        reservations:
          memory: 512M

  redis:
    image: redis:7-alpine
    deploy:
      resources:
        limits:
          cpus: "0.25"
          memory: 128M

Note: The deploy.resources key works natively with Docker Swarm. For plain docker compose up, use --compatibility flag or switch to the older mem_limit / cpus keys at the service level.

# Alternative for plain Docker Compose (non-Swarm)
services:
  app:
    image: myapp:latest
    mem_limit: 512m
    cpus: 0.5

6. Enable Read-Only Filesystems

Containers in your Compose stack should not be able to write to their own filesystems unless absolutely necessary. Use read_only: true and mount tmpfs only for directories that genuinely need write access.

version: "3.9"

services:
  nginx:
    image: nginx:alpine
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro  # Mount config as read-only

  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
    volumes:
      - app_uploads:/app/uploads  # Named volume for legitimate write access

volumes:
  app_uploads:

Mounting your configuration files with :ro (read-only) at the end of the volume path is also a good habit β€” it prevents the container from accidentally or maliciously overwriting its own config.

7. Drop Unnecessary Linux Capabilities

Add cap_drop and cap_add to every service in your Compose file. The goal is to start with zero capabilities and add back only what each specific service requires.

version: "3.9"

services:
  nginx:
    image: nginx:alpine
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # Needed to bind to ports 80 and 443

  app:
    image: myapp:latest
    cap_drop:
      - ALL
    # No capabilities needed β€” app runs on an unprivileged port

  db:
    image: postgres:15-alpine
    cap_drop:
      - ALL
    cap_add:
      - CHOWN       # Needed by PostgreSQL to manage data directory ownership
      - SETUID
      - SETGID

Never use privileged: true in production. This grants the container nearly all Linux capabilities and removes most of the security isolation Docker provides.

# ❌ NEVER use this in production
services:
  app:
    image: myapp:latest
    privileged: true

8. Do Not Mount the Docker Socket

Mounting /var/run/docker.sock into a container gives it full control over the Docker daemon β€” equivalent to giving it root access to the host. This is a common pattern in monitoring and CI tools, but it is extremely dangerous in production.

# ❌ DANGEROUS β€” full host access via Docker socket
services:
  monitoring:
    image: some-monitor:latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Safer alternatives:

  • Use the Docker API over TLS with a restricted client certificate
  • Use cAdvisor without socket access for container metrics
  • For CI/CD build scenarios, replace Docker-in-Docker with Kaniko or Buildah

If you absolutely must use the socket for a tool like Portainer or Traefik, isolate that service on its own dedicated network with no connectivity to your application services.

9. Restrict Port Exposure β€” Only Publish What Is Necessary

Every port you publish with ports: in Docker Compose becomes accessible from outside the host. Many services β€” databases, caches, internal APIs β€” should never be reachable from the internet.

# ❌ Exposing database port to the host (and potentially the internet)
services:
  db:
    image: postgres:15-alpine
    ports:
      - "5432:5432"  # Anyone who can reach your host can try to connect
# βœ… Database is only accessible within the internal Docker network
services:
  db:
    image: postgres:15-alpine
    expose:
      - "5432"  # Visible only to other containers on the same network
    networks:
      - backend_net

Use expose instead of ports for internal services. Reserve ports only for services that genuinely need to accept external traffic β€” typically your reverse proxy or load balancer.

10. Apply Security Options (Seccomp & AppArmor)

You can apply seccomp and AppArmor profiles directly in Docker Compose using the security_opt key.

version: "3.9"

services:
  app:
    image: myapp:latest
    security_opt:
      - no-new-privileges:true       # Prevent privilege escalation
      - seccomp:./seccomp-profile.json
      - apparmor:docker-default

  db:
    image: postgres:15-alpine
    security_opt:
      - no-new-privileges:true

no-new-privileges:true is especially important β€” it prevents processes inside the container from gaining additional privileges through setuid binaries or other escalation techniques. This should be set on every service without exception.

11. Use a Specific Image Tag, Never latest

Using image: myapp:latest in production is a security and stability risk. The latest tag is mutable β€” it can change without warning, introducing breaking changes or vulnerabilities.

# ❌ Unpredictable and potentially insecure
services:
  app:
    image: myapp:latest

# βœ… Pinned to a specific, audited version
services:
  app:
    image: myapp:1.4.2

For base images, pin to a specific digest for maximum immutability:

services:
  app:
    image: node:20-alpine@sha256:a1b2c3d4e5f6...

This guarantees that even if the tag is republished with different content, your deployment always uses the exact image you tested and scanned.

12. Configure Logging Properly

Uncontrolled logging can fill up your disk (a form of DoS) and miss critical security events. Configure log rotation and consider forwarding logs to a centralized system.

version: "3.9"

services:
  app:
    image: myapp:latest
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"
        labels: "service,environment"
        env: "APP_ENV"

  nginx:
    image: nginx:alpine
    logging:
      driver: "json-file"
      options:
        max-size: "20m"
        max-file: "3"

For production, forward logs to a centralized system like the ELK Stack, Loki, or a managed logging service:

services:
  app:
    image: myapp:latest
    logging:
      driver: "syslog"
      options:
        syslog-address: "tcp://logs.yourcompany.com:514"
        tag: "myapp-production"

Complete Hardened docker-compose.yml Example

Here is a full example bringing all the practices above together into a single production-ready Compose file for a typical three-tier web application:

version: "3.9"

services:
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend_net
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
    logging:
      driver: "json-file"
      options:
        max-size: "20m"
        max-file: "3"
    depends_on:
      - app

  app:
    image: myapp:1.4.2
    networks:
      - frontend_net
      - backend_net
    user: "1001:1001"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
      - seccomp:./seccomp-profile.json
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
      APP_ENV: production
    secrets:
      - db_password
    mem_limit: 512m
    cpus: 0.5
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"
    depends_on:
      - db
      - redis

  db:
    image: postgres:15-alpine
    networks:
      - backend_net
    expose:
      - "5432"
    volumes:
      - db_data:/var/lib/postgresql/data
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    security_opt:
      - no-new-privileges:true
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_USER: appuser
      POSTGRES_DB: appdb
    secrets:
      - db_password
    mem_limit: 1g
    cpus: 1.0
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  redis:
    image: redis:7-alpine
    networks:
      - backend_net
    expose:
      - "6379"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    mem_limit: 128m
    cpus: 0.25

networks:
  frontend_net:
    driver: bridge
  backend_net:
    driver: bridge
    internal: true

volumes:
  db_data:

secrets:
  db_password:
    external: true

Docker Compose Security Checklist

Run through this checklist before every production deployment:

[ ] network_mode: host is not used anywhere
[ ] Services are separated into frontend and backend networks
[ ] backend network has internal: true
[ ] All services run as non-root users
[ ] No secrets or passwords in plain environment variables
[ ] Docker Secrets or .env with .gitignore is used
[ ] Resource limits (mem_limit, cpus) set on every service
[ ] read_only: true applied where possible, with tmpfs for write paths
[ ] cap_drop: ALL on every service, cap_add only for what is needed
[ ] privileged: true is not used on any service
[ ] Docker socket is not mounted into any container
[ ] Only the reverse proxy/load balancer has ports: exposed
[ ] All other services use expose: instead of ports:
[ ] no-new-privileges:true in security_opt on every service
[ ] All images are pinned to specific versions, not latest
[ ] Logging is configured with rotation limits

Conclusion

A default Docker Compose file is not a secure Docker Compose file. The good news is that hardening your stack does not require rewriting everything from scratch β€” it is a matter of adding the right directives to an existing file, service by service.

Start with the highest-impact changes: network segmentation, removing secrets from environment variables, and adding no-new-privileges:true to every service. From there, work through the checklist systematically.

Security in Docker Compose is cumulative β€” each layer you add makes the entire stack meaningfully harder to compromise.

Have questions about a specific service configuration or use case? Leave a comment below.

(Visited 1 times, 1 visits today)

You may also like