Docker Security Best Practices — Production Checklist (2026)
A complete Docker security checklist for production. Cover image hardening, runtime security, secrets management, network isolation, and scanning — with real examples.
Docker makes it easy to ship software — but default Docker configurations are not production-safe. Containers running as root, secrets baked into images, exposed Docker sockets: these are real vulnerabilities that get exploited in the wild.
This guide is a production-ready Docker security checklist with working examples for every item.
The Docker Security Threat Model
Before hardening, understand what you're protecting against:
Attack Surface Common Vulnerabilities
─────────────────────────────────────────────────────────────────
Container images Outdated OS packages, CVEs, embedded secrets
Running containers Root processes, over-privileged capabilities
Host-container boundary Docker socket exposure, privileged mode
Networking Container-to-container lateral movement
Secrets / env vars Plaintext secrets in image layers or envs
Supply chain Untrusted base images, compromised registries
─────────────────────────────────────────────────────────────────
1. Never Run Containers as Root
By default, containers run as root (UID 0). A container escape by a root process gives the attacker root on the host.
Bad (Default)
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Runs as root — dangerousGood (Non-Root User)
FROM node:18-alpine
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy and install as root, then switch
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
# Drop privileges before running
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]Enforce at Runtime (Docker)
# Run with specific UID even if Dockerfile doesn't specify
docker run --user 1001:1001 myapp:latest
# Verify the running user
docker exec <container_id> whoami
docker exec <container_id> idEnforce in Kubernetes
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
containers:
- name: myapp
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true # Prevent writes to filesystem
capabilities:
drop:
- ALL # Drop all Linux capabilities
add:
- NET_BIND_SERVICE # Only add what's needed2. Use Minimal, Distroless Base Images
Every package in your image is a potential vulnerability. Alpine Linux is 5MB. Distroless images are even smaller and contain only the runtime — no shell, no package manager, no attack surface.
Size and Attack Surface Comparison
Base Image Size Shell Package Mgr CVEs (typical)
──────────────────────────────────────────────────────────────────────
ubuntu:22.04 77MB bash apt 50–100+
node:18 1.1GB bash apt + npm 100+
node:18-slim 243MB bash apt 30–60
node:18-alpine 130MB sh apk 5–15
distroless/nodejs18 115MB None None 0–5
──────────────────────────────────────────────────────────────────────
Multi-Stage Build with Distroless
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production — distroless runtime only
FROM gcr.io/distroless/nodejs18-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# nonroot tag = runs as UID 65532 automatically
EXPOSE 3000
CMD ["dist/server.js"]# For Python
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM gcr.io/distroless/python3-debian12:nonroot
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /app /app
CMD ["/app/main.py"]3. Never Store Secrets in Images
Secrets baked into images are permanent — even if you delete them in a later layer, they're recoverable from the image history.
Bad — Secrets in Dockerfile
# NEVER DO THIS
ENV DB_PASSWORD=supersecret123
ENV API_KEY=sk-prod-key-abc123
RUN aws configure set aws_access_key_id AKIAIOSFODNN7EXAMPLE
RUN aws configure set aws_secret_access_key wJalrXUtnFEMI/K7MDENG# Anyone can extract these:
docker history myapp:latest --no-trunc
docker inspect myapp:latest | grep -i passwordGood — Runtime Secrets Injection
# Pass secrets at runtime via environment (minimal, for simple cases)
docker run \
-e DB_PASSWORD="$(aws ssm get-parameter --name /prod/db-password --query Parameter.Value --output text)" \
myapp:latest
# Better: use Docker secrets (Swarm) or Kubernetes SecretsDocker Secrets (Swarm)
# Create secret
echo "supersecret123" | docker secret create db_password -
# Use in service
docker service create \
--name myapp \
--secret db_password \
myapp:latest# Access in entrypoint script
#!/bin/sh
export DB_PASSWORD=$(cat /run/secrets/db_password)
exec "$@"Kubernetes Secrets (Mounted as Files)
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque
stringData:
DB_PASSWORD: "supersecret123" # Base64 encoded at rest
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: myapp
volumeMounts:
- name: secrets
mountPath: /run/secrets
readOnly: true
env:
# Mount as env (acceptable) or file (preferred)
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: DB_PASSWORD
volumes:
- name: secrets
secret:
secretName: db-credentialsBest Practice: External Secrets (HashiCorp Vault / AWS Secrets Manager)
# External Secrets Operator pulls secrets from Vault/AWS/GCP
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: db-credentials # Creates K8s Secret automatically
data:
- secretKey: DB_PASSWORD
remoteRef:
key: prod/myapp/db
property: password4. Scan Images for Vulnerabilities
Scan before shipping — don't let CVEs reach production.
Trivy (Free, Open-Source)
# Install Trivy
brew install aquasecurity/trivy/trivy
# Scan a local image
trivy image myapp:latest
# Scan with severity filter — fail CI on CRITICAL/HIGH
trivy image --severity CRITICAL,HIGH myapp:latest
echo "Exit code: $?"
# Scan in CI — output as SARIF for GitHub Security tab
trivy image \
--format sarif \
--output results.sarif \
myapp:latest
# Scan filesystem (not just image)
trivy fs --security-checks vuln,config .
# Scan Kubernetes manifests for misconfigurations
trivy config ./k8s/GitHub Actions CI Integration
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
trivy-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1' # Fail build on findings
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif5. Drop Linux Capabilities
Linux capabilities are fine-grained root permissions. By default, containers get too many.
# See what capabilities a container has
docker run --rm alpine sh -c "cat /proc/self/status | grep Cap"
# CapEff: 00000000a80425fb ← 13 capabilities by default# Kubernetes: drop ALL capabilities, add only what's needed
securityContext:
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE # Only if binding to port < 1024# Docker: drop capabilities at runtime
docker run \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
myapp:latest
# Or use --security-opt for seccomp profile
docker run \
--security-opt seccomp=default.json \
myapp:latestCommon Capabilities and When to Drop Them
Capability Default? Should Drop? Why
────────────────────────────────────────────────────────────────
NET_RAW Yes YES Allows raw socket crafting (ARP spoofing)
SYS_PTRACE No YES Allows process debugging/tracing
SYS_ADMIN No YES Near-root — allows mount, namespaces
CHOWN Yes Often YES Needed only for user management
NET_BIND_SERVICE Yes Only if >1024 Needed to bind ports < 1024
SETUID/SETGID Yes YES if no su Needed only for privilege escalation
────────────────────────────────────────────────────────────────
6. Use Read-Only Root Filesystem
Prevent attackers from writing malware to disk.
# Kubernetes
securityContext:
readOnlyRootFilesystem: true
# Allow writes only to specific paths via emptyDir volumes
volumeMounts:
- name: tmp
mountPath: /tmp
- name: app-cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir: {}
- name: app-cache
emptyDir: {}# Docker runtime
docker run --read-only \
--tmpfs /tmp \
--tmpfs /app/cache \
myapp:latest7. Secure the Docker Daemon
The Docker socket (/var/run/docker.sock) is the crown jewel — anyone with access can root the host.
Never Mount the Docker Socket in Containers
# EXTREMELY DANGEROUS — gives container full control of host Docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock # ← NEVER DO THIS
# If you need Docker-in-Docker, use alternatives:
# - Kaniko (builds images without Docker daemon)
# - Buildah (rootless image builder)
# - img (unprivileged image builder)Enable TLS for Remote Docker
# If you expose Docker daemon remotely, always use TLS
dockerd \
--tlsverify \
--tlscacert=ca.pem \
--tlscert=server-cert.pem \
--tlskey=server-key.pem \
--host=tcp://0.0.0.0:2376
# Client-side
docker --tlsverify \
--tlscacert=ca.pem \
--tlscert=cert.pem \
--tlskey=key.pem \
-H=tcp://my-docker-host:2376 infoDocker Daemon Security Flags
// /etc/docker/daemon.json
{
"icc": false, // Disable inter-container communication by default
"userns-remap": "default", // Enable user namespace remapping
"no-new-privileges": true, // Prevent privilege escalation
"live-restore": true, // Keep containers running during daemon restart
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}8. Implement Network Policies
By default, all pods can talk to all other pods. Network Policies create microsegmentation.
# Allow only frontend to talk to backend, deny everything else
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-policy
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend # Only frontend can reach backend
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: database # Backend can only reach database
ports:
- protocol: TCP
port: 5432
- to: [] # Allow DNS
ports:
- protocol: UDP
port: 539. Pin Image Tags and Use Digests
latest is a moving target. A supply chain attack could swap the image under you.
# Bad — unpinned
FROM node:18
FROM postgres:latest
# Better — tag pinned
FROM node:18.19.0-alpine3.19
FROM postgres:16.1-alpine3.19
# Best — digest pinned (immutable)
FROM node@sha256:a6394f7e4e4c8e4c6b6c9d7b2e8f9a3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9# Get the digest of an image
docker pull node:18-alpine
docker inspect node:18-alpine | jq '.[0].RepoDigests'
# Pull by digest
docker pull node:18-alpine@sha256:abc123...10. Scan IaC and Dockerfiles for Misconfigurations
Security issues often start at configuration time, not runtime.
# Trivy config scan — checks Dockerfiles, K8s manifests, Terraform
trivy config .
# Example findings:
# Dockerfile (dockerfile): DS002: Detected image with 'root' user
# Dockerfile (dockerfile): DS026: No HEALTHCHECK defined
# k8s/deployment.yaml: KSV001: Process running as root user
# k8s/deployment.yaml: KSV011: CPU not limited# Hadolint — Dockerfile linter
docker run --rm -i hadolint/hadolint < Dockerfile
# Common findings:
# DL3008: Pin versions in apt get install
# DL3013: Pin pip packages
# DL3020: Use COPY instead of ADD
# DL3025: Use exec form for CMDThe Docker Security Checklist
Docker Security Production Checklist
─────────────────────────────────────────────────────────
IMAGE BUILD
✅ Use minimal base image (Alpine / Distroless)
✅ Multi-stage builds — don't ship build tools
✅ No secrets in Dockerfile or image layers
✅ Pin base image with digest (not :latest)
✅ Non-root USER in Dockerfile
✅ HEALTHCHECK defined
✅ .dockerignore excludes .env, keys, secrets
BEFORE SHIP
✅ Trivy image scan — 0 CRITICAL, minimal HIGH
✅ Hadolint Dockerfile lint passes
✅ trivy config . passes on K8s manifests
✅ No hardcoded passwords in git history
RUNTIME (DOCKER)
✅ --user 1001 (or non-root)
✅ --read-only root filesystem
✅ --cap-drop ALL --cap-add only needed
✅ --no-new-privileges
✅ No --privileged flag
✅ Docker socket NOT mounted
RUNTIME (KUBERNETES)
✅ securityContext.runAsNonRoot: true
✅ securityContext.readOnlyRootFilesystem: true
✅ capabilities.drop: [ALL]
✅ allowPrivilegeEscalation: false
✅ Resource requests and limits set
✅ NetworkPolicy applied
✅ Secrets from Vault/AWS SM, not Kubernetes Secrets only
ONGOING
✅ Weekly Trivy scans on running images
✅ Automated CVE alerts (Dependabot, Snyk)
✅ Docker daemon configured securely
✅ Audit logs enabled
─────────────────────────────────────────────────────────
Quick Security Audit for Existing Containers
# Audit running containers for common issues
#!/bin/bash
echo "=== Running as root? ==="
docker ps -q | xargs -I{} docker inspect {} \
--format '{{.Name}}: User={{.Config.User}}'
echo "=== Privileged containers? ==="
docker ps -q | xargs -I{} docker inspect {} \
--format '{{.Name}}: Privileged={{.HostConfig.Privileged}}'
echo "=== Docker socket mounts? ==="
docker ps -q | xargs -I{} docker inspect {} \
--format '{{.Name}}: Mounts={{range .Mounts}}{{.Source}} {{end}}' \
| grep docker.sock
echo "=== Running with :latest tag? ==="
docker ps --format '{{.Names}}: {{.Image}}' | grep :latest
echo "=== No resource limits? ==="
docker ps -q | xargs -I{} docker inspect {} \
--format '{{.Name}}: CPU={{.HostConfig.NanoCpus}} Mem={{.HostConfig.Memory}}'Conclusion
Container security isn't optional in production. The good news: most of it is low-effort and high-impact.
Start with the fundamentals — non-root user, no secrets in images, Trivy scans in CI, read-only filesystem — and you'll be ahead of 80% of teams.
Then layer on runtime security (capabilities, seccomp) and network policies for defense-in-depth.
For the full Docker command reference, check our Docker Cheatsheet. And if you're preparing for interviews, our Docker Interview Questions covers all the security questions you'll encounter.
Also read our beginner-friendly Docker Guide if you're just getting started with containers.
Want to practice Docker security on a real cloud environment? DigitalOcean Droplets give you a full Linux VM to practice container hardening, network policies, and security scanning — starting at $6/month with $200 free credit for new users.
Stay ahead of the curve
Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam — just practical engineering content.
Related Articles
Docker Complete Beginners Guide — Everything You Need to Know
What is Docker, why engineers use it, and how to get started with containers from scratch. A practical, no-fluff guide.
Docker Compose Complete Guide 2026: From Zero to Production
Master Docker Compose in 2026. Learn how to write docker-compose.yml files, manage volumes, networks, environment variables, health checks, and run multi-container apps the right way.
Why Your Docker Container Keeps Restarting (and How to Fix It)
CrashLoopBackOff, OOMKilled, exit code 1, exit code 137 — Docker containers restart for specific, diagnosable reasons. Here is how to identify the exact cause and fix it in minutes.