Container Security Best Practices: Docker and Kubernetes
Containers introduce new attack surfaces. Learn Docker security essentials: minimal images, non-root users, read-only filesystems, image scanning, secrets management, and runtime protection.
Containers have become the default deployment unit for modern applications, but they come with their own security considerations. A misconfigured container can give attackers root access on the host — and most default Docker configurations are insecure.
This guide covers the essential security practices for containerized workloads.
The Container Security Threat Model
Containers share the host kernel. Unlike VMs (which have a hypervisor between them), a compromised container can escape to the host if:
- The container runs as root
- Dangerous capabilities are granted
- The container has access to host resources (socket, namespaces, /proc)
The attacker's path: exploit app vulnerability → code execution in container → container escape → root on host → lateral movement.
1. Use Minimal Base Images
The fewer packages in an image, the smaller the attack surface.
# ❌ Heavyweight — 900MB, hundreds of packages
FROM ubuntu:22.04
# ✅ Better — 30MB, minimal packages
FROM debian:bookworm-slim
# ✅ Best for production — no shell, no package manager
FROM gcr.io/distroless/nodejs22-debian12
Distroless images (Google's distroless project, Chainguard) contain only your application and its runtime dependencies — no shell, no package manager, no utilities. This dramatically limits what an attacker can do even if they get code execution.
# Multi-stage build with distroless final image
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs22-debian12 AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER nonroot
CMD ["dist/server.js"]
2. Run as Non-Root User
Docker containers run as root by default. If a container is compromised, the attacker has root inside the container — and if container escape is possible, root on the host.
# Create a non-root user
FROM node:22-alpine
WORKDIR /app
# Add a non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
COPY --chown=appuser:appgroup . .
RUN npm ci --only=production
# Switch to non-root
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
In Kubernetes, also set the security context:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
3. Read-Only Root Filesystem
Prevent attackers from modifying the container filesystem:
# In docker run:
docker run --read-only myimage
# But allow /tmp for temporary files
docker run --read-only --tmpfs /tmp myimage
In Kubernetes:
securityContext:
readOnlyRootFilesystem: true
If your app writes to disk, use explicit volume mounts rather than the root filesystem. This limits what an attacker can modify even with code execution.
4. Drop All Capabilities
Linux capabilities divide root privileges into granular units. Docker grants a large set by default. Drop all and add back only what's needed:
# Drop all capabilities
docker run --cap-drop ALL myimage
# Add back only what's needed (e.g., bind to port 80)
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myimage
Most web applications need zero capabilities. If you're running as a non-root user on a port > 1024, you need no capabilities at all.
# Kubernetes
securityContext:
capabilities:
drop: ["ALL"]
# add: ["NET_BIND_SERVICE"] # Only if binding port <1024
5. Secrets Management
Never bake secrets into images. They persist in image layers and can be extracted:
# ❌ Secret is in the image layer forever
RUN curl -H "Authorization: Bearer sk_live_abc123" https://api.example.com/setup
# ❌ ARG values are visible in docker history
ARG API_KEY
RUN ./setup.sh --key=$API_KEY
Correct approaches:
Runtime environment variables (from a secrets manager):
docker run -e API_KEY=$(aws secretsmanager get-secret-value --secret-id mykey --query SecretString --output text) myimage
Kubernetes Secrets (use External Secrets Operator to sync from AWS Secrets Manager, Vault, etc.):
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
API_KEY: "injected-by-operator"
---
spec:
containers:
- name: app
envFrom:
- secretRef:
name: app-secrets
Docker BuildKit secrets (for build-time secrets that don't land in layers):
# syntax=docker/dockerfile:1
FROM node:22-alpine
RUN --mount=type=secret,id=npm_token npm config set //registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)
6. Image Vulnerability Scanning
Scan images for known CVEs before deploying:
# Docker Scout (built into Docker Desktop)
docker scout cves myimage:latest
# Trivy (open source, fast)
trivy image myimage:latest
trivy image --severity CRITICAL,HIGH myimage:latest
# In CI/CD with exit code:
trivy image --exit-code 1 --severity CRITICAL myimage:latest
# Fails the build if critical CVEs found
Integrate scanning into your CI/CD pipeline so every push to main is scanned:
# GitHub Actions
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}'
format: 'sarif'
exit-code: '1'
severity: 'CRITICAL,HIGH'
7. Docker Daemon Security
Secure the Docker daemon itself:
# Never expose Docker socket to containers (enables full host escape)
# ❌ This gives root on the host
docker run -v /var/run/docker.sock:/var/run/docker.sock myimage
# Enable Docker Content Trust (image signing)
export DOCKER_CONTENT_TRUST=1
docker pull myimage # Verifies signature
# Use rootless Docker mode (daemon doesn't run as root)
# Install: https://docs.docker.com/engine/security/rootless/
8. Dockerfile Best Practices Summary
# 1. Pin specific versions (not :latest)
FROM node:22.3.0-alpine3.20
# 2. Multi-stage builds (minimize final image size)
FROM node:22-alpine AS builder
# ... build steps ...
FROM gcr.io/distroless/nodejs22-debian12 AS runtime
# Copy only what's needed
# 3. COPY specific files, not entire context
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/
# 4. Non-root user
USER nonroot:nonroot
# 5. No SUID/SGID bits
RUN find / -perm /6000 -type f -exec chmod a-s {} \; 2>/dev/null || true
# 6. Explicit port (documentation + tooling)
EXPOSE 3000
# 7. HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1
Checking Your Configuration
Run Docker Bench for Security against your Docker installation:
docker run --net host --pid host --userns host --cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /etc:/etc:ro \
-v /usr/bin/containerd:/usr/bin/containerd:ro \
-v /usr/bin/runc:/usr/bin/runc:ro \
-v /usr/lib/systemd:/usr/lib/systemd:ro \
-v /var/lib:/var/lib:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
--label docker_bench_security \
docker/docker-bench-security
This runs CIS Docker Benchmark checks and gives you a scored report of your configuration.