Hands-on lab
Secure Docker Images with Trivy
Build a deliberately weak image, scan it with Trivy, and rebuild it until the gate for fixable HIGH and CRITICAL findings passes: current base, non-root user, patched packages, no pip in the runtime image.
Before you start
- Docker Desktop or Docker Engine
- Trivy installed (brew install trivy) or the aquasec/trivy image
- jq
- About 1.5 GB free: the vulnerability database is large on first download
Interactive environment
Practice this lab in a temporary browser-based environment. Nothing needs to be installed on your machine.
Steps on this page
Goal
By the end of this lab you will have scanned an image built from an end-of-life base, watched the count of fixable HIGH and CRITICAL findings fall through three rebuilds, and understood why the last few findings are the hardest to remove. Every image here was built and scanned on 2026-09-13 with Trivy 0.68.2; the exact counts depend on the day’s vulnerability database and will be higher when you run it, which is part of the lesson.
Environment
mkdir -p ~/labs/trivy && cd ~/labs/trivy
trivy --version
trivy image --download-db-only # once; the database is several hundred MB
The application is a Python HTTP server that answers ok. It never changes; only the Dockerfile does.
cat > app.py <<'PY'
from http.server import HTTPServer, BaseHTTPRequestHandler
class H(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n")
HTTPServer(("0.0.0.0", 8080), H).serve_forever()
PY
Step 1: build the weak image
python:3.9 reached end of life in October 2025. The tag still resolves, still builds, and still runs as root.
FROM python:3.9-slim
COPY app.py /app.py
CMD ["python", "/app.py"]
docker build --pull -t lab/app:v1 .
docker run --rm lab/app:v1 id
uid=0(root) gid=0(root) groups=0(root)
Step 2: scan it, then count what matters
trivy image --severity HIGH,CRITICAL --ignore-unfixed --quiet lab/app:v1
Report Summary
┌────────────────────────────────────────────────────────────────────────┬────────────┬─────────────────┬─────────┐
│ Target │ Type │ Vulnerabilities │ Secrets │
├────────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ lab/app:v1 (debian 13.1) │ debian │ 67 │ - │
├────────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ usr/local/lib/python3.9/site-packages/pip-23.0.1.dist-info/METADATA │ python-pkg │ 0 │ - │
│ … │ │ │ │
└────────────────────────────────────────────────────────────────────────┴────────────┴─────────────────┴─────────┘
lab/app:v1 (debian 13.1)
Total: 67 (HIGH: 61, CRITICAL: 6)
Six criticals, all of them in packages the application never calls (openssl and perl-base), and all of them
with a fixed version in Debian. Get the number you will track as a single integer:
trivy image --severity HIGH,CRITICAL --ignore-unfixed --quiet --format json lab/app:v1 \
| jq '[.Results[].Vulnerabilities // [] | length] | add // 0'
70
Sixty-seven in Debian packages plus three in the Python packages the base image ships. That is the baseline.
Step 3: current base, non-root user
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 app
USER app
WORKDIR /home/app
COPY --chown=app:app app.py .
EXPOSE 8080
CMD ["python", "app.py"]
docker build --pull -t lab/app:v2 .
docker run --rm lab/app:v2 id
trivy image --severity HIGH,CRITICAL --ignore-unfixed --quiet --format json lab/app:v2 \
| jq '[.Results[].Vulnerabilities // [] | length] | add // 0'
uid=10001(app) gid=10001(app) groups=10001(app)
14
Seventy to fourteen by changing one line of the FROM and adding a user. The remaining fourteen are twelve Debian
packages (gzip, libpcre2-8-0, libsqlite3-0, perl-base) that received security updates after this tag of the
base image was published, plus two Python packages. The gate still fails:
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --quiet lab/app:v2 > /dev/null; echo "exit=$?"
exit=1
Step 4: patch the OS, and look at what is left
FROM python:3.13-slim
RUN apt-get update \
&& apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --uid 10001 app
USER app
WORKDIR /home/app
COPY --chown=app:app app.py .
EXPOSE 8080
CMD ["python", "app.py"]
docker build -t lab/app:v3 .
trivy image --severity HIGH,CRITICAL --ignore-unfixed --quiet lab/app:v3
lab/app:v3 (debian 13.7)
Total: 0 (HIGH: 0, CRITICAL: 0)
Python (python-pkg)
Total: 2 (HIGH: 2, CRITICAL: 0)
┌────────────┬─────────────────────┬──────────┬────────┬───────────────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │
├────────────┼─────────────────────┼──────────┼────────┼───────────────────┼───────────────┤
│ msgpack │ GHSA-6v7p-g79w-8964 │ HIGH │ fixed │ 1.1.2 │ 1.2.1 │
│ setuptools │ CVE-2025-47273 │ HIGH │ fixed │ 70.3.0 │ 78.1.1 │
└────────────┴─────────────────────┴──────────┴────────┴───────────────────┴───────────────┘
The Debian side is clean (the point release moved from 13.6 to 13.7 in the process). The two Python findings are
the interesting part. Neither package is installed in site-packages in those versions; they are the copies that
pip vendors inside itself, which Trivy reads from pip/_vendor/vendor.txt. Upgrading pip, setuptools and
msgpack does not change them, because pip 26.2.1 still ships those internal copies. They are reachable only when
pip itself runs.
Step 5: remove what the runtime does not need
A runtime image has no reason to contain pip. Uninstall it (and the bundled wheels ensurepip would use to
reinstall it) and the findings go with it:
FROM python:3.13-slim
RUN apt-get update \
&& apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/* \
&& python -m pip uninstall -y pip \
&& rm -rf /usr/local/lib/python3.13/ensurepip
RUN useradd --create-home --uid 10001 app
USER app
WORKDIR /home/app
COPY --chown=app:app app.py .
EXPOSE 8080
CMD ["python", "app.py"]
docker build -t lab/app:v4 .
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --quiet lab/app:v4 > /dev/null; echo "exit=$?"
docker run --rm -d --name v4 -p 127.0.0.1:18080:8080 lab/app:v4 && sleep 1 && curl -s http://127.0.0.1:18080/; docker rm -f v4
exit=0
ok
Zero fixable HIGH or CRITICAL findings, the process runs as uid=10001, and the application still answers. For a
real application, do the pip install of your dependencies in a builder stage and copy only site-packages into a
runtime stage that never had pip; that is the multi-stage pattern in
Building Minimal Container Images with Multi-Stage Builds,
and it gets the same result without the uninstall dance.
Step 6: compare
for tag in v1 v2 v3 v4; do
printf '%s: ' "$tag"
trivy image --quiet --severity HIGH,CRITICAL --ignore-unfixed --format json lab/app:$tag \
| jq '[.Results[].Vulnerabilities // [] | length] | add // 0'
done
docker image ls lab/app --format '{{.Tag}} {{.Size}}'
v1: 70
v2: 14
v3: 2
v4: 0
v4 197MB
v3 193MB
v2 151MB
v1 147MB
Note the sizes. The clean image is the largest, because apt-get upgrade adds a layer on top of the base rather
than replacing it, and Python 3.13 is bigger than 3.9. Size and vulnerability count are different goals; a
distroless or -slim multi-stage build addresses both, but the scan result is the one that gates a deploy.
Verification
-
lab/app:v1reports 70 fixable HIGH/CRITICAL findings, including 6 CRITICAL, and runs as root -
lab/app:v2reports 14 and runs asuid=10001 -
lab/app:v3reports 0 in Debian packages and exactly 2 inpip’s vendored packages -
trivy image --exit-code 1 … lab/app:v4exits0and the container answersok
Cleanup
docker image rm lab/app:v1 lab/app:v2 lab/app:v3 lab/app:v4
rm -rf ~/labs/trivy
Troubleshooting
| Problem | Fix |
|---|---|
failed to download vulnerability DB or a timeout | The DB is large; run trivy image --download-db-only --timeout 30m once, or point --db-repository at a mirror |
TOOMANYREQUESTS from the registry | Retry after a minute; in CI, cache TRIVY_CACHE_DIR between jobs |
| Counts differ from the ones above | Expected: the database changes daily and base image tags are rebuilt. Compare your v1 with your v4 |
permission denied on the Docker socket | Add your user to the docker group, or run Trivy via docker run with the socket mounted |
jq: command not found | brew install jq / apt-get install jq |