Docker security guide
How to audit a Node.js Docker image for npm vulnerabilities
Containerizing a Node.js app doesn’t make its npm vulnerabilities disappear — it ships them as a reproducible artifact. npm audit doesn’t run automatically when you docker build, and a plain build will silently package every vulnerable dependency into your production container. This guide covers how to catch them before they ship.
- Two vulnerability surfaces
- npm audit in the Dockerfile
- The multi-stage build trap
- Image scanning with Trivy
- Docker Scout
- GitHub Actions workflow
- Base image choice
- FAQ
Two vulnerability surfaces in a Node.js Docker image
A Node.js container has vulnerabilities in two distinct layers, and they require different tools to find:
| Layer | What it contains | Tool to scan it |
|---|---|---|
npm packages (node_modules) | Your application’s direct and transitive npm dependencies | npm audit, Trivy, Docker Scout |
| OS packages (apt/apk) | System libraries in the base image — OpenSSL, glibc, zlib, curl, and others | Trivy, Docker Scout |
npm audit only knows about npm packages. OS-layer vulnerabilities — such as a CVE in the version of OpenSSL bundled in your base image — are completely outside its scope. This is why image-level scanning is a necessary second step even after npm audit passes.
Add npm audit to your Dockerfile build
The simplest way to catch vulnerable npm packages at build time is to add an explicit RUN npm audit step immediately after installing dependencies. If the audit finds high or critical findings, the build fails — no image is produced, and nothing ships.
FROM node:20-slim AS builder
WORKDIR /app
# Copy manifests first (layer cache optimization)
COPY package.json package-lock.json ./
# Install exact lockfile versions
RUN npm ci
# Fail the build if high or critical vulnerabilities are present
RUN npm audit --audit-level=high
# Copy source and build
COPY . .
RUN npm run build
npm ci, not npm install. npm ci installs exactly what is in your package-lock.json — no version resolution, no surprises. npm install can silently update transitive dependencies at build time, meaning your Docker build may produce a different dependency tree than what you tested locally.
The --audit-level=high flag causes the step to exit non-zero (failing the build) only for high and critical findings. Low and moderate findings appear in the build log but do not block. Adjust to --audit-level=critical to be more permissive, or --audit-level=moderate to be stricter.
npm ci reads a stale package-lock.json that was not regenerated after npm audit fix locally, you may get a clean audit inside the build and still ship vulnerable packages. Always commit the updated lockfile and rebuild the image after fixing.
The multi-stage build devDependencies trap
Multi-stage builds are the recommended pattern for production Node.js images — compile or bundle in a build stage and copy only the runtime artifacts to a final stage. But there is a common mistake that pulls devDependencies into the production image anyway.
The wrong way
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # installs ALL deps including devDependencies
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules # copies devDeps too
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
Copying the entire node_modules from the build stage pulls in TypeScript, Jest, ESLint, Vite, and all their transitive dependencies into the production container. Dev tools update less carefully than production libraries and frequently carry vulnerabilities.
The right way
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
# Install only production dependencies in the final image
RUN npm ci --omit=dev
RUN npm audit --audit-level=high
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
Running npm ci --omit=dev in the production stage installs only the packages under "dependencies" in your package.json — every devDependency is excluded. The RUN npm audit step after it verifies the production-only install is clean.
Scan the final image with Trivy
Trivy (from Aqua Security) is a free, open-source vulnerability scanner that inspects a Docker image after it is built — scanning both OS packages and npm packages in node_modules. This catches OS-layer CVEs that npm audit never sees.
# Install Trivy (macOS)
brew install trivy
# Install Trivy (Linux)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
# Build your image
docker build -t myapp:latest .
# Scan it — exit 1 on high or critical findings
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
Trivy reads the OS package manifest (Alpine’s apk database, Debian’s dpkg status) and the npm lockfile embedded in the image. Output groups findings by layer and severity, showing the CVE ID, affected package, version, and fixed version where one exists.
node:20-slim pulled this week may have different OS packages than the same tag pulled last month. Scanning after every docker build (not just on a schedule) catches OS CVEs that appear between builds.
Docker Scout
Docker Scout is Docker’s built-in vulnerability scanning feature, available via the Docker CLI and Docker Desktop. It integrates directly with Docker Hub and your build output.
# Scan a local image
docker scout cves myapp:latest
# Show only high and critical
docker scout cves --only-severity high,critical myapp:latest
# Compare against the base image to see what your Dockerfile added
docker scout compare myapp:latest --to node:20-slim
The docker scout compare command isolates which vulnerabilities come from your application’s dependencies versus which came from the base image. If a CVE is already in node:20-slim, the fix is to update the base image tag, not to change your Dockerfile logic.
GitHub Actions workflow
Automate both npm audit (inside the build) and image scanning in CI so every push to main is checked before anything reaches your registry.
name: Docker image security scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
# npm audit --audit-level=high runs inside the Dockerfile RUN step
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
exit-code: '1'
severity: HIGH,CRITICAL
format: table
The trivy-action step fails the workflow if Trivy finds any high or critical vulnerabilities in either npm packages or OS packages. Combined with the npm audit step inside the Dockerfile, you have two gates: one that fails before the image is created, and one that scans the final artifact before it is pushed.
Base image choice affects your OS attack surface
The Node.js official images come in several variants. The variant you choose determines how many OS-level packages are pre-installed and therefore how many OS-layer CVEs you start with before your app adds any.
| Base image | OS | Notes |
|---|---|---|
node:20 / node:20-bookworm | Debian 12 (full) | Includes build tools, curl, and dev utilities. Largest OS attack surface. Avoid in production. |
node:20-slim | Debian 12 (minimal) | Dev tools removed. glibc present — native modules work. Good default for production. |
node:20-alpine | Alpine Linux (musl) | Smallest image, fewest OS packages. Uses musl libc — can break native modules expecting glibc. Test before adopting. |
When Trivy reports OS CVEs in a base image, run docker pull node:20-slim to fetch the latest digest and rebuild — OS package maintainers patch CVEs in updated image layers, so keeping your base image current is the primary mitigation for OS-layer findings.
Check your package.json with DepCheck before you containerize →
DepCheck scans your npm dependencies for vulnerabilities, abandoned packages, typosquatting risk, and license issues before they end up baked into a Docker image — in the browser, no install required.
Frequently asked questions
Does Docker automatically run npm audit when building an image?
No. Docker executes only the RUN commands you write in your Dockerfile. Unless you explicitly add a RUN npm audit step, vulnerable npm packages will be installed into your image silently. Add RUN npm audit --audit-level=high after RUN npm ci to fail the build on high or critical findings.
Why do npm vulnerabilities still appear in my image after running npm audit fix?
The most common reason: the package-lock.json copied into the Docker build was not updated after you ran npm audit fix locally. npm ci installs exactly what the lockfile says — if it still references the vulnerable version, that version is installed. Commit and push the updated lockfile, then rebuild. A second reason: some findings require npm audit fix --force (a major version bump) that the safe fix skips.
What is the multi-stage build devDependencies trap?
In a multi-stage Dockerfile, a common pattern is to install all dependencies in a build stage and then COPY --from=builder /app/node_modules ./node_modules into the production stage. This copies every devDependency — TypeScript, Jest, bundlers — into the production container along with their vulnerabilities. Fix it by running npm ci --omit=dev directly in the production stage instead of copying node_modules from the build stage.
What does Trivy scan that npm audit does not?
Trivy scans both npm packages in node_modules and OS packages installed by apt or apk in your base image (OpenSSL, zlib, curl, and other system libraries). npm audit has no visibility into OS packages — a CVE in the OpenSSL version bundled with node:20-slim will never appear in npm audit output. Trivy catches both layers in a single scan.
Should I use node:alpine or node:slim for production?
node:slim is the safer default. Alpine uses musl libc instead of glibc — most pure JavaScript packages work fine, but native modules compiled against glibc (some database drivers, image processing libraries) fail at runtime on Alpine. node:slim keeps glibc compatibility while removing the dev tools that inflate the full image. Switch to Alpine only after verifying your specific dependencies are compatible.
How do I scan a Docker image for vulnerabilities in GitHub Actions?
Use the aquasecurity/trivy-action after your docker build step. Set exit-code: 1 and severity: HIGH,CRITICAL to fail the workflow on serious findings, and pass the built image tag as image-ref. This scans both OS packages and npm packages in the final image before any push to a registry. See the GitHub Actions example above for the complete workflow.
Can npm audit run inside a running container instead of during the build?
Yes — you can exec into a running container and run npm audit manually. But this is harder to automate and won’t block a deployment. The recommended pattern is to scan at build time (in the Dockerfile) so a vulnerable image is never created, and scan the built image in CI (with Trivy or Docker Scout) before pushing to a registry. Both gates together mean a vulnerable image never reaches your registry in the first place.
Also in the Copper Bay Labs ship-safety suite
Docker vulnerability scanning is one layer of your pre-ship checklist. These free tools cover the rest:
Paste your package.json and see vulnerable, abandoned, typosquatted, and risky-license packages flagged by severity — before they end up in your container.
Paste code or a config file and see exposed API keys and secrets before they reach the repo or a Docker build context.
Post-deploy ExposureCheckScan your live URL for exposed .env files, .git directories, source maps, and bundled secrets.
Check your security headers and CSP so your containerized app isn’t an exfiltration vector.