Learn how to secure Docker containers in production with proven techniques for image hardening, runtime protection, and vulnerability management.
Docker containers power millions of applications, but with great flexibility comes great responsibility. After securing containerized environments processing sensitive data, I've compiled the essential security practices every DevOps team needs to implement.
The Container Security Landscape
Container security isn't just about the container itself—it's about the entire lifecycle:
- Base image selection and maintenance
- Build-time security scanning
- Runtime protection and monitoring
- Network segmentation
- Secrets management
- Compliance and auditing
Secure Base Images
Choose Minimal Base Images
# Instead of this
FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3
# Use this
FROM python:3.11-slim-bullseye
# Or even better, use distroless
FROM gcr.io/distroless/python3-debian11
Scan for Vulnerabilities
# Using Trivy for scanning
trivy image --severity HIGH,CRITICAL myapp:latest
# Integrate into CI/CD
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:latest'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
Dockerfile Security Best Practices
Multi-Stage Builds for Smaller Attack Surface
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Production stage
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --chown=nonroot:nonroot . .
USER nonroot
EXPOSE 3000
CMD ["server.js"]
Never Run as Root
# Create a non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Switch to non-root user
USER appuser
# Or use numeric UID for better security
USER 1001:1001
Secure Secret Handling
# BAD: Never hardcode secrets
ENV API_KEY=sk_live_abc123def456
# GOOD: Use build arguments for build-time secrets
ARG BUILD_TOKEN
RUN --mount=type=secret,id=build_token \
BUILD_TOKEN=$(cat /run/secrets/build_token) && \
npm install --registry https://private-registry.com
# BETTER: Use runtime secret injection
# Secrets are provided at runtime via environment or mounted files
Runtime Security
Security Options and Capabilities
# docker-compose.yml with security options
version: '3.8'
services:
app:
image: myapp:latest
security_opt:
- no-new-privileges:true
- seccomp:seccomp-profile.json
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
read_only: true
tmpfs:
- /tmp
- /var/run
AppArmor and SELinux Profiles
# Load AppArmor profile
sudo apparmor_parser -r -W /etc/apparmor.d/docker-nginx
# Run container with AppArmor profile
docker run --security-opt apparmor=docker-nginx nginx
# SELinux context
docker run --security-opt label=level:s0:c100,c200 myapp
Resource Limits
# Prevent resource exhaustion attacks
services:
app:
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
reservations:
cpus: '0.25'
memory: 128M
pids_limit: 100
ulimits:
nofile:
soft: 65535
hard: 65535
Network Security
Network Segmentation
# Create custom networks for isolation
docker network create --driver bridge frontend
docker network create --driver bridge backend
docker network create --driver bridge data
# Run containers in appropriate networks
docker run -d --name web --network frontend nginx
docker run -d --name api --network frontend --network backend api-server
docker run -d --name db --network data postgres
Network Policies
# Kubernetes NetworkPolicy example
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-netpol
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: web
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: database
ports:
- protocol: TCP
port: 5432
Secrets Management
Using Docker Secrets
# Create secrets
echo "mypassword" | docker secret create db_password -
# Use in stack deployment
version: '3.8'
services:
db:
image: postgres
secrets:
- db_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
external: true
Integration with External Secret Stores
# HashiCorp Vault integration
- name: Fetch secrets from Vault
uri:
url: "{{ vault_addr }}/v1/secret/data/myapp"
method: GET
headers:
X-Vault-Token: "{{ vault_token }}"
register: vault_secrets
- name: Run container with secrets
docker_container:
name: myapp
image: myapp:latest
env:
DATABASE_URL: "{{ vault_secrets.json.data.data.database_url }}"
Image Signing and Verification
Docker Content Trust
# Enable content trust
export DOCKER_CONTENT_TRUST=1
# Sign images
docker trust sign myregistry/myapp:latest
# Verify signatures
docker trust inspect --pretty myregistry/myapp:latest
Cosign for Container Signing
# Generate keys
cosign generate-key-pair
# Sign container image
cosign sign --key cosign.key myregistry/myapp:latest
# Verify signature
cosign verify --key cosign.pub myregistry/myapp:latest
Compliance and Auditing
CIS Docker Benchmark
# Run Docker Bench Security
docker run --rm --net host --pid host --userns host --cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /var/lib:/var/lib:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /etc:/etc:ro \
docker/docker-bench-security
Audit Logging
// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3",
"labels": "environment,version",
"env": "ENVIRONMENT,VERSION"
},
"experimental": true,
"metrics-addr": "127.0.0.1:9323",
"log-level": "info"
}
Runtime Protection with Falco
Falco Rules for Container Security
# falco_rules.yaml
- rule: Terminal shell in container
desc: Detect shell spawned in container
condition: >
container.id != host and
proc.name in (bash, sh, zsh) and
spawned_process and
proc.pname exists and
not proc.pname in (sh, bash)
output: >
Shell spawned in container (user=%user.name container=%container.name
shell=%proc.name parent=%proc.pname cmdline=%proc.cmdline)
priority: WARNING
- rule: Write below root
desc: Detect write below root
condition: >
container.id != host and
fd.name startswith / and
(evt.type = write or evt.type = open_write) and
not fd.name startswith /tmp and
not fd.name startswith /var
output: >
File write below root directory (user=%user.name command=%proc.cmdline file=%fd.name)
priority: ERROR
Vulnerability Management Pipeline
Automated Scanning in CI/CD
# GitLab CI example
stages:
- build
- scan
- deploy
build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
container_scanning:
stage: scan
image: registry.gitlab.com/security-products/analyzers/klar:latest
variables:
CLAIR_DB_IMAGE: "arminc/clair-db:latest"
CLAIR_DB_IMAGE_TAG: "latest"
script:
- /analyzer run
artifacts:
reports:
container_scanning: gl-container-scanning-report.json
dependencies: []
only:
- branches
Admission Controllers
# OPA (Open Policy Agent) policy
package docker.security
deny[msg] {
input.request.kind.kind == "Pod"
input.request.object.spec.containers[_].image
not starts_with(input.request.object.spec.containers[_].image, "myregistry.com/")
msg := "Images must be from approved registry"
}
deny[msg] {
input.request.kind.kind == "Pod"
input.request.object.spec.containers[_].securityContext.runAsUser == 0
msg := "Containers cannot run as root"
}
Incident Response
Container Forensics
# Capture running container state
docker commit suspicious-container investigation-image
docker save investigation-image > evidence.tar
# Analyze with tools
docker run --rm -v $(pwd):/data:ro \
aquasec/trivy image --input /data/evidence.tar
# Extract file system for analysis
docker export suspicious-container | tar -tv > filesystem-listing.txt
Kill Chain Prevention
- Initial Access: Use signed images only
- Execution: Restrict capabilities and syscalls
- Persistence: Read-only root filesystem
- Privilege Escalation: No new privileges flag
- Defense Evasion: Runtime monitoring with Falco
- Lateral Movement: Network segmentation
- Exfiltration: Egress filtering
Security Checklist
- Use minimal base images (distroless/alpine)
- Never run containers as root
- Implement multi-stage builds
- Scan images for vulnerabilities
- Sign and verify images
- Drop unnecessary capabilities
- Use read-only root filesystem
- Implement resource limits
- Enable security options (AppArmor/SELinux)
- Segregate container networks
- Manage secrets properly
- Monitor runtime behavior
- Implement admission controls
- Regular security audits
- Incident response plan
Conclusion
Container security is not a one-time configuration but an ongoing practice. Start with the basics—non-root users, minimal images, and vulnerability scanning—then progressively add layers of security as your infrastructure matures.
Remember: the goal is defense in depth. No single security measure is perfect, but combining multiple layers creates a robust security posture that can protect against most threats. Regular audits, continuous monitoring, and staying updated with the latest security practices are key to maintaining secure containerized environments.
Share this article
David Childs
Consulting Systems Engineer with over 10 years of experience building scalable infrastructure and helping organizations optimize their technology stack.