It started with a Slack message from our CTO at 2 AM.
"We have a breach. Someone got into our container host."
That was two years ago. Our team had been running Docker in production for over a year, and like many teams, we had focused entirely on getting things working — not on keeping things secure. We assumed Docker was "safe by default." We were wrong.
After spending weeks doing a post-mortem and rebuilding our infrastructure with security as a first-class concern, I learned more about container security in one month than I had in the previous year. This article is the guide I wish I had before that 2 AM message.
Why Container Security Is Different
Containers share the host kernel. That single fact changes everything. A misconfigured container is not an isolated problem — it is a potential foothold into your entire host system. Understanding this is the mental shift that makes everything else click.
Let us walk through 10 concrete practices, from the easiest wins to the more advanced configurations.
1. Never Run Containers as Root
This is the number one mistake I see in production Docker setups. By default, processes inside a container run as root (UID 0). If an attacker exploits your application, they get root inside the container — and depending on your configuration, that can mean root on the host.
Fix it in your Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Create a non-root user and switch to it
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Or enforce it at runtime:
docker run --user 1000:1000 my-app
2. Use Read-Only Filesystems
If your application does not need to write to the filesystem (most web services do not), make the container filesystem read-only. This stops a whole class of attacks where the attacker tries to write malicious scripts or modify configuration files.
docker run --read-only \
--tmpfs /tmp \
--tmpfs /var/run \
my-app
Or in Docker Compose:
services:
api:
image: my-app
read_only: true
tmpfs:
- /tmp
- /var/run
The --tmpfs flag gives the container writable in-memory space for things like temp files and PID files, without touching the real filesystem.
3. Drop Unnecessary Linux Capabilities
Docker gives containers a default set of Linux capabilities — powers like NET_RAW (can craft raw packets), SYS_CHROOT (can change root directory), and others that most applications simply do not need.
Drop all capabilities and add back only what you need:
docker run \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
my-app
In Docker Compose:
services:
api:
image: my-app
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
For most web APIs, you need zero extra capabilities beyond the defaults — and you should drop the defaults too.
4. Scan Your Images for Vulnerabilities
Your application code is only part of the equation. The base image you build on top of could contain hundreds of known vulnerabilities. Scanning tools make this visible.
# Using Docker Scout (built into Docker Desktop and CLI)
docker scout cves my-app:latest
# Using Trivy (open source, excellent CI integration)
trivy image my-app:latest
# Using Grype
grype my-app:latest
Integrate this into your CI pipeline so vulnerabilities are caught before images reach production:
# .github/workflows/security.yml
- name: Scan image for vulnerabilities
run: |
trivy image \
--exit-code 1 \
--severity HIGH,CRITICAL \
my-app:${{ github.sha }}
5. Use Minimal Base Images
Fewer packages means a smaller attack surface. Alpine Linux images are around 5MB and contain almost nothing. Distroless images from Google contain only your application and its runtime — no shell, no package manager, nothing extra.
# Before: 1.1GB attack surface
FROM node:20
# Better: ~150MB
FROM node:20-alpine
# Best for production: ~120MB, no shell at all
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app .
EXPOSE 3000
CMD ["server.js"]
An attacker who gets into a distroless container has no bash, no curl, no wget — their options are severely limited.
6. Never Put Secrets in Images
This one should be obvious, but I still find secrets baked into Docker images in the wild. Environment variables, build args, and COPY instructions can all end up in image layers — and image layers are permanent.
Bad:
# This secret is now in your image forever
ARG DATABASE_URL=postgres://user:password@host/db
ENV DATABASE_URL=$DATABASE_URL
Better — inject secrets at runtime:
docker run -e DATABASE_URL="$DATABASE_URL" my-app
Best — use Docker secrets for Swarm, or mount secret files:
# Docker secret (Swarm)
docker secret create db_password ./password.txt
# Mount as a file at runtime
docker run \
--mount type=secret,id=db_password \
my-app
Always add your .env files to .dockerignore:
.env
.env.local
.env.production
*.pem
*.key
secrets/
7. Limit Container Resources
Without resource limits, a single compromised or runaway container can eat all CPU and memory on the host, taking down every other service. This is both a reliability issue and a security issue (denial of service).
docker run \
--memory 512m \
--memory-swap 512m \
--cpus 0.5 \
--pids-limit 100 \
my-app
In Docker Compose:
services:
api:
image: my-app
deploy:
resources:
limits:
cpus: '0.50'
memory: 512M
--pids-limit is particularly underrated — it prevents fork bombs and process-based denial-of-service attacks.
8. Enable Security Profiles (seccomp and AppArmor)
Docker applies a default seccomp profile that blocks about 44 dangerous syscalls. You can go further with a custom profile.
# Apply default seccomp profile explicitly
docker run --security-opt seccomp=default my-app
# Apply a custom restrictive profile
docker run --security-opt seccomp=./my-seccomp-profile.json my-app
# Enable AppArmor profile
docker run --security-opt apparmor=docker-default my-app
# Prevent privilege escalation inside the container
docker run --security-opt no-new-privileges my-app
The no-new-privileges flag is an easy win — it prevents processes inside the container from gaining more privileges than they started with, even if they call setuid binaries.
9. Isolate Containers with Networks
By default, all containers on a host can talk to each other on the default bridge network. Use custom networks to enforce isolation — only containers that need to communicate should be on the same network.
services:
api:
image: my-api
networks:
- frontend
- backend
postgres:
image: postgres:16
networks:
- backend # Only accessible from api, not from internet-facing services
nginx:
image: nginx
networks:
- frontend # Only talks to api, not to the database
ports:
- "80:80"
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No external access
The internal: true flag on the backend network means containers on that network cannot reach the internet — even if compromised, they cannot exfiltrate data outbound.
10. Audit Your Running Containers Regularly
Security is not a one-time configuration — it is an ongoing practice. Build auditing into your workflow.
# List containers running as root
docker ps -q | xargs docker inspect \
--format '{{.Name}}: User={{.Config.User}}' | grep 'User=$'
# Check for containers with dangerous capabilities
docker ps -q | xargs docker inspect \
--format '{{.Name}}: CapAdd={{.HostConfig.CapAdd}}'
# Find images with no vulnerability scan record
docker images --format '{{.Repository}}:{{.Tag}}' | \
xargs -I{} sh -c 'echo "Scanning {}..."; trivy image --quiet {}'
# Use Docker Bench for Security (automated CIS benchmark)
docker run --rm \
--net host --pid host --userns host --cap-add audit_control \
-v /var/lib:/var/lib \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /etc:/etc:ro \
docker/docker-bench-security
Docker Bench for Security is a script that checks for dozens of common configuration issues against the CIS Docker Benchmark. Run it when you first set up a new host and then periodically thereafter.
Putting It All Together
Here is a production-ready Docker Compose snippet that incorporates all of these practices:
services:
api:
image: my-api:${VERSION}
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges
- seccomp=./seccomp-profile.json
networks:
- backend
environment:
- NODE_ENV=production
secrets:
- db_password
deploy:
resources:
limits:
cpus: '0.50'
memory: 256M
pids: 100
networks:
backend:
internal: true
secrets:
db_password:
external: true
The Mindset Shift
The 2 AM breach taught me that container security is not about any single setting — it is about defense in depth. Each of these practices limits what an attacker can do if they get past one layer of defense.
None of them are complicated. Most can be added to existing setups in an afternoon. But they compound: a container running as a non-root user with a read-only filesystem, dropped capabilities, resource limits, and no-new-privileges is an incredibly hostile environment for an attacker.
Do not wait for your own 2 AM message. Start with the first three practices this week — non-root user, read-only filesystem, and drop capabilities — and work your way down the list.
Your future self will thank you.
Have questions or want to share your own container security setup? Drop a comment below. I read every one.
Top comments (0)