The base image you choose for your Docker container determines how many known vulnerabilities you ship to production. A standard node:20 image carries hundreds of packages — and hundreds of potential CVEs — that your application never uses. Hardened and distroless images strip everything unnecessary, leaving only what your app actually needs to run. This guide shows you how to migrate to hardened base images and integrate vulnerability scanning into your CI/CD pipeline.
The Problem with Default Base Images
Pull a standard node:20 image and scan it:
docker pull node:20
trivy image node:20 --severity HIGH,CRITICALYou'll typically find dozens to hundreds of HIGH and CRITICAL CVEs in the underlying Debian or Ubuntu layers — packages like curl, libc,openssl, and perl that your Node.js app never calls. Each one is a potential attack vector if an attacker reaches your container.
Understanding Your Options
Distroless Images (Google)
Google's distroless images contain only the application runtime and its direct dependencies — no shell, no package manager, no coreutils. You cannot docker exec into a distroless container and run bash (there is no bash).
# Available distroless images
gcr.io/distroless/base-debian12
gcr.io/distroless/static-debian12
gcr.io/distroless/nodejs20-debian12
gcr.io/distroless/java21-debian12
gcr.io/distroless/python3-debian12Alpine-Based Images
Alpine Linux is a minimal distribution (~5 MB) that uses musl libc instead of glibc. It includes a package manager (apk) and a shell, making it more practical than distroless for images that need build tools. Alpine images have significantly fewer CVEs than Debian/Ubuntu-based equivalents.
Docker Official "slim" Variants
Many official images offer -slim variants (e.g., node:20-slim,python:3.12-slim) that remove documentation, man pages, and non-essential packages while keeping the Debian base. A middle ground between full and distroless.
Chainguard Images
Chainguard produces hardened, minimal images with zero known CVEs at build time. They use Wolfi OS (a glibc-based Alpine alternative) and are updated daily. Free tier available for many common runtimes.
cgr.dev/chainguard/node:latest
cgr.dev/chainguard/python:latest
cgr.dev/chainguard/nginx:latestMulti-Stage Builds: The Key Pattern
The technique that makes distroless practical is the multi-stage build. Use a full image to build your application, then copy only the compiled output into a minimal runtime image:
Node.js Example
# Stage 1: Build
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime (distroless)
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER 1000
CMD ["dist/server.js"]Go Example (fully static, truly distroless)
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Stage 2: Runtime (scratch - no OS at all)
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1000:1000
ENTRYPOINT ["/server"]Go binaries compiled with CGO_ENABLED=0 are statically linked and can run in a FROM scratch image — the most minimal possible container.
Python Example
# Stage 1: Install dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/app/packages -r requirements.txt
# Stage 2: Runtime
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY --from=builder /app/packages ./packages
COPY . .
ENV PYTHONPATH=/app/packages
USER 1000
CMD ["app.py"]Integrating Vulnerability Scanning in CI/CD
GitHub Actions with Trivy
name: Container Security Scan
on:
push:
branches: [main]
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${GITHUB_SHA} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${GITHUB_SHA}
format: table
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: trueSetting exit-code: 1 causes the pipeline to fail if CRITICAL or HIGH CVEs are found. Use ignore-unfixed: true to skip CVEs that have no available fix yet — you can't patch what hasn't been patched upstream.
GitLab CI with Grype
container-scan:
image: anchore/grype:latest
script:
- grype myapp:latest --fail-on high
only:
- mainBefore and After: CVE Count Comparison
| Base Image | Size | Typical HIGH+CRITICAL CVEs |
|---|---|---|
| node:20 | ~1.1 GB | 50-150+ |
| node:20-slim | ~220 MB | 10-40 |
| node:20-alpine | ~60 MB | 2-10 |
| distroless/nodejs20 | ~100 MB | 0-5 |
| chainguard/node | ~80 MB | 0 (at build time) |
CVE counts vary by scan date and scanner — treat these as representative ranges, not guarantees. The trend is consistent: smaller, more minimal images have fewer vulnerabilities.
Debugging Distroless Containers
The tradeoff with distroless is debugging difficulty — no shell means nodocker exec ... /bin/bash. Use these approaches instead:
# Use debug variant of distroless (includes busybox shell)
FROM gcr.io/distroless/nodejs20-debian12:debug
# Or use kubectl debug / docker debug (Docker Desktop 4.27+)
docker debug <container_id>
# Or add a debug sidecar in development compose
services:
app:
image: myapp:distroless
debug:
image: busybox
volumes_from:
- appConclusion
Migrating to hardened or distroless base images is one of the highest-impact, lowest-effort security improvements you can make to your container workloads. Start with-slim or Alpine variants for quick wins, then migrate critical services to distroless or Chainguard images. Pair the migration with CI/CD vulnerability scanning to maintain visibility as new CVEs are disclosed. For broader container security, see the guide on Docker container hardening for VPS.