Ravindra BagaleCourses & study guides Track your progress

Guides

How to Fix Docker Image Vulnerabilities: Base Image, Multi-stage and Distroless

To fix Docker image vulnerabilities, first scan with Trivy. Then remove what you do not need: switch to a current, smaller base image (slim, Alpine or distroless), use a multi-stage build so compilers and build tools never reach the final image, upgrade the vulnerable libraries, run as a non-root user, and rebuild every week to pick up OS patches. Most findings disappear just by changing the base image.

Quick answer

  1. trivy image myapp:old and note the HIGH/CRITICAL total.
  2. Change FROM node:18 to a current slim or distroless base.
  3. Build in one stage, copy only the output into the runtime stage.
  4. Upgrade the libraries Trivy lists, using its "Fixed Version" column.
  5. Add USER with a non-root user.
  6. Rebuild, re-scan and compare.

A live example first

A small Node.js API was built a year ago like this:

FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
trivy image --severity HIGH,CRITICAL --ignore-unfixed orders-api:old
orders-api:old (debian 12)
Total: 64 (HIGH: 55, CRITICAL: 9)
Node.js (package-lock.json)
Total: 6 (HIGH: 5, CRITICAL: 1)

(Your numbers will differ; they change daily.)

We make three changes: a multi-stage build, a distroless runtime, and npm ci --omit=dev with upgraded packages (npm audit fix):

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /app /app
USER nonroot
CMD ["server.js"]
orders-api:new (debian 12)
Total: 2 (HIGH: 2, CRITICAL: 0)
Node.js (package-lock.json)
Total: 0

The image also shrank from about 1.1 GB to under 200 MB. Friends, we did not change one line of the app code.

Why do images have so many vulnerabilities?

Shrinking image risk A full base image with many CVEs is rebuilt with multi-stage builds onto a small runtime, cutting the attack surface. Shrinking image risk Full basemany CVEsMulti-stagebuild vs runSlim runtimefewer pkgsNon-rootUSER appRescanfewer CVEs you Fewer packages in the final image means fewer vulnerabilities to fix

Most CVEs come from the base image. Multi-stage builds and slim or distroless runtimes remove them.

node:18, python:3.12 and openjdk "full" tags are built on a complete Debian system. They include compilers, curl, git, image libraries and hundreds of other packages, so that any build works. Your running app needs almost none of them, but every one can carry CVEs. A scanner reports them all, because an attacker who gets in can use them.

How do I fix it step by step?

  1. Scan and save the totals as your "before".
  2. Fix the base image: use the newest supported version, in a slim, Alpine or distroless variant.
  3. Split the Dockerfile into a build stage and a runtime stage.
  4. Install only production dependencies in the runtime.
  5. Upgrade libraries to the "Fixed Version" Trivy shows.
  6. Add a non-root USER.
  7. Add a .dockerignore so .git, .env and test files never enter the image.
  8. Rebuild, scan, and compare with "before".
  9. Schedule a weekly rebuild in CI.

Dockerfiles for each language

Java / Spring Boot

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY . .
RUN mvn -q -DskipTests package

FROM gcr.io/distroless/java21-debian12
COPY --from=build /src/target/app.jar /app.jar
USER nonroot
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=70", "-jar", "/app.jar"]

Maven and the JDK stay in the build stage. Only the JRE and the jar ship.

Python (Flask / FastAPI)

FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim
COPY --from=build /install /usr/local
WORKDIR /app
COPY . .
RUN useradd -r -u 10001 app
USER app
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]

Go

FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /server .

FROM gcr.io/distroless/static-debian12
COPY --from=build /server /server
USER nonroot
ENTRYPOINT ["/server"]

A static Go binary on distroless/static often scans with zero OS findings.

.NET

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /out

FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled
COPY --from=build /out /app
WORKDIR /app
ENTRYPOINT ["dotnet", "Api.dll"]

