DevInsight

A developer's field notes

DevOps
0 viewsAbout 6 min read

The Paradox of Thinner Images and Bulkier Builds

Optimizing Next.js/Node.js containers with multi-stage builds and standalone output can cut size by 80% while build time actually grows, and cache invalidation can force dependencies to be re-downloaded on every deploy. This article lays out, in the form of a decision memo, the criteria for what should be trimmed and what should never be touched, and points out where optimization backfires.

Published by DevInsight.

#Docker#Next.js#Node.js#multi-stage 빌드#레이어 캐시#standalone#이미지 최적화#빌드 시간#DevOps

Moving a Next.js app to a multi-stage build and enabling standalone output shrinks image size by about 80%. But measure wall-clock time in the deploy pipeline and the build is often slower, not faster. Dependencies get re-downloaded every time, and caches keep getting invalidated. The image gets thinner while the time grows. This paradox comes from the surface of image optimization telling a different story than the substance.

Only the image got thinner, the build is still the same size

A multi-stage build splits into three stages. A stage that only installs dependencies, a stage that runs next build, and a runtime stage that only carries the output. The last stage just copies the standalone directory, .next/static, and public. No devDependencies, no full source. That's why the image is light. Images that used to exceed 1GB often drop to around 150MB. Just looking at that number, the optimization looks like a success.

The contradiction shows up in the next step. Standalone is produced by next build tracing the source and its dependencies. That means the build stage needs all the devDependencies—TypeScript, ESLint, tailwind—present. The image you deploy is thin, but the build that runs on every deploy is still heavy. You made the body lighter; you didn't make the engine smaller.

That heavy build keeps breaking the layer cache. Docker rebuilds every layer after the one that changed, no matter how small the change. That's why the convention is to copy package.json and the lockfile first, run npm ci, and copy the source afterwards. The intent is to keep the node_modules layer cached even when a single line of source changes.

This convention holds under one condition: the lockfile must not change often. In a repo where hand-run npm install makes the lockfile fluctuate, or dependency-update PRs land every day, the npm ci layer keeps breaking. Every time, all dependencies get re-downloaded. The image is thin, but a build that took 2 minutes now takes 10. The point is that cache viability rests on lockfile stability, not on layer ordering.

The more common mistake isn't ordering but the copy approach. Put npm ci after COPY . . and any source change invalidates the dependency layer. Without a .dockerignore, node_modules, .next, and .git get shipped into the build context in full and transferred to the daemon every time. No matter how well you set up the cache policy, that transfer cost remains. If the context is 300MB, every single-line source change shuttles 300MB back and forth. The file that fixes this isn't the Dockerfile—it's a single .dockerignore.

Decision memo: what to trim, what not to touch

Before starting optimization, here are three criteria worth writing down.

The runtime stage is genuinely safe to trim. Use standalone and don't copy node_modules wholesale. Splitting .next/standalone, .next/static, and public into separate COPY statements also buys you layer separation. There's one trap here. Standalone doesn't include static assets. Skip the .next/static copy and the image will throw 404s—and you'll only discover that at runtime, after a successful build. That's why a first deploy can go up cleanly with the static files entirely missing.

The build stage is a target for time, not size. This image isn't deployed anyway. The goal instead is to eliminate the dependency re-download on every build. BuildKit's cache mount does exactly that.

FROM node:20-bookworm-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci COPY . . RUN --mount=type=cache,target=/root/.npm npm run build

Adding --mount=type=cache,target=/root/.npm keeps the tarball cache on the builder machine and avoids re-downloading without creating a new layer. npm ci still reconstructs node_modules, but it never hits the network. If your team uses private registry auth, you should also check that this cache doesn't hold auth tokens. Teams on pnpm can reach the same goal faster by pre-populating the global store with pnpm fetch.

The cache mount has blind spots too. If the CI runner is a fresh machine every time, the local cache dies with that build. Unless you set up buildx remote cache or registry cache, the mount cache is nearly useless in that environment. Where the cache survives depends on the environment. The right order is to check first whether you're on a local build, an always-on runner, or a runner that's spun up fresh each time.

