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
- Minimal base images
- Non-root users
- Multi-stage builds
- Read-only filesystems and dropped capabilities
- Secret management — what never goes in a Dockerfile
- Scanning images in CI
- Signing images with Cosign
- 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 /appFor 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:nonrootEnforce non-root in Kubernetes
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001Some 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 neededIn 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 # OptionalMost 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=abc123Even 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" myappDocker secrets (Swarm):
services:
app:
image: myapp
secrets:
- db_password
secrets:
db_password:
external: trueKubernetes Secrets:
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-urlExternal 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 (recommended)
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 ./DockerfileIn 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.sarifSARIF 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:latestIntegrate 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:latestIn 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 signing8. 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 \
myappIn Kubernetes:
securityContext:
seccompProfile:
type: RuntimeDefaultAppArmor
On Debian/Ubuntu nodes, Docker automatically applies the docker-default AppArmor profile. For custom profiles:
docker run --security-opt apparmor=docker-default myappDon'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
--privilegedcontainers -
HEALTHCHECKdefined
What PatchVex checks
VulnPilot includes Dockerfile linting that catches common security misconfigurations before they reach CI:
$ 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.