Docker Multi-Stage Builds and Image Optimization
A Node.js image can shrink from 1.2GB to 150MB with a few fundamentals, not magic. This guide covers multi-stage builds, separating build and runtime stages, choosing distroless or Alpine base images, .dockerignore, layer-cache reuse, and Next.js standalone for smaller, faster, safer images.
Key Points
- Topic Focus: DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
I once inherited a Node project whose image was 1.2 GB according to docker images. The Dockerfile contained a single FROM node:22, followed by COPY . ., npm install, npm run build, and CMD ["node", "server.js"], all in one sequence. It built successfully, but the problem was obvious: the runtime image contained the entire build toolchain, source code, devDependencies, and .git history.
An image like this runs, but every pull takes tens of seconds, every startup takes several seconds, and vulnerability scanners report a wall of findings. This article explains how to reduce an image from 1.2 GB to less than 150 MB while also improving build speed and security. There is no magic involved, just a few fundamental rules.
1. Why is the image so large? Identify the right causes first
In a typical fat image, more than 80% of the contents are unnecessary at runtime:
- The base image itself:
node:22, based on full Debian, is about 900 MB when extracted. Shells,apt, and glibc documentation alone account for much of it. - devDependencies:
typescript,eslint,vitest, andprismaare used only during the build and do not execute a single line at runtime. - Source code and intermediate build artifacts:
src/,.next/cache/,*.map, and test snapshots. - Contaminated build context:
.git, localnode_modules,.env, andnpm-debug.logare all swept into the image byCOPY . ..
Image optimization delivers three benefits at once, not just one:
- Faster pulls: Pulling a 150 MB image from a registry is almost ten times faster than pulling a 1.2 GB image. During K8s autoscaling and rolling updates, this is the difference between minutes and seconds.
- Faster startup: A smaller image with fewer layers and less disk I/O makes cold starts noticeably faster.
- Smaller attack surface: Without a shell,
curl, orapt-getin the image, an attacker has far fewer tools available even after achieving RCE. Vulnerability scanners may immediately report half as many critical findings.
Keep these three benefits in mind; every optimization below maps back to them.
2. Multi-stage builds: separate the build from the runtime
Multi-stage builds are the first principle of image optimization. The core idea is to use multiple FROM instructions in a Dockerfile. Each FROM begins an independent stage, and the final stage receives only the required files through COPY --from=....
# ---- Stage 1: builder, install dependencies and build ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# ---- Stage 2: runner, copy only the artifacts ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["node", "server.js"]
The second stage is the key: it copies only build artifacts from the builder, without copying the source code, the original node_modules, or devDependencies. The hundreds of megabytes used by the builder do not leave a trace in the final image.
The builder is disposable, which is the essence of multi-stage builds. After docker build completes, intermediate stages are removed unless you use --target to stop at a particular stage for debugging. Only the final FROM is pushed to the registry.
3. Choosing a base image: node:22, Alpine, slim, or distroless?
This choice directly determines the minimum possible image size. Even for Node 22, the four options differ by an order of magnitude:
| Base image | Size | Characteristics |
|---|---|---|
node:22 | ~900MB | Full Debian; includes everything, much of it unnecessary |
node:22-slim | ~200MB | Minimal Debian with glibc retained for strong compatibility |
node:22-alpine | ~120MB | musl libc and very small, but some native modules cause problems |
gcr.io/distroless/nodejs22-debian12 | ~150MB | No shell or package manager; the most secure option |
My recommendations are straightforward:
- Use
node:22-slimby default. It offers the best compatibility, most npm packages do not include native bindings, and its size is reasonable. For a team adopting multi-stage builds for the first time, slim is the safest starting point. - Choose Alpine for the smallest possible image. Be prepared for complications: packages with native dependencies, such as
sharp,bcrypt,node-canvas, andbetter-sqlite3, may need to be recompiled for musl or may not run at all. Recent Prisma versions support Alpine, but older versions caused plenty of problems. If you use Alpine, actually run the image in CI; do not stop after confirming that the build passes. - Choose distroless when security requirements are high. It does not even contain
/bin/sh, so an attacker cannot open a shell after usingexec. The tradeoff is difficult debugging:docker exec -it <c> shfails immediately, so troubleshooting must rely on logs andkubectl cp. That tradeoff is worthwhile in production.
My choice in practice is simple: use node:22-alpine for the builder because it is fast and convenient for installing build tools, then use node:22-alpine or distroless for the runner. The builder does not need the same level of hardening because it is never deployed.
4. .dockerignore: the first gate that 90% of people forget
The first step of docker build is to package the entire build context and send it to the daemon. Without .dockerignore, even before COPY . . runs, .git (potentially hundreds of megabytes), local node_modules if dependencies were installed, .next from the previous build, node_modules, .env, IDE configuration, and *.log files are all compressed, transferred, and extracted.
The result is not just a larger image. Worse, every build becomes slower, because changes in the build context reduce cache hits.
A proper .dockerignore looks like this:
# Dependencies and build artifacts (the builder reinstalls them; do not copy host files)
node_modules
.next
out
build
dist
# Version control and CI reports
.git
.github
.lighthouseci
# Local environment and logs
.env*
*.log
npm-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Editors and operating-system files
.vscode
.idea
.DS_Store
*.tsbuildinfo
# Deployment files do not need to be included in the image
Dockerfile
.dockerignore
docker-compose*.yml
README.md
docs
In one sentence: .dockerignore is the seat belt for COPY . .. Do not proceed without it.
5. Reusing the layer cache: copy package.json before the source code
Every Docker instruction creates a layer, and the cache is matched by instruction and input. If any source file changes after COPY . ., every subsequent layer is invalidated, including RUN pnpm install. That means changing one line of application code forces every dependency to be reinstalled, wasting tens of seconds or several minutes.
The correct pattern is to place stable layers first and frequently changing layers later. Dependency lockfiles change less often than application code, so copy them first:
FROM node:22-alpine AS deps
WORKDIR /app
# Step 1: Copy only the lockfiles; this layer is invalidated only when package.json changes
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Step 2: Copy the source code only now. Source changes invalidate only the builder COPY;
# pnpm install in the deps stage hits the cache and completes in seconds
COPY . .
RUN pnpm build
Extracting dependencies into a separate deps stage is an advanced technique. It lets the dependency-installation layer be reused across builds and copied cleanly into any downstream stage. Change one line of code and the deps stage hits the cache immediately, reducing a two-minute build to 15 seconds.
Another often-overlooked detail is that pnpm is better suited to Docker than npm or yarn. pnpm uses hard links and a content-addressable store, producing a smaller and faster node_modules, while --frozen-lockfile strictly guarantees identical dependencies in CI and local development.
6. In practice: use Next.js standalone to reach 150 MB
This optimization offers the highest ROI. Next.js 13+ supports output: 'standalone'; enable it in next.config.ts:
const nextConfig = {
output: 'standalone',
};
After the build, .next/standalone/ contains a self-contained minimal Node server. It already includes the subset of node_modules required at runtime, retaining only dependencies that are actually referenced. You no longer need to COPY node_modules; use:
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Three items: the standalone server, static assets, and public files
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# If you use i18n or Prisma, add these runtime files separately
COPY --from=builder /app/src/messages ./src/messages
COPY --from=builder /app/src/generated ./src/generated
CMD ["node", "server.js"]
Note that standalone does not include public/ or .next/static/, so static assets must be copied separately. It also excludes directories read dynamically at runtime, such as messages, the Prisma client, and configuration files. Missing runtime files cause MODULE_NOT_FOUND; this is the most common standalone pitfall, and you can add the required files one by one based on the errors.
The final image consists of the node:22-alpine base image (~50 MB), the standalone server, and a small set of static assets, totaling 120-160 MB. Compared with the original 1.2 GB fat image, that is an 87% reduction.
7. Least privilege, image scanning, and build caching: the final three steps
Once the image is small, add three final measures to make it robust.
1. Run as a non-root user. Containers run as root by default, and an escape can grant root access to the host. Add a system user:
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
USER nextjs
Place USER nextjs before CMD to ensure that the process starts as non-root. In K8s, add runAsNonRoot: true as another safeguard.
2. Scan the image. Trivy, Grype, and Snyk all work; enforce a check in CI:
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
High-severity CVEs fail the build immediately. Smaller images also scan faster, creating another compounding benefit.
3. Use buildx caching. In local development and CI, use the BuildKit layer cache across branches and pull requests:
docker buildx build \
--cache-from type=registry,ref=myorg/cache \
--cache-to type=registry,ref=myorg/cache,mode=max \
-t myapp:latest .
mode=max also caches intermediate stages; the default caches only the final stage. Combined with the deps stage, builds become nearly instantaneous. Store the cache in a registry or S3 in CI so all developers can share it.
Summary
- Large images result from packaging the build toolchain, source code, and devDependencies into the runtime image. Optimization delivers three benefits at once: faster pulls, faster startup, and a smaller attack surface.
- Multi-stage builds are the first principle: the builder stage installs dependencies and builds the application, while the runner stage copies only artifacts with
COPY --from=builder. The builder is discarded, leaving no trace in the final image. - Choose the base image according to your needs: use
node:22-slimby default for compatibility,alpinefor the smallest image while accounting for native-module issues, ordistrolesswhen security is the priority and no shell is desired. .dockerignoreand layer ordering are the two gates that control build speed: keep.gitandnode_modulesout of the context; copypackage.jsonand install dependencies before copying the source so the dependency layer remains rock-solid.- Next.js
output: standaloneoffers the highest ROI, reducing the image to roughly 150 MB while packaging a minimal subset ofnode_modules.
In one sentence: Image optimization is not a stunt. It means clearly separating build artifacts from the runtime environment: deploy the artifacts, not the construction site. A 150 MB image with no shell that runs as non-root is the best deliverable you can give operations and security teams.
Related
Related Content
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.
Article
Long-Running Tool Calls: The Terminal-State Protocol for Operation, Poll, and Cancel
After the initial request times out, the tool may still be running server-side; issuing a cancel does not mean the side effects have stopped. This article establishes an operation identity, polling watermark, cancellation race handling, and a single terminal-state acceptance point for a single long-running tool call.
Article
Agent Memory Erasure Propagation: From User Forgetting Request to Verifiable Purge
Deleting one row in the primary table won't clear vectors, summaries, caches, or restored backups. This article uses a deletion state machine, generational tombstones, derived lineage, and end-to-end canary verification to truly forget.