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
- Audit every
privileged: true— remove or replace with specificcap_add - Bind exposed ports to
127.0.0.1unless they must be public - Move secrets from
environment:to.envfiles or Docker secrets - Add
cap_drop: [ALL]and grant back only needed capabilities - Add
security_opt: [no-new-privileges:true]to all containers - Set
user:directive where the image supports it - Enable
read_only: truewithtmpfsfor write paths - Set memory and CPU limits on every service
- Segment networks so services only reach what they need
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.