Docker image vulnerability scanning before you deploy: Trivy and Grype, step by step
A practical guide to Docker image vulnerability scanning with Trivy and Grype: scan locally, gate merges in CI, and rescan what's already running.
Docker image vulnerability scanning checks the software inside your images, like OS packages from the base image and the language dependencies your app pulls in, against public vulnerability databases. It's the layer that host maintenance never reaches. As Automatic security updates on a self-hosted server points out, unattended-upgrades patches the server and leaves the images alone. An image built six months ago on an old base image still has those six-month-old packages, however well patched the host is.
This guide shows two free, open-source scanners, Trivy from Aqua Security and Grype from Anchore. It covers running them locally, setting a failure threshold that doesn't drown you in noise, gating merges in CI, and rescanning images that are already running. The commands work for any Docker workflow. Where it matters, the guide explains how they fit a Pushify deploy.
Where scanning fits in a Pushify deploy
Start with where the image comes from, because that decides where a scan can run.
On Pushify, a git push to your production branch fires a webhook and queues a deployment. The image is built on the target server, either from your own Dockerfile or with framework detection. The new container has to pass a health check before it gets traffic. From git push to live walks through the whole pipeline.
That gives you two practical places to scan:
- Before the push reaches the production branch. Build the same Dockerfile in CI on every pull request, scan it, and make the scan a required check for merging. This is the only place a scan can stop a vulnerable image before it ships.
- On the server, after the build. Scan the images that are actually running. This doesn't block anything, but it scans exactly what's in production, and it's how you catch vulnerabilities disclosed after you deployed.
Keep one caveat in mind. An image built in CI from the same commit and Dockerfile is a close match for what the server builds, not a byte-identical copy. If your Dockerfile uses a tag like node:22-slim, the tag can point to a newer base image by the time the server builds. In practice that usually means the server's image is a little newer, and that's one more reason to do both.
If you deploy with framework detection and don't keep a Dockerfile in the repository, CI can't rebuild the same image. In that case, scan the repository's lockfiles in CI (covered below) and scan the built image on the server.
Step 1: Install a scanner and run your first scan
You don't need both tools. Pick one to start. They're compared near the end of this post.
Trivy
On macOS, brew install trivy. On Linux, use the install script or your distribution's package if it has a recent version:
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/binBuild your image and scan it:
docker build -t myapp:local .
trivy image myapp:localOn its first run, Trivy downloads its vulnerability database, so the first scan takes longer than the ones after it. The output groups findings by target: the OS packages from the base image, then each language ecosystem it recognized in the image. Every finding lists the package, the installed version, the fixed version if there is one, and a severity.
Grype
Grype has a similar install script:
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \
| sh -s -- -b /usr/local/binThen:
grype myapp:localGrype also downloads its database on first use. The output is one table, with one row per vulnerable package, its installed version, the version that fixes it, the vulnerability ID, and severity.
A note on those install scripts: piping a script from a main branch into sh runs whatever is on that branch today. Both scripts take a version tag as an extra argument after the install directory. For anything automated, pin a release you've checked instead of always taking the latest. Scanners have broad access to your build environment, so treat them like any other dependency.
Step 2: Decide what should fail a build
Your first scan of a typical image will probably return a long list. Most teams that turn on scanning and fail on every finding switch it off within a week. A threshold you'll actually keep is worth more than a strict one you'll disable.
A reasonable starting policy:
- Fail on HIGH and CRITICAL. Report the rest, but don't block on it.
- Ignore findings with no fix yet. If no fixed version exists, failing the build gives you nothing to act on. Track those separately.
With Trivy:
trivy image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
myapp:localWith Grype:
grype myapp:local --only-fixed --fail-on high--exit-code 1 (Trivy) and --fail-on high (Grype) make the command exit non-zero when a matching finding exists, which is what CI needs. Grype's --fail-on high triggers on high and anything more severe.
Severity is a general rating, not a verdict on your app. A critical vulnerability in a package your app never loads matters less than a medium one in your HTTP parser. The threshold sorts what gets looked at first. It doesn't replace reading the finding.
Step 3: Fix what you can, then reduce the surface
Most findings in a fresh scan come from the base image, and you rarely fix them one by one. These changes usually remove whole groups of them:
- Rebuild on a current base image. Often the fix is already published and your image just predates it. Pull the base image again and rebuild before you change anything else.
- Use a smaller base. A
-slimor Alpine variant, or a distroless image for the final stage, ships fewer packages, so there are fewer packages to be vulnerable. - Use a multi-stage build. Compilers, build tools, and dev dependencies belong in a build stage. Copy only the built output into the final stage, so the image you scan and run doesn't include them.
- Update the language dependency. For findings in your own dependencies, the fixed version is in the scan output. Bump it in your lockfile like any other upgrade.
A minimal multi-stage pattern for a Node app looks like this:
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]Adjust the paths to your build. The point is that the final stage carries only what the app needs at runtime.
Accepting a finding on purpose
Some findings don't apply to you, and some have no practical fix yet. Record those decisions in the repository instead of lowering the threshold for everything.
Trivy reads a .trivyignore file from the working directory. Each line is a vulnerability ID, and it can carry an expiry date so the decision gets revisited:
# Not reachable: we don't use the affected feature. Revisit after the next base image bump.
CVE-2026-12345 exp:2027-01-31Grype reads ignore rules from a .grype.yaml file:
ignore:
# Not reachable: we don't use the affected feature.
- vulnerability: CVE-2026-12345Always write the reason down. An ignore file without reasons turns into a list nobody dares to touch.
Step 4: Gate merges in CI
Since a push to the production branch starts a deploy, the gate has to sit before that push. On GitHub, that means a workflow that runs on pull requests against the production branch, plus a branch protection rule (or ruleset) that requires the check to pass before merging.
Here's a workflow with Trivy, assuming main is your production branch:
name: image-scan
on:
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:ci .
- name: Install Trivy
# Pin a release you've reviewed by adding its tag after /usr/local/bin
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
- name: Scan image
run: |
trivy image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
myapp:ciTo use Grype, swap the last two steps for the Grype install script and grype myapp:ci --only-fixed --fail-on high.
Both tools also publish official GitHub Actions. They save a few lines, but they're third-party code running in your pipeline, so pin them to a full commit SHA rather than a movable tag.
Two details make the gate actually hold:
- Require the check in branch protection. Without that, a failed scan is only a red mark on the pull request, and the merge still goes through.
- Restrict direct pushes to the production branch. A direct push skips the pull request, and with it the scan, and it still triggers a deploy.
No Dockerfile in the repository
If you rely on framework detection, scan the dependency manifests instead of an image:
trivy fs --severity HIGH,CRITICAL --exit-code 1 .grype dir:. --only-fixed --fail-on highThis covers your language dependencies through their lockfiles. It doesn't cover the base image packages, which is what the server-side scan in the next step is for.
Step 5: Rescan what's already running
A clean scan on deploy day says nothing about next month. New vulnerabilities are disclosed against packages you already ship, and a scan only finds them if it runs again.
The simplest version is a scheduled CI run against the production branch. Add a trigger to the same workflow:
on:
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1"That rebuilds and scans every Monday morning, and GitHub notifies you when a scheduled workflow fails. When it fails, the usual fix is to rebuild on a current base image and deploy again. That's an ordinary push, so it goes through the same health check as any other deploy.
To scan exactly what's in production, SSH into the server and scan the local images. List them first, since the names depend on how they were built:
docker imagesThen run the scanner as a container, so you don't install anything on the host:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v trivy-cache:/root/.cache/ \
aquasec/trivy image --severity HIGH,CRITICAL <image-name>:<tag>The named volume keeps the vulnerability database between runs, so it doesn't download again every time. Grype works the same way with the anchore/grype image.
Keep in mind that mounting the Docker socket gives that container full control of Docker on the host, so only do it with an image you trust and a version you've pinned. A scan also uses CPU, memory, and disk on a machine that's serving traffic. On a small VPS, run it during a quiet hour. Or export the image with docker save and scan the archive on another machine:
docker save <image-name>:<tag> -o app.tar
trivy image --input app.tarTrivy or Grype?
Both are solid, actively maintained, and free. The differences are mostly about scope:
- Trivy covers more ground. Besides vulnerabilities, it can look for secrets baked into image layers (
--scanners secret) and misconfigurations in Dockerfiles and infrastructure code. If you want one tool for several checks, start here. - Grype does one job, vulnerability matching, and pairs with Syft, Anchore's SBOM generator. If you already produce SBOMs, Grype can scan them directly with
grype sbom:./sbom.json, without the image.
They use different databases and matching logic, so the same image can produce different results in each. That doesn't mean one is wrong. Running both in CI mostly gives you two lists to reconcile. Pick one, tune its threshold, and keep it running.
The secret scan deserves a closer look. If a scan finds credentials in an image layer, deleting the file in a later layer doesn't remove it, because earlier layers still hold it. Rotate the credential, and move it into environment variables as described in Managing environment variables and secrets.
The short version
- Host updates don't patch your images. Scanning is how you see what's inside them.
- Start with HIGH and CRITICAL, fixed vulnerabilities only. Tighten it later.
- Most findings go away when you rebuild on a current, smaller base image with a multi-stage build.
- On Pushify, a push to the production branch triggers the deploy, so gate the merge in CI and require the check.
- Rescan on a schedule, and scan the images on the server when you need to know exactly what's running.
For the rest of the security picture, including access control, secrets, and updates, see Securing a self-hosted PaaS.