Security GuidesGuide

Secure Docker

A default Dockerfile is a security problem waiting to happen: running as root, pulling a bloated base image with hundreds of packages you don't need, and baking in secrets that end up in image layers. This guide fixes all of that.

What we're covering

  1. Minimal base images
  2. Non-root users
  3. Multi-stage builds
  4. Read-only filesystems and dropped capabilities
  5. Secret management — what never goes in a Dockerfile
  6. Scanning images in CI
  7. Signing images with Cosign
  8. Runtime security with seccomp and AppArmor

1. Minimal base images

The more software in your base image, the larger your attack surface. A vulnerability in a package you never use can still compromise your container.

For Node.js apps

# Bad — full Debian, hundreds of packages
FROM node:20
 
# Better — Debian slim, fewer packages
FROM node:20-slim
 
# Best — Alpine, ~5MB, minimal packages
FROM node:20-alpine
 
# Best for security (no shell, no package manager, just your app + runtime)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
 
FROM gcr.io/distroless/nodejs20-debian12
COPY --from=builder /app /app
WORKDIR /app
CMD ["/app/server.js"]

Distroless images contain only the runtime and your application — no shell, no package manager. If an attacker gets code execution, they have nothing to pivot with.

For Python apps

# Builder stage — has pip and build tools
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
 
# Runtime stage — minimal
FROM python:3.12-slim
COPY --from=builder /install /usr/local
COPY . /app
WORKDIR /app

For Go apps

FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o server .
 
# Scratch — literally empty, no OS at all
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
CMD ["/server"]

Go binaries are statically compiled — you can ship to scratch and get the smallest possible attack surface.

Alpine is a good default for most languages. Distroless is better for languages with stable runtimes (Node, Python, Java). Scratch is only practical for statically compiled languages (Go, Rust).


2. Non-root users

Containers run as root by default. If an attacker breaks out of your application, they're root inside the container — and container escapes happen.

FROM node:20-alpine
 
WORKDIR /app
 
COPY package*.json ./
RUN npm ci --omit=dev
 
COPY . .
 
# Create a system user with no login shell and no home directory
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 --ingroup nodejs --no-create-home appuser
 
# Change ownership of app files
RUN chown -R appuser:nodejs /app
 
# Switch to non-root user before CMD
USER appuser
 
EXPOSE 3000
CMD ["node", "server.js"]

For distroless images, use the nonroot variant:

FROM gcr.io/distroless/nodejs20-debian12:nonroot

Enforce non-root in Kubernetes

securityContext:
  runAsNonRoot: true
  runAsUser: 1001
  runAsGroup: 1001

Some base images (like the official node image) use UID 1000 for their built-in node user. Check with docker run --rm node:20-alpine id node before creating your own user — you can reuse the existing one instead.


3. Multi-stage builds

Multi-stage builds separate build-time dependencies from the runtime image. Your final image doesn't need compilers, test frameworks, or dev tools.

# Stage 1: Install all dependencies and build
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
 
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
# Stage 2: Production runtime — only what's needed
FROM node:20-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
 
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 --ingroup nodejs --no-create-home nextjs
 
# Copy only the built output and production dependencies
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
 
EXPOSE 3000
ENV PORT=3000
 
CMD ["node", "server.js"]

Key benefit: if your builder stage installs gcc, Python build tools, or other large packages to compile native modules, none of that reaches the final image.


4. Read-only filesystems and dropped capabilities

Read-only root filesystem

If your app doesn't need to write to disk, make the filesystem read-only:

# In docker-compose.yml
services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp          # Allow writes to /tmp only
      - /var/run      # For PID files if needed

In Kubernetes:

securityContext:
  readOnlyRootFilesystem: true
 
volumeMounts:
  - name: tmp
    mountPath: /tmp
 
volumes:
  - name: tmp
    emptyDir: {}

Drop Linux capabilities

Containers get a default set of Linux capabilities. Drop all of them, then add back only what you need:

# docker-compose.yml
services:
  app:
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # Only if you need to bind to ports < 1024
# Kubernetes
securityContext:
  capabilities:
    drop:
      - ALL
    add:
      - NET_BIND_SERVICE  # Optional

Most web applications need zero Linux capabilities. If you're running on ports >= 1024, you don't even need NET_BIND_SERVICE.


5. Secret management

This is where most teams get it wrong. Secrets must never appear in Dockerfile instructions or image layers.

What never goes in a Dockerfile

# Never do any of these
ENV DATABASE_URL=postgres://user:password@host/db
RUN curl -H "Authorization: Bearer sk-..." https://api.example.com
ARG API_KEY=abc123

Even ARG values end up in the image history:

$ docker history myimage
IMAGE          CREATED BY
abc123def456   /bin/sh -c #(nop)  ARG API_KEY=abc123  # Visible!

BuildKit secret mounts

For secrets needed only at build time (private npm registry tokens, private PyPI indexes):

# syntax=docker/dockerfile:1
FROM node:20-alpine
 
# Secret is mounted temporarily — never stored in image layers
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm install
# Build with the secret
DOCKER_BUILDKIT=1 docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  -t myapp .

Runtime secrets

For secrets your app needs at runtime, never bake them into the image. Use:

Environment variables at runtime:

