Skip to content
Back to Blog
Linux & Server10 min read

Docker Hardened and Distroless Images: Reduce CVE Exposure in Production

The base image you choose determines how many known vulnerabilities you ship. This guide explains distroless, Alpine, slim, and Chainguard images, shows multi-stage build patterns for Node.js, Go, and Python, and walks through integrating CVE scanning into your CI/CD pipeline.

Written by Abdul AbrorTechnical Hosting Support Engineer
On this page

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,CRITICAL

You'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-debian12

Alpine-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:latest

Multi-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: true

Setting 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:
    - main

Before and After: CVE Count Comparison

Base ImageSizeTypical HIGH+CRITICAL CVEs
node:20~1.1 GB50-150+
node:20-slim~220 MB10-40
node:20-alpine~60 MB2-10
distroless/nodejs20~100 MB0-5
chainguard/node~80 MB0 (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:
      - app

Conclusion

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.

FAQ

Should I use distroless in production and a full image in development?

That's a reasonable approach. Use multi-stage builds with a build target that includes development tools, and a production target that uses distroless. Build-time arguments can switch between targets: docker build --target dev .vs docker build --target production .

Do hardened images affect application performance?

No measurable runtime performance difference. The application binary runs the same regardless of what OS utilities are (not) present. Alpine's musl libc can occasionally cause compatibility issues with native modules that expect glibc — test before deploying.

How often should I rebuild images to get updated base images?

At minimum weekly, ideally daily for production workloads. Schedule automated rebuilds in your CI/CD pipeline — even if your code hasn't changed, the base image may have received security patches. Tools like Dependabot or Renovate can automate base image update PRs.