The 5-Minute Docker Compose Security Checklist We Run for Every Client
Hole #1: Ports Bound to 0.0.0.0
The Fix
Hole #2: Running as Root
The Fix
The Fix
The Complete Hardened Template
Bonus: Automated Scanning
How We Can Help We've reviewed Docker Compose configurations for over 30 startups. These three security holes appear in every single one. Without exception. They're trivial to fix. Most teams just never do because nobody tells them until something goes wrong. The most common Docker Compose pattern: That "5432:5432" is shorthand for "0.0.0.0:5432:5432". Your database is now accessible from every network interface — including the public internet if your host has a public IP. We've seen production Postgres instances exposed to the internet with default credentials. One client's Redis was mining crypto for 3 days before anyone noticed. For services that only talk to each other via Docker network, remove the port binding entirely: Rule: Only expose ports you need from outside Docker. If the service is internal-only, don't map it. Check your running containers right now: If an attacker achieves container escape (CVE-2024-21626 in runc, for example), they land on the host as root. Full control. Game over. Common objection: "My app needs to write files." Use volumes for specific writable paths. Don't give the entire filesystem write access. Without limits, a single container with a memory leak eats the entire host: When this happens, the OOM killer starts murdering other containers. Your database goes down. Your monitoring goes down. Everything cascades. Limits = hard ceiling. Container gets OOM-killed if it exceeds this.
Reservations = guaranteed minimum. Docker won't schedule other work into this space. Rule of thumb: Set memory limit at 2x your app's normal working set. If your Node.js app uses 200MB normally, set limit to 512M. Enough headroom for spikes, tight enough to prevent runaway. Here's our baseline docker-compose.yml security config that we apply to every project: Add this to your CI to catch these issues before deploy: Or use Trivy for image scanning: We run free 15-minute Docker security reviews. Share your docker-compose.yml (redact credentials), and we'll tell you exactly what's exposed, what's at risk, and how to fix it. No pitch. Just fixes. Book a review: techsaas.cloud/contact Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse
$ services: postgres: image: postgres:16 ports: - "5432:5432" # ← This is 0.0.0.0:5432
services: postgres: image: postgres:16 ports: - "5432:5432" # ← This is 0.0.0.0:5432
services: postgres: image: postgres:16 ports: - "5432:5432" # ← This is 0.0.0.0:5432
services: postgres: image: postgres:16 ports: - "127.0.0.1:5432:5432" # ← Only accessible from localhost
services: postgres: image: postgres:16 ports: - "127.0.0.1:5432:5432" # ← Only accessible from localhost
services: postgres: image: postgres:16 ports: - "127.0.0.1:5432:5432" # ← Only accessible from localhost
services: postgres: image: postgres:16 # No ports section at all — only reachable via Docker internal DNS networks: - backend
services: postgres: image: postgres:16 # No ports section at all — only reachable via Docker internal DNS networks: - backend
services: postgres: image: postgres:16 # No ports section at all — only reachable via Docker internal DNS networks: - backend
-weight: 500;">docker compose exec app whoami
# Output: root
-weight: 500;">docker compose exec app whoami
# Output: root
-weight: 500;">docker compose exec app whoami
# Output: root
services: app: image: myapp:latest user: "1000:1000" security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp
services: app: image: myapp:latest user: "1000:1000" security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp
services: app: image: myapp:latest user: "1000:1000" security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp
# Container using 14GB on a 16GB host
-weight: 500;">docker stats --no-stream
CONTAINER CPU % MEM USAGE / LIMIT MEM %
app 340% 14.2GiB / 15.6GiB 91.03%
# Container using 14GB on a 16GB host
-weight: 500;">docker stats --no-stream
CONTAINER CPU % MEM USAGE / LIMIT MEM %
app 340% 14.2GiB / 15.6GiB 91.03%
# Container using 14GB on a 16GB host
-weight: 500;">docker stats --no-stream
CONTAINER CPU % MEM USAGE / LIMIT MEM %
app 340% 14.2GiB / 15.6GiB 91.03%
services: app: image: myapp:latest deploy: resources: limits: memory: 512M cpus: '1.0' reservations: memory: 256M cpus: '0.25'
services: app: image: myapp:latest deploy: resources: limits: memory: 512M cpus: '1.0' reservations: memory: 256M cpus: '0.25'
services: app: image: myapp:latest deploy: resources: limits: memory: 512M cpus: '1.0' reservations: memory: 256M cpus: '0.25'
services: app: image: myapp:latest user: "1000:1000" read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Only if binding port <1024 tmpfs: - /tmp deploy: resources: limits: memory: 512M cpus: '1.0' networks: - backend # No port binding — reverse proxy handles external access postgres: image: postgres:16-alpine user: "999:999" # postgres user UID read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL volumes: - pgdata:/var/lib/postgresql/data tmpfs: - /tmp - /run/postgresql deploy: resources: limits: memory: 1G cpus: '2.0' networks: - backend # No ports exposed — app connects via Docker DNS traefik: image: traefik:v3 ports: - "0.0.0.0:443:443" # Only HTTPS exposed publicly - "127.0.0.1:8080:8080" # Dashboard localhost only # ... rest of config
services: app: image: myapp:latest user: "1000:1000" read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Only if binding port <1024 tmpfs: - /tmp deploy: resources: limits: memory: 512M cpus: '1.0' networks: - backend # No port binding — reverse proxy handles external access postgres: image: postgres:16-alpine user: "999:999" # postgres user UID read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL volumes: - pgdata:/var/lib/postgresql/data tmpfs: - /tmp - /run/postgresql deploy: resources: limits: memory: 1G cpus: '2.0' networks: - backend # No ports exposed — app connects via Docker DNS traefik: image: traefik:v3 ports: - "0.0.0.0:443:443" # Only HTTPS exposed publicly - "127.0.0.1:8080:8080" # Dashboard localhost only # ... rest of config
services: app: image: myapp:latest user: "1000:1000" read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Only if binding port <1024 tmpfs: - /tmp deploy: resources: limits: memory: 512M cpus: '1.0' networks: - backend # No port binding — reverse proxy handles external access postgres: image: postgres:16-alpine user: "999:999" # postgres user UID read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL volumes: - pgdata:/var/lib/postgresql/data tmpfs: - /tmp - /run/postgresql deploy: resources: limits: memory: 1G cpus: '2.0' networks: - backend # No ports exposed — app connects via Docker DNS traefik: image: traefik:v3 ports: - "0.0.0.0:443:443" # Only HTTPS exposed publicly - "127.0.0.1:8080:8080" # Dashboard localhost only # ... rest of config
# Install -weight: 500;">docker-compose-linter
-weight: 500;">pip -weight: 500;">install -weight: 500;">docker-compose-linter # Scan for security issues
-weight: 500;">docker-compose-lint --security -weight: 500;">docker-compose.yml
# Install -weight: 500;">docker-compose-linter
-weight: 500;">pip -weight: 500;">install -weight: 500;">docker-compose-linter # Scan for security issues
-weight: 500;">docker-compose-lint --security -weight: 500;">docker-compose.yml
# Install -weight: 500;">docker-compose-linter
-weight: 500;">pip -weight: 500;">install -weight: 500;">docker-compose-linter # Scan for security issues
-weight: 500;">docker-compose-lint --security -weight: 500;">docker-compose.yml
trivy config -weight: 500;">docker-compose.yml
trivy config -weight: 500;">docker-compose.yml
trivy config -weight: 500;">docker-compose.yml - user: "1000:1000" — runs as non-root UID
- no-new-privileges — prevents privilege escalation via setuid binaries
- read_only: true — container filesystem is immutable
- tmpfs: /tmp — gives the app a writable temp directory without persistent write access