docker run -e DATABASE_URL="$DATABASE_URL" myapp

Docker secrets (Swarm):

services:
  app:
    image: myapp
    secrets:
      - db_password
 
secrets:
  db_password:
    external: true

Kubernetes Secrets:

env:
  - name: DATABASE_URL
    valueFrom:
      secretKeyRef:
        name: app-secrets
        key: database-url

External secret managers (production best practice):

  • AWS Secrets Manager / Parameter Store
  • GCP Secret Manager
  • HashiCorp Vault
  • Doppler, Infisical

Kubernetes Secrets are base64-encoded, not encrypted, by default. Enable envelope encryption at rest and use a secrets management tool or ESO (External Secrets Operator) to pull secrets from a proper vault into your cluster.


6. Scanning images in CI

Scan images before they reach production. Two good free options:

Trivy scans OS packages, language dependencies, and configuration:

# Install
brew install trivy  # macOS
# or
docker pull aquasec/trivy
 
# Scan a local image
trivy image myapp:latest
 
# Fail CI if high or critical vulnerabilities found
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
 
# Scan a Dockerfile for misconfigurations
trivy config ./Dockerfile

In GitHub Actions:

- name: Build image
  run: docker build -t myapp:${{ github.sha }} .
 
- name: Scan image with Trivy
  uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # v0.28.0
  with:
    image-ref: myapp:${{ github.sha }}
    format: sarif
    output: trivy-results.sarif
    exit-code: '1'
    severity: 'HIGH,CRITICAL'
 
- name: Upload Trivy SARIF results
  uses: github/codeql-action/upload-sarif@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
  if: always()
  with:
    sarif_file: trivy-results.sarif

SARIF upload sends results to GitHub's Security tab — no third-party dashboard needed.

Grype

brew install grype
 
# Scan
grype myapp:latest
 
# Fail on critical
grype --fail-on critical myapp:latest

Integrate scanning at two points: build time (before pushing to registry) and registry scanning (continuous monitoring for newly discovered CVEs in already-deployed images). AWS ECR, GCP Artifact Registry, and GitHub Container Registry all offer built-in registry scanning.


7. Signing images with Cosign

Image signing lets you verify that the image you're pulling is the one your CI actually built — and hasn't been tampered with.

# Install cosign
brew install cosign
 
# Generate a key pair
cosign generate-key-pair
 
# Sign an image (after pushing)
cosign sign --key cosign.key ghcr.io/your-org/your-app:latest
 
# Verify before running
cosign verify --key cosign.pub ghcr.io/your-org/your-app:latest

In GitHub Actions with keyless signing (OIDC-based, no key management):

- name: Sign the container image
  uses: sigstore/cosign-installer@d7d6bc7cc5097e6cf3a89dbf4ba24c9b64b5c73e # v3.8.0
 
- name: Sign image
  run: |
    cosign sign --yes \
      --rekor-url https://rekor.sigstore.dev \
      ghcr.io/${{ github.repository }}:${{ github.sha }}
  env:
    COSIGN_EXPERIMENTAL: 1  # Keyless signing

8. Runtime security

seccomp profiles

seccomp restricts which system calls a container can make. Docker's default profile blocks ~44 syscalls. For tighter security, use a custom profile or runtime/default:

docker run \
  --security-opt seccomp=/path/to/custom-profile.json \
  myapp

In Kubernetes:

securityContext:
  seccompProfile:
    type: RuntimeDefault

AppArmor

On Debian/Ubuntu nodes, Docker automatically applies the docker-default AppArmor profile. For custom profiles:

docker run --security-opt apparmor=docker-default myapp

Don't run privileged containers

# Bad
docker run --privileged myapp
 
# Kubernetes
securityContext:
  privileged: false  # default is false, but be explicit
  allowPrivilegeEscalation: false

--privileged gives the container essentially full host access. There is almost never a legitimate reason for application containers to run privileged.


Hardened Dockerfile template

# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
 
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM node:20-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
 
# Non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 --ingroup nodejs --no-create-home appuser
 
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
 
USER appuser
 
# No secrets in ENV
# No COPY of sensitive files
 
EXPOSE 3000
 
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1
 
CMD ["node", "dist/server.js"]

Checklist

  • Using minimal base image (alpine, slim, or distroless)
  • Multi-stage build — build tools not in final image
  • Running as non-root user (UID 1001+)
  • No secrets in Dockerfile, ENV, or ARG instructions
  • Build-time secrets use --mount=type=secret
  • Runtime secrets from environment or external vault
  • Image scanning in CI (Trivy or Grype)
  • Capabilities dropped (cap_drop: ALL)
  • Read-only root filesystem where possible
  • No --privileged containers
  • HEALTHCHECK defined

What PatchVex checks

VulnPilot includes Dockerfile linting that catches common security misconfigurations before they reach CI:

Terminal

$ vulnpilot scan --dockerfile ./Dockerfile Scanning Dockerfile...

✗ Running as root — no USER instruction found (HIGH) ✗ Secrets in ENV: DATABASE_URL detected (CRITICAL) ✗ Base image node:20 — consider node:20-alpine or distroless (MEDIUM) ✓ Multi-stage build detected ✓ No COPY of .env files

3 issues found. Run vulnpilot explain dockerfile for remediation steps.