Docker Security Best Practices: Hardening Your Containers
A comprehensive guide to securing Docker containers, from image scanning to runtime protection and network isolation.
Containers revolutionized how we deploy software, but they also introduced an entirely new attack surface. A misconfigured Docker environment can expose sensitive data, allow container escapes, or grant attackers lateral movement across your infrastructure. This guide walks through the essential practices every team should adopt to harden Docker containers from build time to runtime.
Start With Minimal Base Images
The foundation of container security begins with your base image. Every additional package in your image is a potential vulnerability. Instead of pulling full OS images like ubuntu:latest, opt for minimal alternatives.
# Bad: full OS with hundreds of packages
FROM ubuntu:latest
# Good: minimal image with only what you need
FROM alpine:3.19
# Best: distroless for production workloads
FROM gcr.io/distroless/static-debian12:nonroot
Alpine Linux images weigh around 5MB compared to Ubuntu’s 75MB+. Google’s distroless images go even further — they contain only your application and its runtime dependencies, with no shell, no package manager, and no unnecessary binaries an attacker could exploit.
Scan Images for Vulnerabilities
Even minimal images can contain known CVEs. Integrate image scanning into your CI/CD pipeline to catch vulnerabilities before they reach production.
# Scan with Trivy (open source, fast, comprehensive)
trivy image --severity HIGH,CRITICAL myapp:latest
# Scan with Docker Scout (built into Docker Desktop)
docker scout cves myapp:latest
# Scan with Grype
grype myapp:latest
Make scanning a blocking step in your pipeline. If critical vulnerabilities are found, the build should fail. Automate this in your CI configuration and schedule periodic scans of images already in production — new CVEs are disclosed daily.
Run Containers as Non-Root
By default, processes inside a Docker container run as root. If an attacker compromises your application, they inherit root privileges inside the container — and potentially on the host if combined with a kernel exploit.
FROM node:20-alpine
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm ci --only=production
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Verify this at runtime as well by enforcing it in your Docker daemon or orchestrator configuration. Never allow --privileged containers in production — this flag disables nearly all security isolation.
Enforce Read-Only Filesystems
Most containers should not need to write to their root filesystem. Mounting it as read-only prevents attackers from dropping malware, modifying binaries, or tampering with application code.
# Run with a read-only root filesystem
docker run --read-only --tmpfs /tmp --tmpfs /var/run myapp:latest
If your application requires temporary write access, mount specific tmpfs volumes for those paths. In Docker Compose:
services:
webapp:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /var/run
security_opt:
- no-new-privileges:true
The no-new-privileges option prevents processes from gaining additional privileges via setuid or setgid binaries — another critical hardening step.
Set Resource Limits
Without resource constraints, a compromised or misbehaving container can consume all host resources, causing denial of service to other workloads. Always define CPU and memory limits.
services:
webapp:
image: myapp:latest
deploy:
resources:
limits:
cpus: "0.50"
memory: 256M
reservations:
cpus: "0.25"
memory: 128M
ulimits:
nofile:
soft: 1024
hard: 2048
nproc:
soft: 64
hard: 128
Setting ulimits for file descriptors and processes prevents fork bombs and resource exhaustion attacks. These limits act as a last line of defense when application-level controls fail.
Isolate Container Networks
By default, all containers on the same Docker bridge network can communicate with each other. This violates the principle of least privilege. Create dedicated networks and restrict inter-container traffic.
services:
frontend:
image: nginx:alpine
networks:
- frontend-net
api:
image: myapi:latest
networks:
- frontend-net
- backend-net
database:
image: postgres:16-alpine
networks:
- backend-net
networks:
frontend-net:
driver: bridge
backend-net:
driver: bridge
internal: true # No external access
The internal: true flag on backend-net ensures the database cannot reach the internet directly. Only the API service, which sits on both networks, can communicate with the database. This segmentation limits blast radius — if the frontend is compromised, the attacker cannot reach the database directly.
Disable Inter-Container Communication
For stricter isolation, disable ICC on the Docker daemon level:
{
"icc": false,
"default-address-pools": [
{"base": "172.20.0.0/16", "size": 24}
]
}
With ICC disabled, containers can only communicate through explicitly published ports or linked services.
Manage Secrets Properly
Hardcoding secrets in Dockerfiles or environment variables is a common and dangerous anti-pattern. Environment variables are visible via docker inspect, and build arguments are stored in image layers.
# docker-compose.yml — use Docker secrets
services:
api:
image: myapi:latest
secrets:
- db_password
- api_key
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
external: true # Managed by Docker Swarm or external vault
In your application, read secrets from /run/secrets/ rather than from environment variables. For production environments, integrate with a dedicated secrets manager like HashiCorp Vault, AWS Secrets Manager, or SOPS for encrypted secret files in your repository.
Multi-Stage Builds for Secret Hygiene
If you need credentials during build time (e.g., private package registries), use multi-stage builds to ensure secrets never end up in the final image:
# Stage 1: Build with credentials
FROM node:20-alpine AS builder
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
# Stage 2: Clean production image
FROM node:20-alpine
COPY --from=builder /app/node_modules ./node_modules
COPY . .
USER appuser
CMD ["node", "server.js"]
The --mount=type=secret BuildKit feature injects the secret only during that RUN step without writing it to any image layer.
Audit With Docker Bench for Security
Docker Bench for Security is an open-source script that checks your Docker deployment against the CIS Docker Benchmark. It audits host configuration, daemon settings, container runtime parameters, and image security.
# Run Docker Bench for Security
docker run --rm --net host --pid host \
--userns host --cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /etc:/etc:ro \
-v /var/lib:/var/lib:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
docker/docker-bench-security
The tool produces a scored report with PASS, WARN, and FAIL results across categories like host configuration, Docker daemon settings, container images, and runtime. Run it regularly — ideally automated in your CI pipeline — and address all WARN and FAIL findings.
Putting It All Together
Security is not a single checkbox but a set of layered defenses. Here is a concise hardening checklist for every Docker deployment:
- Build time: Use minimal or distroless base images, scan for CVEs, use multi-stage builds, never embed secrets in layers.
- Configuration: Run as non-root, enable read-only filesystems, set
no-new-privileges, define resource limits. - Networking: Create isolated bridge networks, use
internalfor backend services, disable ICC where possible. - Secrets: Mount via Docker secrets or external vaults, never use plain environment variables.
- Auditing: Run Docker Bench for Security regularly, monitor container logs, and enable Docker Content Trust for image signing.
No container environment is secure by default. Each layer you add makes exploitation harder, more costly, and more likely to be detected. Start with the basics — non-root users and image scanning — then progressively adopt the full stack of hardening measures outlined here. Your containers will thank you.