You pull a base image like node:18 or python:3.11, build your application on top of it, push it to a registry, and ship it. It works, so you move on. What you usually do not see is that the base image you trusted may carry dozens of known security vulnerabilities in its system libraries, some of them rated critical, all of them sitting in production waiting to be exploited.
This is one of the quietest risks in container-based deployments. The application code gets reviewed, but the operating system packages baked into the image rarely get a second look. A single outdated openssl or glibc package can undermine an otherwise careful deployment.
This tutorial shows you how to fix that gap using Trivy, a fast and widely used open source security scanner. You will install Trivy on Ubuntu, scan local and remote images, understand how to read the output, filter results so they are actionable instead of overwhelming, scan your own Dockerfiles for misconfigurations, detect accidentally committed secrets, and finally plug scanning into a CI pipeline so vulnerable builds get blocked automatically.
This guide is for developers, sysadmins, and DevOps engineers who already build or run containers and want a practical way to keep them secure.
What Trivy Actually Does
Trivy is a scanner made by Aqua Security. You point it at a target, and it reports security problems it finds. The most common target is a container image, but Trivy can also scan filesystems, git repositories, and infrastructure-as-code files.
For container images, Trivy does several things at once:
Vulnerability scanning. Trivy inspects every layer of an image and builds a list of installed packages. This includes OS packages (from apt, apk, yum, and so on) and language dependencies (npm, pip, Go modules, and more). It then matches that list against public vulnerability databases like the National Vulnerability Database. Each match is reported with a CVE identifier and a severity rating.
Misconfiguration scanning. Trivy reads Dockerfiles, Kubernetes manifests, and Terraform files, then flags insecure patterns such as running as root or missing health checks.
Secret scanning. Trivy looks for things that should never be in an image or repository, like AWS access keys, private keys, and hardcoded passwords.
A few terms worth defining before we continue:
- CVE stands for Common Vulnerabilities and Exposures. It is a unique ID assigned to a publicly known security flaw, for example
CVE-2023-38545. - Severity is how serious a vulnerability is, usually one of
LOW,MEDIUM,HIGH, orCRITICAL. - Fixed version is the package version where the flaw was patched. If a vulnerability has no fixed version yet, there is nothing to upgrade to, and you have to decide whether to accept the risk or change approach.
The important idea is that Trivy does not guess. It compares known facts (what is installed) against known data (published vulnerabilities), so its findings are concrete and verifiable.
Prerequisites
- Ubuntu 20.04, 22.04, or 24.04
- A user with
sudoprivileges - Docker installed, if you want to scan locally built images. If you do not have it yet, the Docker and Docker Compose getting started guide walks through the full setup.
- Basic comfort with the Linux command line
Trivy itself does not require Docker. It can scan images directly from a registry. But most of the time you will be scanning images you built locally, so having Docker available makes the examples more realistic.
Step 1: Install Trivy
The cleanest way to install Trivy on Ubuntu is through Aqua Security’s official APT repository. This means you get updates through the normal apt upgrade flow.
Add the repository and its signing key:
sudo apt install -y wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
Now update the package index and install:
sudo apt update
sudo apt install -y trivy
Verify the installation:
trivy --version
You should see output similar to this:
Version: 0.58.1
Vulnerability DB:
Version: 2
UpdatedAt: 2026-06-03 06:11:33 ...
The vulnerability database section may be empty on first run. That is fine, Trivy downloads it automatically the first time you scan.
Step 2: Run Your First Image Scan
Let us scan a real image. We will use an older Node.js image on purpose, because newer images are kept relatively clean and an old one gives us something to look at.
trivy image node:18.17.0-slim
The first run takes a moment because Trivy downloads its vulnerability database (around 40 to 60 MB). After that, scans are fast because the database is cached locally.
The output is grouped by target. A trimmed example looks like this:
node:18.17.0-slim (debian 12.1)
Total: 142 (UNKNOWN: 0, LOW: 78, MEDIUM: 41, HIGH: 19, CRITICAL: 4)
┌──────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┤
│ libssl3 │ CVE-2023-5678 │ MEDIUM │ fixed │ 3.0.9-1 │ 3.0.11-1~deb12│
│ libc6 │ CVE-2023-4911 │ HIGH │ fixed │ 2.36-9 │ 2.36-9+deb12u3│
└──────────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┘
Read this from the top. The summary line tells you the total count and the breakdown by severity. The table then lists each affected package, the CVE, how serious it is, whether a fix exists, what you currently have, and what version you need to upgrade to.
The single most useful column here is Fixed Version. If it has a value, the problem is solvable by upgrading. If it says (no fix) or is empty, the maintainers have not released a patch yet.
Step 3: Filter to What Matters
A scan that returns 142 findings is not actionable. Nobody is going to chase 78 low-severity issues. The first thing to do on any real project is narrow the output to the things you actually care about.
Show only high and critical findings:
trivy image --severity HIGH,CRITICAL node:18.17.0-slim
Now show only the ones that have a fix available, since those are the ones you can act on immediately:
trivy image --severity HIGH,CRITICAL --ignore-unfixed node:18.17.0-slim
The --ignore-unfixed flag is one of the most practical options in Trivy. It hides vulnerabilities that have no patch available, leaving you with a list of things you can fix right now by bumping package versions or switching to a newer base image.
In real workflows, these two flags together form the baseline scan. They turn an intimidating wall of text into a short, fixable to-do list.
Step 4: Scan an Image You Built
Scanning public images is useful, but the real value comes from scanning your own work. Let us build a small image and scan it.
Create a project directory:
mkdir ~/trivy-demo && cd ~/trivy-demo
Create a simple Dockerfile:
FROM node:18.17.0-slim
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Create a minimal package.json:
{
"name": "trivy-demo",
"version": "1.0.0",
"dependencies": {
"express": "4.17.1"
}
}
Build the image with a tag:
docker build -t trivy-demo:latest .
Now scan the image you just built:
trivy image --severity HIGH,CRITICAL trivy-demo:latest
This scan covers two layers of risk at once: the operating system packages inherited from the node:18.17.0-slim base, and the npm dependencies your package.json pulled in. The old express version above will likely show up with its own findings, which is exactly the point. Your dependencies are part of your attack surface, not just the base OS.
Step 5: Scan the Dockerfile for Misconfigurations
Vulnerabilities are about outdated packages. Misconfigurations are about insecure choices in how you build and run the container. Trivy can scan configuration files directly.
Run a config scan against your project directory:
trivy config .
For the Dockerfile above, you will probably see something like:
Dockerfile (dockerfile)
Tests: 26 (SUCCESSES: 24, FAILURES: 2)
Failures: 2 (HIGH: 0, MEDIUM: 1, LOW: 1)
MEDIUM: Specify a maintainer for the Dockerfile
LOW: Add HEALTHCHECK instruction in your Dockerfile
A very common and more serious finding is running as root. By default, containers run as the root user, which is risky. If something breaks out of the container, it does so with root privileges. Fix it by adding a non-root user to your Dockerfile:
FROM node:18.17.0-slim
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
USER node
EXPOSE 3000
HEALTHCHECK CMD node -e "require('http').get('http://localhost:3000/health')"
CMD ["node", "server.js"]
The USER node line switches to the unprivileged node user that the official image already provides. The HEALTHCHECK line tells Docker how to test whether the container is actually healthy, not just running. Rebuild and rescan to confirm the findings clear up.
Step 6: Scan for Leaked Secrets
One of the easiest ways to leak a credential is to copy a .env file or a private key into an image and forget about it. Trivy scans for secrets by default when scanning filesystems and images.
To see this in action, create a file that looks like a leaked key:
echo "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" > ~/trivy-demo/.env
Scan the filesystem:
trivy fs ~/trivy-demo
Trivy will report the secret it found, including the file and line number:
.env (secrets)
Total: 1 (HIGH: 0, CRITICAL: 1)
CRITICAL: AWS Secret Access Key
File: .env:1
Delete that test file when you are done:
rm ~/trivy-demo/.env
This kind of scanning is valuable as a last line of defense. The proper fix for secrets is to keep them out of images entirely and inject them at runtime through environment variables or a secrets manager.
Step 7: Generate a Report You Can Share
Console output is good for a quick check, but teams often need a saved report. Trivy supports several output formats through the --format and --output flags.
Save a JSON report for further processing:
trivy image --severity HIGH,CRITICAL --format json --output report.json trivy-demo:latest
Generate a human-readable HTML report using Trivy’s built-in template:
trivy image --format template \
--template "@/usr/local/share/trivy/templates/html.tpl" \
--output report.html trivy-demo:latest
The JSON output is the one to use for automation, because tools and dashboards can parse it. If you run a private registry, scanning before you push pairs well with the Harbor image registry setup, which can also run its own scans on stored images.
Step 8: Fail a Build on Vulnerabilities
The real payoff of a scanner is stopping bad images before they ship. Trivy can return a non-zero exit code when it finds problems at or above a severity you choose. That exit code is what a CI pipeline uses to fail a build.
Try it locally first:
trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed trivy-demo:latest
echo "Exit code: $?"
If any fixable high or critical vulnerability is found, the exit code is 1. If the image is clean, it is 0.
Here is a minimal GitHub Actions workflow that builds an image and blocks the merge if Trivy finds fixable high or critical issues. Save it as .github/workflows/scan.yml:
name: Container 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 trivy-demo:$ .
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: trivy-demo:$
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: 1
With this in place, a pull request that introduces a vulnerable dependency or base image gets a red check, and the problem is caught during review instead of in production.
Common Mistakes and Troubleshooting
Scanning without filtering and giving up. The first scan of any real image returns a lot. People see 150 findings, feel overwhelmed, and ignore the tool. Always start with --severity HIGH,CRITICAL --ignore-unfixed. A short fixable list gets acted on, a long unfiltered one does not.
Stale vulnerability database. Trivy caches its database and refreshes it periodically, but on a machine that has been idle, the data can lag. Force an update with:
trivy image --download-db-only
Database download fails behind a proxy or rate limit. Trivy pulls its database from a container registry (GitHub Container Registry by default). If your network blocks it or you hit an anonymous pull rate limit, scans fail with a download error. Setting the GITHUB_TOKEN environment variable raises the limit:
export GITHUB_TOKEN=your_token_here
Expecting Trivy to fix things. Trivy reports, it does not patch. When it flags an OS package, the fix is usually to rebuild on a newer base image tag. When it flags a language dependency, the fix is to upgrade that package in your package.json, requirements.txt, or equivalent.
Forgetting that latest is a moving target. Scanning myapp:latest today and tomorrow can give different results because the tag points to different content. Always scan a specific, immutable tag or digest in CI, like the commit SHA used in the workflow above.
Best Practices
Use small base images. A slim, alpine, or distroless base ships far fewer packages, which means a much smaller attack surface and fewer findings to triage. Switching from a full node:18 to node:18-slim often removes dozens of irrelevant OS vulnerabilities instantly.
Scan early and scan often. Run Trivy locally before you push, in CI on every pull request, and on a schedule against images already in your registry. A new CVE published tomorrow affects an image you built and forgot about today, so periodic rescans matter.
Treat the policy as code. Decide once what severity blocks a build and what gets ignored, then commit that decision. Use a .trivyignore file to record specific CVEs you have reviewed and consciously accepted, with a comment explaining why:
# No fix available, not exposed in our network path. Reviewed 2026-06-03.
CVE-2023-1234
Keep secrets out of images, not just scanned for. Secret scanning is a safety net, not a strategy. Inject credentials at runtime and keep them in a secrets manager or environment variables.
Combine Trivy with the rest of your hardening. Image scanning is one layer. It works best alongside host-level protections like a firewall and intrusion prevention, such as the setup in the Fail2ban brute force protection guide.
Conclusion
You now have a complete, practical workflow for keeping container images secure. You installed Trivy from its official repository, scanned both public and locally built images, filtered results down to a short fixable list, scanned Dockerfiles for insecure configuration, caught a leaked secret, generated shareable reports, and wired a scan into CI so vulnerable images get blocked before they ever reach production.
The habit to take away is simple: never trust an image just because it builds and runs. A two-second scan tells you what is actually inside it.
From here, a good next step is to scan the Kubernetes manifests and Terraform files you deploy with, using trivy config, so misconfigurations get caught at the same gate as vulnerable packages. Pairing that with a private registry that scans stored images gives you defense in depth across the whole pipeline.