trivy image --severity CRITICAL,HIGH myapp:latest to scan images for vulnerabilities before deployment.--privileged -- the single most dangerous misconfiguration that grants full host access on container escape.--privileged flag -- this grants ALL Linux kernel capabilities and device access:latest tag in production -- pin to specific version or SHA256 digest for reproducibility/var/run/docker.sock) to containers -- this grants unrestricted root access to the hostUSER directive in Dockerfile| # | 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 |
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
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.
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.
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.
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.
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.
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.
# 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 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
#!/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
# 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"]
# BAD -- no USER directive means container runs as root
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# 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"]
# 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
# 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
# BAD -- unpinned tag and ADD instead of COPY
FROM python:latest
ADD https://example.com/setup.sh /app/
ADD . /app
# 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 . .
# BAD -- --privileged grants ALL capabilities, socket gives root host access
docker run --privileged \
-v /var/run/docker.sock:/var/run/docker.sock \
myapp:latest
# 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
--mount=type=secret which never writes secrets to layers. [src4]-slim Debian variants. [src6].dockerignore, docker build copies .git, .env, node_modules, and other sensitive directories. Fix: Create .dockerignore excluding .git, .env, *.pem, node_modules. [src4]HEALTHCHECK CMD curl localhost:3000/health requires installing curl, increasing attack surface. Fix: Use wget (pre-installed in Alpine) or a custom binary. [src6]npm install invalidates the dependency cache on every code change. Fix: Copy package*.json first, run npm ci, then copy source. [src6]--tmpfs for ephemeral data and named volumes for persistent data. [src1]docker compose build copies the .env file into the build context by default. Fix: Add .env to .dockerignore and use secrets in compose. [src4]userns-remap in /etc/docker/daemon.json. [src2]# 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
| 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 |
| 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 |