Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
Smaller, safer Docker images: multi-stage builds and scanning How we cut Docker image size and attack surface: multi-stage builds, slim and distroless bases, non-root users, and CI scanning with Trivy or Grype.
A Node.js API image comes in at 1.2 GB. The registry bill is small, but every deploy pulls the whole thing, autoscaled nodes take a minute longer to come up, and the scanner reports a few hundred CVEs, most of them in compilers, shells and libraries the application never calls. Nobody can tell which findings matter, so nobody looks.
The short answer: use a multi-stage build so the final image contains only the runtime and your built application, start from a slim or distroless base, run as a non-root user, and scan every image in CI with a tool such as Trivy or Grype, failing the build on fixable high and critical issues. Then rescan what is already deployed on a schedule.
This is how we approach it on client projects, with Dockerfiles you can adapt.
Why image size and contents matter Size is the visible symptom. The real issue is what is inside. Every package in the final image is something that can carry a vulnerability, something an attacker can use after getting a foothold, and something your scanner has to report on. A smaller image is usually a safer one because it simply contains less.
Fewer packages means fewer CVEs and a shorter, more useful scan report. No shell, package manager or compiler leaves less to work with after a compromise. Smaller images pull faster, which shortens deploys, rollbacks and node start-up. Less registry storage and transfer, which adds up with many tags and environments. Before changing anything, measure. docker image ls shows the total size, and docker history shows which layer added what. The open source tool dive goes further and lets you browse each layer's files, which is the quickest way to find a stray node_modules cache or a copied .git directory.
docker image ls myapp
docker history --no-trunc myapp:latest
dive myapp:latestMulti-stage builds: build fat, ship thin A multi-stage Dockerfile has several FROM lines. Early stages install toolchains and build the application. The final stage starts from a small base and copies in only the output. Everything left behind in the build stages, including compilers, dev dependencies and source files, never reaches the image you deploy.
Here is a typical pattern for a TypeScript Node.js service.
# syntax=docker/dockerfile:1
FROM node:24-trixie-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:24-trixie-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]A few details are doing real work here. Copying the lock file before the source means the dependency layer is cached until dependencies change, so most builds skip the install entirely. The cache mount keeps npm's download cache between builds without writing it into a layer. npm prune --omit=dev removes TypeScript, test runners and linters before node_modules is copied across. And the official Node image already ships a non-root user called node, so there is no reason to run as root.
For compiled languages the gains are bigger. A Go service can be built as a static binary and placed on a base that contains almost nothing.
# syntax=docker/dockerfile:1
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/api /api
USER nonroot:nonroot
ENTRYPOINT ["/api"]The final image is usually just your binary plus a couple of megabytes of base, against the several hundred megabytes of the golang build image. The same idea works for Python: build wheels in a full image, then install them into a slim runtime stage, or copy a prepared virtual environment across.
Choosing a base image The base image decides most of what your scanner will find, so choose it deliberately.
Debian slim variants (for example node:24-trixie-slim or python:3.13-slim): glibc, a shell and apt. The safest default when you need native modules or want to debug inside the container. Alpine: very small, but uses musl instead of glibc. Some native packages and Python wheels behave differently or need compiling, so test properly before switching. Distroless: no shell and no package manager, just the runtime and its libraries. Excellent for production, harder to debug. Google's distroless images also publish :debug tags with a busybox shell for troubleshooting. scratch: an empty image. Only suitable for fully static binaries, and you must add CA certificates and time zone data yourself if the program needs them. Whichever you pick, pin it. A tag such as node:24-trixie-slim moves when the maintainers publish updates, which is useful for patching but makes builds non-reproducible. Pinning by digest (FROM node:24-trixie-slim@sha256:...) gives you an exact image, and a tool such as Renovate or Dependabot can open pull requests when a newer digest is available. That way base image updates are reviewed and tested rather than arriving silently.
Hardening the Dockerfile A small image can still be careless. These are the checks we apply during review.
Add a .dockerignore that excludes .git, node_modules, local .env files, test data and build output. It keeps secrets out of the build context and speeds up builds. Never pass secrets through ARG or ENV. They end up in the image metadata or history. Use BuildKit secret mounts instead. Set USER to a non-root account in the final stage, and make sure the application can write only where it needs to. Use COPY rather than ADD unless you need its archive extraction. Clean package manager caches in the same RUN instruction that created them, or use cache mounts so they never land in a layer. Prefer exec form for CMD and ENTRYPOINT so the process receives signals and shuts down cleanly. A secret mount makes a credential available to a single RUN step without storing it anywhere in the image:
# In the Dockerfile
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
# At build time
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:dev .At run time you can go further: start containers with a read-only root filesystem (read_only: true in Compose, plus a tmpfs for any scratch directory), drop all Linux capabilities with cap_drop: [ALL] and add back only what is needed, and set no-new-privileges (security_opt: ["no-new-privileges:true"]). Most web services run happily with all three.
Scanning images in CI Scanners compare the packages in an image against vulnerability databases. Trivy and Grype are both open source and widely used. Docker Scout ships as a Docker CLI plugin with Docker Desktop and can be installed separately on Linux. They differ in detail but all of them work best when they run on every build and fail it on the findings you have agreed to block.
# Trivy: fail on high or critical issues that have a fix available
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
registry.example.internal/app/api:3f9c2ab
# Grype: equivalent gate
grype registry.example.internal/app/api:3f9c2ab --only-fixed --fail-on high
# Docker Scout
docker scout cves --only-severity critical,high --exit-code \
registry.example.internal/app/api:3f9c2abGating only on fixable issues is a deliberate choice. A vulnerability with no released patch cannot be resolved by rebuilding, and failing every build on it teaches the team to ignore the scanner. Track unfixed findings separately and review them, especially if the affected package is actually used by your application.
When you accept a risk, record it. Trivy reads a .trivyignore file of CVE IDs, and Grype supports ignore rules in its config. Add a comment with the reason and a review date, and keep the file in version control so exceptions are visible in pull requests.
In GitHub Actions or any other CI, the order is: build, scan, then push or promote only if the scan passes. Pin third-party actions to a full commit SHA rather than a tag, because a compromised tag in a security tool's action would run inside your pipeline with your credentials.
SBOMs and rescanning what is already running A scan at build time tells you about the vulnerabilities known that day. New CVEs are published constantly, so an image that passed last month may fail today. Two habits close that gap.
First, generate a software bill of materials (SBOM) and provenance when you build. BuildKit can attach both as attestations when pushing to a registry:
docker buildx build \
--sbom=true --provenance=mode=max \
-t registry.example.internal/app/api:3f9c2ab --push .Second, rescan the images that are actually deployed on a schedule, nightly or weekly, and alert on new high or critical findings. When the fix is in the base image, the remedy is usually a rebuild on the updated base, followed by your normal test and deploy. Regular rebuilds, even with no code changes, keep patch levels current.
A practical checklist Measure the current image with docker history or dive before optimising. Use a multi-stage build; the final stage contains only the runtime and built output. Start from a slim or distroless base, pinned by digest, with automated update pull requests. Keep a .dockerignore, use secret mounts, and run as a non-root user. Scan every build and fail on fixable high and critical findings. Record accepted risks with a reason and review date. Generate SBOMs, rescan deployed images on a schedule, and rebuild regularly. If your images are large, your scanner output is ignored, or nobody is sure which base images are in production, that is a good point to get a second pair of eyes on the pipeline. Most of these changes are small individually; the value comes from making them the default for every service.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789