Don't touch the ordering or the lockfile. Copy the package files first, keep the discipline of using npm ci, keep the lockfile stable. If any of these three breaks, expect the dependency layer to break about once a day. There's one more condition that's easy to miss. npm ci installs devDependencies too, unless NODE_ENV=production. It doesn't matter if you never rebuild node_modules in the runtime stage—but if npm is still present in the base image and someone accidentally runs npm ci again, image size quietly returns to where it started. No log, no error, no sign that a devDependencies layer just appeared.

Where the thinned image falls apart

Backfires usually come from something that wasn't an optimization target. Switching to Alpine to shave a few dozen more MB is the classic example. Native modules like sharp need musl binaries, not glibc. With no prebuilt binary available it falls back to compiling from source, and at that moment you need python3 and gcc. Even if compilation finished in the build stage, if the runtime stage's Node version differs from the build stage's, binary compatibility breaks. Saving 20MB and then failing two deploys is a cost you can't easily make back. That's why so many teams settle for node:20-bookworm-slim.

Pinning the Node version is on the same axis. If the build stage runs Node 20 and the runtime stage runs Node 18, the artifacts the standalone output carries can misbehave at runtime. Pull the base version for both stages into a single ARG and the mismatch itself disappears. Most deploys that violate Next.js's minimum Node version trace back to exactly this.

The third failure point is validating by image size alone. The SIZE column in docker image ls is storage, not deploy cost. The time to pull an image from the registry depends on compressed transfer size. Measuring transfer bytes with docker save | gzip -c | wc -c immediately reveals whether the size optimization was wasted effort. Use docker history for per-layer footprint and the CACHED marker in the build log for cache hits. If you haven't recorded wall-clock time before and after the optimization, ask yourself what the current work is even for.

One criterion to keep

Reading this optimization as a size-versus-time tradeoff will always leave you lost. Don't ask what you trimmed—ask which layer gets rebuilt every time. The criterion is one: when you change a single line in a source file, does the node_modules layer's cache break? If it does, tracing whether the cause is the lockfile, the COPY ordering, or context transfer is the starting point. The team that cut a 1GB image to 100MB did less for itself than the team that took dependency installation from 10 minutes per deploy to zero. On the next deploy, instead of opening the image size report, open the CACHED lines in the build log.

Comments

Loading comments.

Good Follow-up Reads

Posts connected to the topic you just read.

View all DevOps
DevOps

삽질 없이 CI를 줄이는 캐시 3종 세트

GitHub Actions에서 의존성 캐시와 빌드 캐시를 도입했는데도 정작 빌드가 느리거나, 캐시가 오히려 잘못된 결과를 재사용하며 깨지는 경험을 해봤다면 이 글이 답이다. cache와 setup-*의 동작 차이, 매트릭스 분할 전략, 캐시 무효화 판단 기준을 함정과 함께 정리해 실패 없이 CI 시간을 단축하는 법을 다룬다.

#GitHub Actions#CI#캐시#빌드 최적화
DevOps

INFO, WARN, ERROR만으로는 부족하다

console.log에서 JSON 로그로 가는 건 시작일 뿐이다. 로그 레벨을 모호하게 정의하면 알람이 무의미해지고, 스키마 없이 쌓은 로그는 검색조차 불가능하다. 마스킹을 미루면 개인정보가 로그 플랫폼에 그대로 노출된다. 급증하는 로그 비용도 간과할 수 없다. 이 글은 로그 레벨 기준, 공통 필드, 마스킹, 비용 거버넌스 등 구조화 로깅 도입 전에 반드시 정해야 할 결정들을 기록한다.

#구조화로깅#로그레벨#observability#DevOps
DevOps

프론트엔드 개발자가 배포에서 벗어나는 순간

Vercel이 'Develop. Preview. Ship.'으로 압축한 것은 단순한 마케팅 문구가 아니다. 로컬 개발부터 프로덕션 배포까지 원클릭으로 연결하는 경험은 프론트엔드 개발 문화를 재정의하고 있다. 이 글에서는 Vercel이 만들어낸 배포의 투명화와 그 이면에 있는 기술적 트레이드오프, 그리고 팀이 겪는 현실적인 도전을 짚어본다.

#Vercel#프론트엔드#배포#CI/CD

Previous post

The Night You Let Go of console.log: Five Questions to Answer Beforehand

DevInsight Digest

Keep every new article in one calm feed.

Follow the full publication feed without promotional alerts.

Subscribe to RSS