Every Docker image you pull is a trust decision. The official Node.js 20 image on Docker Hub contains a full Debian or Alpine Linux installation — and those operating system packages have vulnerabilities. In 2024, Snyk's container security report found that 84% of container images have at least one known vulnerability, and 45% have a critical or high-severity CVE. I run Trivy container scanning in my CI/CD pipelines for all production deployments and have blocked multiple image updates that would have introduced new CVEs. This guide covers how to integrate Trivy into your workflow and actually reduce your container attack surface — not just scan and ignore.
Containers do not provide security isolation — they provide process isolation. A vulnerability in a library inside your container is an exploitable vulnerability in your application. The Log4Shell vulnerability (CVE-2021-44228) in late 2021 affected any Java application using log4j — including applications running in containers. Containers that had not been rebuilt with patched dependencies remained vulnerable even though the host OS was fully patched. Container scanning catches these library-level vulnerabilities before deployment and provides continuous monitoring for new CVEs affecting your deployed images.
Trivy is an open-source vulnerability scanner by Aqua Security that scans Docker images, filesystems, git repositories, and Kubernetes manifests. It checks: OS packages (Debian, Alpine, Ubuntu, RHEL), language-specific packages (npm, pip, Go modules, Maven), infrastructure misconfigurations, and exposed secrets in images. Trivy updates its vulnerability database daily from NVD, GitHub Advisory, and OS-specific vulnerability databases. It runs as a single binary with no daemon required — ideal for CI/CD integration. Install with: `brew install trivy` or via the official Docker image.
# Install Trivy
brew install trivy
# or: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Scan a Docker image
trivy image node:20-alpine
trivy image your-registry.com/your-app:latest
# Scan with severity filter (fail on CRITICAL and HIGH)
trivy image --severity CRITICAL,HIGH --exit-code 1 node:20-alpine
# Scan and ignore unfixed vulnerabilities (recommended for CI)
trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 your-app:latest
# Output SARIF for GitHub Security tab
trivy image --format sarif --output trivy-results.sarif your-app:latest
# Scan your local Dockerfile for misconfigurations
trivy config .
# Scan running Kubernetes cluster
trivy k8s --report summary cluster
# GitHub Actions integration
# .github/workflows/trivy.yml:
name: Container Security Scan
on:
push:
branches: [main]
pull_request:
jobs:
trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t app:${{ github.sha }} .
- name: Run Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: app:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: 1
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarifFrom my experience running Trivy in production CI/CD: use `--ignore-unfixed` in your CI pipeline to only fail on vulnerabilities that have available fixes. Failing on every CVE including unfixable ones creates alert fatigue — developers start ignoring the scanner output. Only actionable findings get actioned. Reserve unfixed vulnerability reporting for a separate weekly security review where you triage and make deliberate decisions about accepted risk.
The CI integration pattern I use: build the Docker image in CI, run Trivy scan on the built image, fail the pipeline on critical or high-severity vulnerabilities with available fixes, and upload the SARIF report to GitHub Security tab for tracking. This ensures no image with critical vulnerabilities gets pushed to production. The pipeline runs Trivy using the official GitHub Action which handles database caching for performance.
The most effective container security control is choosing minimal base images. Comparison for a Node.js application: node:20 (Debian-based) — 600MB, 300+ packages, many unneeded. node:20-slim — 200MB, minimal Debian packages. node:20-alpine — 65MB, Alpine Linux with only ~20 packages. Distroless (gcr.io/distroless/nodejs20) — no shell, no package manager, minimal attack surface. I use node:20-alpine for most services and distroless for security-critical components. Fewer packages means fewer vulnerability surface. A Trivy scan on node:20-alpine typically shows 5-15 vulnerabilities vs 80-120 on node:20.
# Secure Dockerfile — multi-stage, non-root, pinned base
# Pin exact digest to prevent silent updates
FROM node:20.18.1-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 AS builder
WORKDIR /app
COPY package*.json ./
# Install all dependencies for build
RUN npm ci --include=dev
COPY . .
RUN npm run build
# Production stage — minimal image
FROM node:20.18.1-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03
WORKDIR /app
# Create non-root user BEFORE copying files
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# Copy only production artifacts
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
# Run as non-root
USER nextjs
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/main.js"]
# Trivy scan comparison:
# node:20 → ~120 CVEs (85MB base)
# node:20-slim → ~45 CVEs (55MB)
# node:20-alpine → ~8 CVEs (15MB)
# Distroless nodejs → ~2 CVEs (no shell at all)Using `FROM node:20` in your Dockerfile is dangerous — it silently updates to the latest patch when you rebuild, which can introduce new vulnerabilities or breaking changes. Always pin to a specific digest: `FROM node:20.18.1-alpine3.20@sha256:<digest>`. Use Renovate Bot or Dependabot to automate base image updates — they create PRs with changelogs when new base images are available, letting you review and test before updating. Unpinned base images in production have burned me before: an image update changed the default user from root to node and broke file permission assumptions in the application.
Beyond base image selection, Dockerfile security comes down to: running as a non-root user (USER node or USER 1001 — never run production containers as root), not copying secrets into the image (use build-time secrets with --mount=type=secret, not ARG), using multi-stage builds to exclude build tools and source code from the final image, scanning for secrets in the Dockerfile itself (Trivy does this), and setting read-only filesystem where possible (--read-only flag at container runtime). A container running as root is a privilege escalation risk if any vulnerability exists in the application or its dependencies.
Trivy can scan running containers and Kubernetes manifests in addition to images. For Kubernetes: `trivy k8s --report summary cluster` scans your entire cluster for vulnerabilities and misconfigurations. OPA Gatekeeper or Kyverno can enforce policies that prevent pods from using unscanned or non-compliant images from running in production. The combination of pre-deployment scanning and runtime policy enforcement creates a continuous security posture rather than a point-in-time check.
Complete workflow: Dockerfile uses pinned Alpine base image and non-root user. Multi-stage build excludes devDependencies and build tools from final image. GitHub Actions builds image and runs Trivy with --exit-code 1 on critical/high fixable CVEs. Trivy SARIF output uploaded to GitHub Security tab for tracking. Images tagged with git SHA and pushed to registry only after scan passes. Renovate Bot creates weekly PRs for base image updates. Monthly Trivy scan of all running images in production cluster. This workflow reduced our critical CVE count from 40+ (when we started scanning) to consistently under 5 — and those remaining 5 are tracked accepted risks with mitigation plans.