Container Security: Docker Security Checklist

Type: Software Reference Confidence: 0.94 Sources: 7 Verified: 2026-02-27 Freshness: 2026-02-27

TL;DR

Constraints

Quick Reference

#Security ControlRisk LevelImplementationVerification
1Use minimal base imageHighFROM node:20-alpine or distroless/scratchdocker image ls -- check size < 100MB
2Run as non-root userCriticalUSER appuser in Dockerfiledocker exec <ctr> whoami -- should NOT be root
3Drop all capabilitiesHigh--cap-drop=ALL --cap-add=NET_BIND_SERVICEdocker inspect --format '{{.HostConfig.CapDrop}}'
4Read-only filesystemMedium--read-only --tmpfs /tmpdocker exec <ctr> touch /test -- should fail
5No secrets in imageCriticalUse BuildKit --mount=type=secret or Docker Secretsdocker history myimage -- no sensitive data in layers
6Pin image versionsHighFROM node:20.11.1-alpine3.19 or @sha256:...Verify FROM lines in Dockerfile
7Scan for vulnerabilitiesCriticaltrivy image myapp:latest in CI/CD pipelineExit code 1 on CRITICAL/HIGH findings
8Limit resourcesMedium--memory=512m --cpus=1.0 --pids-limit=100docker stats -- check limits enforced
9Use custom networksMediumdocker network create --internal mynetdocker network inspect mynet
10Sign imagesHighcosign sign --key cosign.key myimage:1.0cosign verify myimage:1.0
11Prevent privilege escalationHigh--security-opt=no-new-privilegesdocker inspect --format '{{.HostConfig.SecurityOpt}}'
12Enable rootless modeMediumdockerd-rootless-setuptools.sh installdocker info -- check "rootless"
13Use COPY not ADDMediumCOPY . /app instead of ADD . /appLint with hadolint Dockerfile
14Keep host and Docker updatedCriticalRegular patch cycle, auto-updates for Docker Enginedocker 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

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

ComponentVersionStatusKey Security Feature
Docker Engine28.xCurrentBuildKit default, rootless improvements
Docker Engine27.xSupportedInit containers, compose v2 built-in
Docker Engine25.xEOLLeaky Vessels fixes (CVE-2024-21626)
CIS Benchmarkv1.8.0Current27 updated recommendations for Docker 28
Trivy0.58.xCurrentVEX support, SBOM generation, policy engine
Cosign2.4.xCurrentKeyless signing via OIDC, Rekor transparency log
BuildKit0.17.xCurrentSecret mounts, SSH forwarding, cache export

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Deploying any Docker container to productionRunning trusted code in development onlyBasic Docker Compose defaults suffice
Building CI/CD pipelines that create imagesUsing VM-based isolation (no containers)VM hardening guides
Operating multi-tenant container environmentsRunning Kubernetes (need K8s-specific controls)Kubernetes security + Pod Security Standards
Storing or processing sensitive data in containersUsing serverless functions (no container management)Cloud provider serverless security docs

Important Caveats

Related Units