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
trivy image myapp:oldand note the HIGH/CRITICAL total.- Change
FROM node:18to a current slim or distroless base. - Build in one stage, copy only the output into the runtime stage.
- Upgrade the libraries Trivy lists, using its "Fixed Version" column.
- Add
USERwith a non-root user. - 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?
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?
- Scan and save the totals as your "before".
- Fix the base image: use the newest supported version, in a slim, Alpine or distroless variant.
- Split the Dockerfile into a build stage and a runtime stage.
- Install only production dependencies in the runtime.
- Upgrade libraries to the "Fixed Version" Trivy shows.
- Add a non-root
USER. - Add a
.dockerignoreso.git,.envand test files never enter the image. - Rebuild, scan, and compare with "before".
- 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
COPY . .with no.dockerignore..env,.gitand SSH keys end up inside the image.- Pinning an old tag forever.
FROM python:3.8stops getting fixes after its end-of-life date. Plan upgrades. - 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
Where do most CVEs in a typical image come from?
- The packages in the base image
- The CMD line in the Dockerfile
- The container name you choose
- The Docker network driver used
Show answer
A. Full OS bases ship many unused packages, each with its own CVEs.
What does a multi-stage build let you do?
- Ship only the built output
- Run two apps in one container
- Skip the image scan entirely
- Use root and non-root at once
Show answer
A. Build tools stay in the build stage; only the result goes into the runtime.
What is missing from a distroless image?
- A shell and package manager
- The language runtime itself
- Your application binary
- The image config metadata
Show answer
A. Distroless keeps only the app and runtime, so there is less to attack.
Why rebuild images every week?
- To pick up new base patches
- To change the image name tag
- To reset the Docker daemon
- 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.