build: standalone runtime image - #592
Merged
BastiOfBerlin merged 3 commits intoAug 17, 2026
Merged
Conversation
A transient registry reset during `npm ci` fails the whole image build, which is a frustrating way to lose a release: nothing is wrong with the code and the only fix is to run it again. Retry up to five times, and raise npm's own fetch retries and timeout so a slow mirror is tolerated before the outer loop is needed at all. A genuine failure (a missing package, a lockfile mismatch) still fails five times in a row and still fails the build, just a little later.
The runtime image installs a second, production-only node_modules and copies the whole .next directory into it. Most of that is never loaded: npm resolves every production dependency, including the parts of packages the app does not import and the transitive tail behind them. Next.js can answer the question directly. `output: 'standalone'` traces the module graph reachable from the server and emits just those files, plus a server.js entry point, into .next/standalone. The runtime stage copies that instead of installing anything. Three consequences worth calling out: - The `runtime-deps` stage is gone. It existed only to produce the production node_modules, and with it goes the class of bug spliit-app#552 had to fix, where `--omit=optional` stripped sharp's platform binaries out of that install. Tracing keeps a file because something reaches it, not because of which dependency bucket it was declared in. - The Prisma CLI needs its own stage. `migrate deploy` runs at container start but the CLI is not part of the app's module graph, so nothing traces it. It cannot simply be copied out of the base stage either: that stage installs with --ignore-scripts, so @prisma/engines never fetches the schema engine `migrate deploy` needs. A small isolated install of the same pinned version, with scripts, produces a complete CLI. - The entrypoint invokes both by path. A standalone image has no package.json scripts and no node_modules/.bin on PATH, so `npx prisma` would try to fetch the CLI from the network and `npm run start` has nothing to run. `rm -r .next/cache` goes away with the wholesale .next copy that motivated it.
Every release currently rebuilds from scratch on both runners, and the slowest layer by far is the dependency install -- which only actually changes when the lockfile does. Cache to the GitHub Actions cache, scoped per architecture so the amd64 and arm64 matrix jobs do not evict each other. mode=max keeps intermediate stages too, which is what makes the multi-stage build benefit rather than just the final layer.
24 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
build: standalone runtime image
Last one in the series in #553. Three commits, 5 files, no dependency changes.
The runtime image is about 2.4× larger than it needs to be. It installs a
second, production-only
node_modulesand copies the whole.nextdirectoryon top. Almost none of that second install is ever loaded:
npm ci --omit=devresolves every production dependency, including the parts of packages the app
never imports and the transitive tail behind them.
Next.js can answer the "what is actually needed" question directly.
output: 'standalone'traces the module graph reachable from the server andemits exactly those files, plus a
server.jsentry point.Measured, not estimated
Both images built with
docker buildfrom the same commit, same base image,sizes read from
docker image inspect:docker pulltransfers)maintodayEssentially the whole difference is one layer. From
docker historyonmain:and on this branch:
The traced server that actually runs the app is 74.8 MB, against 1.05 GB of
installed dependencies.
Three consequences worth reviewing
The
runtime-depsstage is gone. It existed only to produce thatnode_modules. With it goes the class of bug fix: install optional deps in Docker runtime stage #552 had to fix, where--omit=optionalstripped sharp's platform binaries out of that install —tracing keeps a file because something reaches it, not because of which
dependency bucket it was declared in.
The Prisma CLI needs its own stage.
migrate deployruns at containerstart, but the CLI is not part of the app's module graph, so nothing traces
it. It also cannot just be copied out of the base stage: that stage installs
with
--ignore-scripts(the repo'spostinstallrunsmigrate deploy,which can't run at build time), so
@prisma/enginesnever fetches the schemaengine
migrate deployneeds. A small isolated install of the same pinnedversion, with scripts, produces a complete CLI. It reads the version out of
the base stage rather than hardcoding it, so it cannot drift from the
lockfile.
The entrypoint invokes both by path. A standalone image has no
package.jsonscripts and nonode_modules/.binonPATH, sonpx prismawould try to fetch the CLI over the network at container start, and
npm run starthas nothing to run.A bug this introduced, caught by running the image
Next.js copies
.envinto the standalone output. The build stage doesCOPY scripts/build.env .envfor its mocked values, so the first version ofthis image shipped those mocks at
/usr/app/.env— a database URL pointing atdb,S3_UPLOAD_SECRET=AAAA…,OPENAI_API_KEY=XXXX…. Today's image has nosuch file, so this would have been a regression.
Real configuration from the container environment takes precedence, so it would
not have broken a correctly-configured deployment. The bad case is quieter: a
variable the operator forgot to set would resolve to a build placeholder
instead of failing, and
POSTGRES_PRISMA_URLin particular would silentlypoint at a host called
db.The build now deletes it, and the deletion is folded into the commit that
causes the problem. Worth knowing about generally — it is a property of
output: 'standalone', not of this repo.Verified by running the image
Against a real PostgreSQL 16 with an empty database:
prisma migrate deployapplies the full migration history from scratch, andthe server starts — so the isolated CLI stage really is complete.
/api/health/readinessreturns 200, which percompose.e2e.yaml's ownreasoning proves the schema is migrated and the app is serving.
Pages, static assets and
public/files all serve.The full E2E suite passes 41/41 against the container itself, rather than
against
npm run startas in the earlier PRs in this series.feat: runtime-configurable feature flags and BASE_URL for prebuilt images #591's runtime configuration still works through the standalone build,
which was the thing I most expected to break. The same image, restarted with
a different environment:
BASE_URL+DEFAULT_CURRENCY_CODEsetrobots.txtsitemap URLhttp://localhost:3000/…https://standalone.example.com/…sitemap.xml<loc>http://localhost:3000https://standalone.example.comUSDEURAn observation for later, not part of this PR
The Prisma CLI stage is now the largest layer at 264 MB, bigger than the app
itself. Measuring an
npm install prisma@7.9.1in isolation:Roughly 140 MB of the CLI is Prisma Studio and
prisma dev— a graph-layoutlibrary and a React renderer — reached through
prisma's regulardependencies, so--omit=optionaldoes not drop them.migrate deployneeds@prisma/engines(23 MB) and@prisma/config(56 KB).I have not tried to prune it here. A hand-maintained deny-list of package
directories is exactly the kind of thing that breaks silently on the next
Prisma upgrade, and this PR is already a large enough change to the runtime
image. Flagging it as the obvious next target if image size stays interesting.
What I could not verify
linux/arm64. Only amd64 here. The change is architecture-independent —no new binaries, and tracing produces the same file list — but the CD matrix
builds arm64 natively and that leg has not been exercised.
cache-from/cache-toonly do anything on a realActions runner, so commit 3 gets its first real test when you cut a tag. It
is a separate commit and drops cleanly if you would rather not take it.
I deliberately left out the action-version bumps that were on my list for this
PR: #564 added
github-actionsto Dependabot, so it will propose them itselfwith release notes to check against, which beats me asserting a set of pins.