Skip to content
SJ
All writing
10 min read

Images That Build Fast and Ship Small

Image size is deploy latency, cold-start time and attack surface. Most Node images are several times too large, and layer ordering is usually why.

DockerDevOpsNode.jsCI/CD

A typical first Dockerfile for a Node service produces something around 1.2GB and rebuilds from scratch on every commit. Both numbers can come down by an order of magnitude without changing a line of application code, and the fix is mostly about ordering rather than about picking a smaller base image.

Why size is not vanity

Four costs, and only the first is obvious:

  • Deploy latency. Every node pulls the image before it can run. A 1GB image across a rolling deploy is minutes of pulling, repeated per node, on every release.
  • Scaling latency. Autoscaling means pulling the image before the new instance serves traffic. The image size is now part of your response to a traffic spike.
  • Attack surface. A full OS image contains a package manager, a shell, compilers and dozens of libraries you do not use. Each is something a scanner will flag and something an attacker can use after a compromise.
  • Build time. Not size directly, but the same discipline fixes both — and a slow build is a slow feedback loop for everyone.

Layer caching, which is the whole game

Each instruction creates a layer. Docker reuses a cached layer only if that instruction and every instruction before it are unchanged. One invalidated layer invalidates everything after it.

So this is slow on every single build:

FROM node:22
WORKDIR /app
COPY . .                 # any source change invalidates here
RUN npm install          # ...so this reruns every time
RUN npm run build
CMD ["node", "dist/main.js"]

Change one character in a component and you reinstall every dependency. The fix is to separate what changes rarely from what changes constantly:

FROM node:22
WORKDIR /app
COPY package*.json ./    # changes only when dependencies change
RUN npm ci               # cached across every source-only commit
COPY . .                 # changes constantly, but it is the last expensive step
RUN npm run build
CMD ["node", "dist/main.js"]

Same result, and dependency installation is now skipped on the large majority of builds. The general principle: order instructions from least to most frequently changed.

Use npm ci rather than npm install in an image. It installs exactly the lockfile, fails if the lockfile disagrees with package.json, and does not silently mutate anything — which is what you want from a reproducible build.

And write a .dockerignore before anything else. Without one, COPY . . sends your entire working directory to the daemon — node_modules, .git, build output, local environment files. That is a slow build context, a cache that invalidates whenever any local file changes, and a real risk of copying .env into a published image.

node_modules
.git
.next
dist
coverage
*.log
.env*

Multi-stage builds

The single-stage image above ships everything needed to build the app: TypeScript, test frameworks, source files, the full dependency tree. None of it is needed to run the app.

# --- build stage ---
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci                       # all dependencies, including dev
COPY . .
RUN npm run build

# --- production dependencies, isolated ---
FROM node:22-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

# --- runtime ---
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps  /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]

Only the final stage becomes the image. The compiler, the dev dependencies and the source never ship. The separate deps stage exists so that production dependencies are installed cleanly rather than pruned after the fact, and so that stage stays cached independently of your source.

This also matters for secrets. A token used during the build in an earlier stage is not present in the final image — whereas in a single-stage build, anything ever written to a layer remains in the image history even if a later instruction deletes it. Deleting a file in a subsequent layer does not remove it from the layer that added it, and docker history will show it to anyone who pulls the image.

Choosing a base image

Roughly, in decreasing size and increasing sharp edges:

  • Full (node:22) — around a gigabyte, contains build tools and a full userland. Fine for a build stage, wasteful for runtime.
  • Slim (node:22-slim) — a Debian base with the extras removed. Roughly 200MB, glibc, a shell for debugging. This is the sensible default for runtime.
  • Alpine — around 50MB, but musl libc rather than glibc. Native modules may need recompiling or behave differently, and there are known DNS resolution differences. The size saving is real; so is the class of bug you have opted into.
  • Distroless — no shell, no package manager, just the runtime and your app. The smallest attack surface available and the right choice for a mature service. The absence of a shell means docker exec for debugging is not available, which is a genuine trade — one that pushes you toward proper logging, which is where you wanted to be anyway.

Pin the version. FROM node:22-slim is a moving target; FROM node:22.11.0-slim is reproducible. Pinning by digest is stronger still. An image that built yesterday and fails today with no code change is almost always an unpinned base that moved underneath you.

Security basics that cost nothing

  • Do not run as root. The official Node images include a node user; USER node is one line. A container escape from a root process is a substantially worse day than one from an unprivileged process.
  • Never bake secrets in. Not as ENV, not as a copied file. Anyone who can pull the image can read them. Inject at runtime.
  • Scan images in CI and fail on high-severity findings in your own dependencies. Most of what a scanner reports comes from the base image, which is another argument for a smaller one — a distroless image simply has less to report.
  • Handle SIGTERM. On shutdown the orchestrator sends SIGTERM and waits before killing. A process that ignores it drops in-flight requests on every deploy. Close the server, finish what is running, then exit — and make sure your process is PID 1 or has an init that forwards signals, since a shell-form CMD will swallow them.

Development images are a different problem

The production Dockerfile optimises for small and immutable. Development wants the opposite: source mounted from the host, hot reload, dev dependencies present, and a shell available.

Use a separate target rather than compromising the production image. Multi-stage makes this natural — a dev stage that stops after installing dependencies, with the source bind-mounted at run time.

One detail that trips people constantly: bind-mounting your project directory over /app also replaces the container's node_modules with the host's, which were built for a different platform. Mount an anonymous volume over /app/node_modules so the container keeps its own, or the symptom is native modules failing with errors that make no sense.

The short version

Copy the lockfile and install before copying source, so dependency installation stays cached. Write a .dockerignore first. Build in one stage and ship from another, so compilers, dev dependencies and build-time secrets never reach the image. Default to slim, pin the version, drop to a non-root user, and handle SIGTERM. Then keep the development image separate rather than compromising both.

Written by Saumya Jain

Full Stack Engineer working on headless commerce, NestJS microservices, and real-time systems. Currently open to remote work.