Container Security: Docker Security Checklist
What is the container security checklist (Docker)?
TL;DR
- Bottom line: Secure Docker containers require layered defenses across image build (minimal base, non-root user, no secrets), runtime (drop capabilities, read-only filesystem, resource limits), and operations (vulnerability scanning, image signing, network segmentation).
- Key tool/command:
trivy image --severity CRITICAL,HIGH myapp:latestto scan images for vulnerabilities before deployment. - Watch out for: Running containers as root with
--privileged-- the single most dangerous misconfiguration that grants full host access on container escape. - Works with: Docker Engine 20.10+, Docker Compose v2+, Docker Desktop, BuildKit, CIS Docker Benchmark v1.8.0.
Constraints
- NEVER run containers with
--privilegedflag -- this grants ALL Linux kernel capabilities and device access - NEVER store secrets (passwords, API keys, tokens) in Dockerfiles, environment variables, or image layers
- NEVER use the
:latesttag in production -- pin to specific version or SHA256 digest for reproducibility - NEVER expose the Docker daemon socket (
/var/run/docker.sock) to containers -- this grants unrestricted root access to the host - ALWAYS run containers as a non-root user via the
USERdirective in Dockerfile - ALWAYS scan images for vulnerabilities before deployment -- block builds with critical CVEs in CI/CD
Quick Reference
| # | Security Control | Risk Level | Implementation | Verification |
|---|---|---|---|---|
| 1 | Use minimal base image | High | FROM node:20-alpine or distroless/scratch | docker image ls -- check size < 100MB |
| 2 | Run as non-root user | Critical | USER appuser in Dockerfile | docker exec <ctr> whoami -- should NOT be root |
| 3 | Drop all capabilities | High | --cap-drop=ALL --cap-add=NET_BIND_SERVICE | docker inspect --format '{{.HostConfig.CapDrop}}' |
| 4 | Read-only filesystem | Medium | --read-only --tmpfs /tmp | docker exec <ctr> touch /test -- should fail |
| 5 | No secrets in image | Critical | Use BuildKit --mount=type=secret or Docker Secrets | docker history myimage -- no sensitive data in layers |
| 6 | Pin image versions | High | FROM node:20.11.1-alpine3.19 or @sha256:... | Verify FROM lines in Dockerfile |
| 7 | Scan for vulnerabilities | Critical | trivy image myapp:latest in CI/CD pipeline | Exit code 1 on CRITICAL/HIGH findings |
| 8 | Limit resources | Medium | --memory=512m --cpus=1.0 --pids-limit=100 | docker stats -- check limits enforced |
| 9 | Use custom networks | Medium | docker network create --internal mynet | docker network inspect mynet |
| 10 | Sign images | High | cosign sign --key cosign.key myimage:1.0 | cosign verify myimage:1.0 |
| 11 | Prevent privilege escalation | High | --security-opt=no-new-privileges | docker inspect --format '{{.HostConfig.SecurityOpt}}' |
| 12 | Enable rootless mode | Medium | dockerd-rootless-setuptools.sh install | docker info -- check "rootless" |
| 13 | Use COPY not ADD | Medium | COPY . /app instead of ADD . /app | Lint with hadolint Dockerfile |
| 14 | Keep host and Docker updated | Critical | Regular patch cycle, auto-updates for Docker Engine | docker version -- check latest stable |
Decision Tree
START: What phase of the container lifecycle are you securing?
├── BUILD TIME (Dockerfile)?
│ ├── Using a full OS base image (ubuntu, debian)?
│ │ ├── YES → Switch to alpine, distroless, or scratch
│ │ └── NO → Verify image is pinned to specific version
│ ├── Running as root (no USER directive)?
│ │ ├── YES → Add non-root user and USER directive
│ │ └── NO → Verify user has minimal permissions
│ ├── Secrets in Dockerfile (ENV, COPY .env)?
│ │ ├── YES → Use BuildKit --mount=type=secret or multi-stage builds
│ │ └── NO → Verify with docker history
│ └── Using ADD instruction?
│ ├── YES → Replace with COPY unless extracting tar archives
│ └── NO → Good
├── CI/CD PIPELINE?
│ ├── Image scanning enabled?
│ │ ├── YES → Verify it blocks on CRITICAL/HIGH CVEs
│ │ └── NO → Add Trivy or Snyk scan step
│ └── Image signing enabled?
│ ├── YES → Verify with cosign verify
│ └── NO → Add Cosign or Docker Content Trust
├── RUNTIME (docker run / compose)?
│ ├── Using --privileged?
│ │ ├── YES → REMOVE immediately. Use --cap-add for specific capabilities
│ │ └── NO → Add --cap-drop=ALL
│ ├── Filesystem writable?
│ │ ├── YES → Add --read-only --tmpfs /tmp
│ │ └── NO → Good
│ ├── Resource limits set?
│ │ ├── YES → Verify memory, CPU, PID limits
│ │ └── NO → Add --memory, --cpus, --pids-limit
│ └── Docker socket mounted?
│ ├── YES → REMOVE unless absolutely required
│ └── NO → Good
└── DEFAULT → Apply all controls; start with non-root user and image scanning
Step-by-Step Guide
1. Use minimal base images
Smaller images have fewer packages, fewer vulnerabilities, and a smaller attack surface. Alpine Linux images are ~5MB versus ~120MB for Debian. [src4]
# Minimal Alpine-based image
FROM node:20.11.1-alpine3.19
# Distroless for compiled languages
FROM gcr.io/distroless/static-debian12:nonroot
# Scratch for statically-compiled binaries (Go, Rust)
FROM scratch
COPY --from=builder /app/binary /binary
ENTRYPOINT ["/binary"]
Verify: docker image ls myapp -- image size should be under 100MB.
2. Create and use a non-root user
Running as root inside a container means a container escape gives the attacker root access on the host. Always create a dedicated user. [src1]
FROM node:20.11.1-alpine3.19
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm ci --omit=dev
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Verify: docker exec <container> whoami -- should output appuser, NOT root.
3. Handle secrets securely with BuildKit
Never embed secrets in Dockerfiles or image layers. Use BuildKit secret mounts which are not persisted in the final image. [src4]
# syntax=docker/dockerfile:1
FROM node:20.11.1-alpine3.19
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci --omit=dev
COPY . .
USER appuser
CMD ["node", "server.js"]
Build with: DOCKER_BUILDKIT=1 docker build --secret id=npm_token,src=.npmrc -t myapp .
Verify: docker history myapp -- no secret values should appear in any layer.
4. Scan images for vulnerabilities in CI/CD
Integrate vulnerability scanning as a mandatory gate in your CI/CD pipeline. Trivy is the most widely adopted open-source scanner. [src5]
# Scan image and fail on CRITICAL or HIGH severity
trivy image --severity CRITICAL,HIGH --exit-code 1 myapp:latest
# Generate SBOM (Software Bill of Materials)
trivy image --format spdx-json --output sbom.json myapp:latest
# Scan Dockerfile for misconfigurations
trivy config --severity HIGH,CRITICAL Dockerfile
Verify: CI pipeline fails (exit code 1) when critical vulnerabilities are found.
5. Sign and verify images
Image signing ensures images have not been tampered with between build and deployment. Cosign (Sigstore) is the modern standard. [src4]
# Generate a key pair
cosign generate-key-pair
# Sign an image after build
cosign sign --key cosign.key myregistry.com/myapp:1.0
# Verify before deployment
cosign verify --key cosign.pub myregistry.com/myapp:1.0
Verify: cosign verify --key cosign.pub myregistry.com/myapp:1.0 returns signature details.
6. Harden runtime configuration
Apply defense-in-depth at runtime: drop capabilities, read-only filesystem, resource limits, and prevent privilege escalation. [src1]
docker run -d \
--name myapp \
--user appuser \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
--memory 512m \
--cpus 1.0 \
--pids-limit 100 \
myapp:1.0
Verify: docker inspect myapp --format '{{json .HostConfig}}' -- confirm CapDrop, ReadonlyRootfs, Memory, SecurityOpt are set.
Code Examples
Dockerfile: Production-Ready Secure Multi-Stage Build
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM node:20.11.1-alpine3.19 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production (minimal image)
FROM node:20.11.1-alpine3.19 AS production
RUN apk update && apk upgrade --no-cache
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
RUN apk del --purge apk-tools
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
docker-compose.yml: Security-Hardened Service Configuration
# docker-compose.yml -- security-hardened configuration
version: "3.9"
services:
api:
image: myapp:1.0.0
user: "1001:1001"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
pids: 100
networks:
- backend
secrets:
- db_password
db:
image: postgres:16.2-alpine
user: "999:999"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid
- /run/postgresql:rw
volumes:
- db-data:/var/lib/postgresql/data:rw
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
networks:
- backend
secrets:
- db_password
networks:
backend:
driver: bridge
internal: true
secrets:
db_password:
file: ./secrets/db_password.txt
volumes:
db-data:
driver: local
Python: Trivy Scan Wrapper for CI/CD
#!/usr/bin/env python3
"""Container security scan wrapper for CI/CD pipelines."""
import subprocess, json, sys
def scan_image(image_ref, severity="CRITICAL,HIGH"):
result = subprocess.run(
["trivy", "image", "--format", "json", "--severity", severity, image_ref],
capture_output=True, text=True)
return json.loads(result.stdout) if result.stdout else {}
def check_results(report, max_critical=0, max_high=5):
critical = sum(1 for r in report.get("Results", [])
for v in r.get("Vulnerabilities", [])
if v.get("Severity") == "CRITICAL")
high = sum(1 for r in report.get("Results", [])
for v in r.get("Vulnerabilities", [])
if v.get("Severity") == "HIGH")
print(f"CRITICAL: {critical}/{max_critical}, HIGH: {high}/{max_high}")
return critical <= max_critical and high <= max_high
Go: Secure Dockerfile for Go Applications
# Stage 1: Build
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-w -s" -o /app/server ./cmd/server
# Stage 2: Minimal production image
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/server"]
Anti-Patterns
Wrong: Running containers as root
# BAD -- no USER directive means container runs as root
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
Correct: Non-root user with minimal permissions
# GOOD -- dedicated non-root user
FROM node:20.11.1-alpine3.19
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app . .
RUN npm ci --omit=dev
USER app
CMD ["node", "server.js"]
Wrong: Storing secrets in Dockerfile
# BAD -- secret is cached in image layer forever
FROM node:20-alpine
ENV DATABASE_URL=postgres://admin:s3cret@db:5432/mydb
COPY .env /app/.env
Correct: BuildKit secret mounts
# GOOD -- secret is never stored in image layers
# syntax=docker/dockerfile:1
FROM node:20.11.1-alpine3.19
RUN --mount=type=secret,id=db_url \
export DATABASE_URL=$(cat /run/secrets/db_url) && \
npm run migrate
Wrong: Using :latest tag and ADD instruction
# BAD -- unpinned tag and ADD instead of COPY
FROM python:latest
ADD https://example.com/setup.sh /app/
ADD . /app
Correct: Pinned version and COPY instruction
# GOOD -- pinned version and explicit COPY
FROM python:3.12.2-slim-bookworm
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
Wrong: Running with --privileged and exposed socket
# BAD -- --privileged grants ALL capabilities, socket gives root host access
docker run --privileged \
-v /var/run/docker.sock:/var/run/docker.sock \
myapp:latest
Correct: Minimal capabilities and no socket
# GOOD -- drop all caps, add only what's needed, no socket
docker run -d \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
--read-only \
--tmpfs /tmp \
myapp:1.0.0
Common Pitfalls
- Build cache leaks secrets: Multi-stage builds don't guarantee secret removal if secrets are copied in early stages. Fix: Use BuildKit
--mount=type=secretwhich never writes secrets to layers. [src4] - Alpine musl vs glibc incompatibility: Applications compiled on glibc-based systems may fail on Alpine (musl libc). Fix: Compile on Alpine or use
-slimDebian variants. [src6] - Ignoring .dockerignore: Without
.dockerignore,docker buildcopies.git,.env,node_modules, and other sensitive directories. Fix: Create.dockerignoreexcluding.git,.env,*.pem,node_modules. [src4] - HEALTHCHECK installs extra packages:
HEALTHCHECK CMD curl localhost:3000/healthrequires installing curl, increasing attack surface. Fix: Usewget(pre-installed in Alpine) or a custom binary. [src6] - Layer ordering breaks cache: Copying all source files before
npm installinvalidates the dependency cache on every code change. Fix: Copypackage*.jsonfirst, runnpm ci, then copy source. [src6] - Read-only filesystem breaks applications: Some apps need writable directories for logs or PID files. Fix: Use
--tmpfsfor ephemeral data and named volumes for persistent data. [src1] - Docker Compose .env file in build context:
docker compose buildcopies the.envfile into the build context by default. Fix: Add.envto.dockerignoreand usesecretsin compose. [src4] - User namespace remapping disabled by default: Docker's user namespace maps container root to host non-root, but is not enabled by default. Fix: Configure
userns-remapin/etc/docker/daemon.json. [src2]
Diagnostic Commands
# Check if container runs as root
docker exec <container> whoami
docker exec <container> id
# Inspect security configuration of running container
docker inspect <container> --format '{{json .HostConfig.SecurityOpt}}'
docker inspect <container> --format '{{json .HostConfig.CapDrop}}'
docker inspect <container> --format '{{json .HostConfig.ReadonlyRootfs}}'
# Scan image for vulnerabilities with Trivy
trivy image --severity CRITICAL,HIGH myapp:latest
# Scan Dockerfile for misconfigurations
hadolint Dockerfile
# Check image layers for secret leaks
docker history --no-trunc myapp:latest
# Run CIS Docker Benchmark audit
docker run --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 --label docker_bench_security \
docker/docker-bench-security
# Check Docker daemon security options
docker info --format '{{json .SecurityOptions}}'
# Verify image signature
cosign verify --key cosign.pub myregistry.com/myapp:1.0
Version History & Compatibility
| Component | Version | Status | Key Security Feature |
|---|---|---|---|
| Docker Engine | 28.x | Current | BuildKit default, rootless improvements |
| Docker Engine | 27.x | Supported | Init containers, compose v2 built-in |
| Docker Engine | 25.x | EOL | Leaky Vessels fixes (CVE-2024-21626) |
| CIS Benchmark | v1.8.0 | Current | 27 updated recommendations for Docker 28 |
| Trivy | 0.58.x | Current | VEX support, SBOM generation, policy engine |
| Cosign | 2.4.x | Current | Keyless signing via OIDC, Rekor transparency log |
| BuildKit | 0.17.x | Current | Secret mounts, SSH forwarding, cache export |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Deploying any Docker container to production | Running trusted code in development only | Basic Docker Compose defaults suffice |
| Building CI/CD pipelines that create images | Using VM-based isolation (no containers) | VM hardening guides |
| Operating multi-tenant container environments | Running Kubernetes (need K8s-specific controls) | Kubernetes security + Pod Security Standards |
| Storing or processing sensitive data in containers | Using serverless functions (no container management) | Cloud provider serverless security docs |
Important Caveats
- Container isolation is NOT equivalent to VM isolation -- containers share the host kernel, so kernel exploits (e.g., Leaky Vessels CVE-2024-21626) can lead to host compromise
- Rootless mode introduces performance overhead and some incompatibilities with specific network drivers and storage backends
- Alpine Linux uses musl libc instead of glibc, which can cause subtle runtime issues with some applications -- test thoroughly before switching base images
- Docker Content Trust (DCT/Notary v1) is being superseded by Sigstore/Cosign for image signing -- prefer Cosign for new implementations
- The CIS Docker Benchmark is a guideline, not a compliance standard -- adapt recommendations to your threat model and operational requirements
- Seccomp and AppArmor profiles vary by host OS distribution -- default profiles provide baseline protection but may need customization for specific applications