Minimal: distroless without native deps
If your app doesn’t use native modules, the smallest viable image is
gcr.io/distroless/base-debian12:nonroot — has glibc and not much
else, ~25MB.
FROM oven/bun:1.3.14 AS builder
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --no-save
COPY . .
RUN bun run build
FROM gcr.io/distroless/base-debian12:nonroot AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME="0.0.0.0"
COPY --from=builder --chown=nonroot:nonroot /app/dist/app ./
EXPOSE 3000
CMD ["./app"]With native deps (sharp, bcrypt, etc.)
Native modules need a libstdc++ in the runner. Switch to
gcr.io/distroless/cc-debian12:nonroot — same minimal style, but
has libstdc++ and libgcc:
FROM gcr.io/distroless/cc-debian12:nonroot AS runnerFor the full recipe (including fontconfig for libvips text rendering with sharp), see Distroless + sharp.
Choosing a base image
| Base image | Size | Has | Use when |
|---|---|---|---|
gcr.io/distroless/base-debian12:nonroot |
~25MB | glibc, openssl | Pure-JS app, no native modules |
gcr.io/distroless/cc-debian12:nonroot |
~30MB | cc deps + libstdc++, libgcc |
Any native dep (sharp, bcrypt, native query engines) |
debian:12-slim |
~75MB | full slim debian | Need apt-get for extra libs at runtime |
oven/bun:1.3.14-slim |
~150MB | bun runtime | Skip the binary entirely; run standalone output with bun directly |
Read-only root filesystems
The binary extracts runtime files next to itself by default. For
readOnlyRootFilesystem: true (Kubernetes) or read-only containers,
point extraction at a writable tmpfs:
ENV NBC_RUNTIME_DIR=/tmp/app# k8s: mount tmpfs at /tmp
volumes:
- name: runtime
emptyDir: { medium: Memory }Pre-extract at image build (--extract)
On first boot the binary extracts its runtime tree (server chunks,
node_modules, manifests) to NBC_RUNTIME_DIR. On generous hardware
that’s a couple of seconds — but under a Kubernetes CPU limit it can
stretch to 30s+ and trip liveness probes into killing the container
mid-boot.
server --extract runs that extraction and exits. Do it once at image
build time, and every container boot skips straight past extraction
(one manifest-file read) to serving:
FROM gcr.io/distroless/base-debian12:nonroot AS runner
WORKDIR /app
ENV NBC_RUNTIME_DIR=/runtime
COPY --from=builder --chown=nonroot:nonroot /app/dist/app ./
# Bake the extracted tree into an image layer. Exec form — no shell
# needed, works in distroless.
RUN ["/app/app", "--extract"]
CMD ["./app"]Two things to keep consistent:
- Same
NBC_RUNTIME_DIRat build and boot. The extraction manifest stamps the directory it extracted into; a different path at runtime means a full re-extraction (correct, just slow again). - Don’t mount a volume over the whole runtime dir — that would
shadow the baked layer. Mount writable storage only where writes
happen,
$NBC_RUNTIME_DIR/.next/cache(ISR/revalidation):
# k8s: tree stays a read-only image layer; only the cache is writable
volumes:
- name: next-cache
emptyDir: { sizeLimit: 512Mi }
volumeMounts:
- name: next-cache
mountPath: /runtime/.next/cacheThe binary stays self-sufficient: if the tree is missing or stale it re-extracts on boot as usual — pre-extraction is purely a fast path.
Multi-arch builds in CI
GitHub Actions example for building both amd64 and arm64:
- run: NBC_TARGET=bun-linux-x64 bun run build
- run: mv server server-amd64
- run: NBC_TARGET=bun-linux-arm64 bun run build
- run: mv server server-arm64
- uses: docker/buildx-action@v3
- run: |
docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg BINARY_AMD64=server-amd64 \
--build-arg BINARY_ARM64=server-arm64 \
-t myapp:latest --push .The cross-compilation flags are documented in Cross-compilation.
Why not just use bun --bun ./server.js instead?
The standalone-with-bun-runtime path is a perfectly valid alternative to compiling a binary. Bigger image, simpler debug story, no compiled-binary resolver quirks.
Use the binary when:
- Image size matters (~30MB vs ~150MB)
- Cold start matters (static tiers answer in ~60ms while Next boots lazily)
- You want a single self-contained artifact
Use bun + standalone when:
- You want zero next-bun-compile-specific behavior
- You’re hitting edge cases the resolver hook doesn’t handle (please file an issue!)