Sachin Chaurasiya

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.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Time budget
~30 minutes
Difficulty
beginner

Reviewed Tested with Trivy 0.68.2, Docker 29, Vulnerability DB 2026-09-13

Related path: Container Security

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

Tools Docker Trivy

Interactive environment

Practice this lab in a temporary browser-based environment. Nothing needs to be installed on your machine.

Launch on Killercoda(opens in a new tab)

Opens in a new tab · Temporary environment

Practice environment hosted on Killercoda.

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:v1 reports 70 fixable HIGH/CRITICAL findings, including 6 CRITICAL, and runs as root
  • lab/app:v2 reports 14 and runs as uid=10001
  • lab/app:v3 reports 0 in Debian packages and exactly 2 in pip’s vendored packages
  • trivy image --exit-code 1 … lab/app:v4 exits 0 and the container answers ok

Cleanup

docker image rm lab/app:v1 lab/app:v2 lab/app:v3 lab/app:v4
rm -rf ~/labs/trivy

Troubleshooting

ProblemFix
failed to download vulnerability DB or a timeoutThe DB is large; run trivy image --download-db-only --timeout 30m once, or point --db-repository at a mirror
TOOMANYREQUESTS from the registryRetry after a minute; in CI, cache TRIVY_CACHE_DIR between jobs
Counts differ from the ones aboveExpected: the database changes daily and base image tags are rebuilt. Compare your v1 with your v4
permission denied on the Docker socketAdd your user to the docker group, or run Trivy via docker run with the socket mounted
jq: command not foundbrew install jq / apt-get install jq