Microsoft's "chiseled" Ubuntu images are its distroless-style option, and they run as non-root by default.

PHP (with nginx or Apache in front)

FROM composer:2 AS deps
WORKDIR /app
COPY composer.* ./
RUN composer install --no-dev --no-scripts

FROM php:8.3-fpm-alpine
COPY --from=deps /app/vendor /var/www/html/vendor
COPY . /var/www/html
USER www-data

nginx static site

Use nginx:1.27-alpine, or the nginxinc/nginx-unprivileged image, which listens on port 8080 as a non-root user.

Library vulnerabilities: upgrade, then lock

Stack Find Fix
Node.js npm audit npm audit fix, then commit package-​lock.​json
Python pip-audit bump the version in requirements.txt
Java Trivy / OWASP Dependency-Check bump in pom.xml / build.gradle
Go govulncheck ./... go get module@fixed
PHP composer audit composer update vendor/​package
Ruby bundle audit bundle update gem

Real incident: why build hygiene matters

In April 2021, Codecov disclosed that attackers had changed its Bash Uploader script. Its investigation said they got in using a credential that leaked from a mistake in Codecov's Docker image creation process. Thousands of customer CI pipelines then ran the altered script. The lessons for us: keep secrets out of images (use .dockerignore and build secrets), build from small, trusted bases, and scan every image before you publish it.

Mistakes to avoid

  1. COPY . . with no .dockerignore. .env, .git and SSH keys end up inside the image.
  2. Pinning an old tag forever. FROM python:3.8 stops getting fixes after its end-of-life date. Plan upgrades.
  3. Using latest. It changes without warning. Pin a version, and rebuild on purpose.

Summary table

Fix Typical effect
Newer base tag Removes already-patched OS CVEs
Slim / Alpine / distroless runtime Removes most unused packages
Multi-stage build Keeps compilers and build tools out
Library upgrades Fixes app-level CVEs (Log4Shell-type)
Non-root USER Limits damage if the app is broken into
Weekly rebuild + scan Keeps up with new CVEs

Quick quiz: check what you learned

  1. Where do most CVEs in a typical image come from?

    1. The packages in the base image
    2. The CMD line in the Dockerfile
    3. The container name you choose
    4. The Docker network driver used
    Show answer

    A. Full OS bases ship many unused packages, each with its own CVEs.

  2. What does a multi-stage build let you do?

    1. Ship only the built output
    2. Run two apps in one container
    3. Skip the image scan entirely
    4. Use root and non-root at once
    Show answer

    A. Build tools stay in the build stage; only the result goes into the runtime.

  3. What is missing from a distroless image?

    1. A shell and package manager
    2. The language runtime itself
    3. Your application binary
    4. The image config metadata
    Show answer

    A. Distroless keeps only the app and runtime, so there is less to attack.

  4. Why rebuild images every week?

    1. To pick up new base patches
    2. To change the image name tag
    3. To reset the Docker daemon
    4. To clear the registry cache
    Show answer

    A. Base images are patched often; rebuilding brings those fixes into your image.

Learn it properly

This guide is the short path. These pages go deeper, with labs:

Frequently asked questions

Why does my image have hundreds of vulnerabilities?

Most come from the base image: a full Debian or Ubuntu image ships many packages your app never uses, and every one can have CVEs.

Is Alpine always better?

It is small and usually has fewer CVEs, but it uses musl instead of glibc, which can break some Python wheels and native libraries. Test it first.

What is a distroless image?

An image with only your app and its runtime, without a shell or package manager. Google publishes distroless images for Java, Node.js, Python and static binaries.

Does apt-get upgrade in the Dockerfile help?

It can fix OS CVEs today, but rebuilding from a newer base image tag is cleaner and repeatable.

How often should I rebuild images?

At least weekly, and immediately when a critical CVE affects your base image or libraries.

Why run as non-root?

If an attacker breaks into the app, a non-root user limits what they can do inside the container and on the host.