Skip to content

build: standalone runtime image - #592

Merged
BastiOfBerlin merged 3 commits into
spliit-app:mainfrom
BastiOfBerlin:up-14-standalone-image
Aug 17, 2026
Merged

build: standalone runtime image#592
BastiOfBerlin merged 3 commits into
spliit-app:mainfrom
BastiOfBerlin:up-14-standalone-image

Conversation

@BastiOfBerlin

Copy link
Copy Markdown
Collaborator

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_modules and copies the whole .next directory
on top. Almost none of that second install is ever loaded: npm ci --omit=dev
resolves 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 and
emits exactly those files, plus a server.js entry point.

Measured, not estimated

Both images built with docker build from the same commit, same base image,
sizes read from docker image inspect:

uncompressed compressed (what a docker pull transfers)
main today 1.6 GB 316.2 MB
this branch 670 MB 143.5 MB
−58% −55%

Essentially the whole difference is one layer. From docker history on main:

1.05GB  COPY /usr/app/node_modules ./node_modules
41.5MB  COPY /usr/app/.next ./.next

and on this branch:

264MB   COPY /opt/prisma-cli/node_modules ./node_modules
74.8MB  COPY /usr/app/.next/standalone ./
3.43MB  COPY /usr/app/.next/static ./.next/static

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-deps stage is gone. It existed only to produce that
    node_modules. With it goes the class of bug fix: install optional deps in Docker runtime stage #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 also cannot just be copied out of the base stage: that stage installs
    with --ignore-scripts (the repo's postinstall runs migrate deploy,
    which can't run at build time), 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. 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.json scripts and no node_modules/.bin on PATH, so npx prisma
    would try to fetch the CLI over the network at container start, and
    npm run start has nothing to run.

A bug this introduced, caught by running the image

Next.js copies .env into the standalone output. The build stage does
COPY scripts/build.env .env for its mocked values, so the first version of
this image shipped those mocks at /usr/app/.env — a database URL pointing at
db, S3_UPLOAD_SECRET=AAAA…, OPENAI_API_KEY=XXXX…. Today's image has no
such 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_URL in particular would silently
point 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 deploy applies the full migration history from scratch, and
    the server starts — so the isolated CLI stage really is complete.

  • /api/health/readiness returns 200, which per compose.e2e.yaml's own
    reasoning 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 start as 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:

    no runtime vars BASE_URL + DEFAULT_CURRENCY_CODE set
    robots.txt sitemap URL http://localhost:3000/… https://standalone.example.com/…
    sitemap.xml <loc> http://localhost:3000 https://standalone.example.com
    new-group currency USD EUR

An 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.1 in isolation:

43M  @prisma/studio-core      34M  effect          26M  @electric-sql
19M  @prisma/dev               7.7M elkjs           7.2M react-dom

Roughly 140 MB of the CLI is Prisma Studio and prisma dev — a graph-layout
library and a React renderer — reached through prisma's regular
dependencies, so --omit=optional does not drop them. migrate deploy needs
@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.
  • The GHA layer cache. cache-from/cache-to only do anything on a real
    Actions 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-actions to Dependabot, so it will propose them itself
with release notes to check against, which beats me asserting a set of pins.

claude added 3 commits August 17, 2026 18:13
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.
@BastiOfBerlin
BastiOfBerlin merged commit 6a84c44 into spliit-app:main Aug 17, 2026
1 check passed
@BastiOfBerlin
BastiOfBerlin deleted the up-14-standalone-image branch August 17, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants