Skip to content

Docker

How to package the compiled binary into a Docker image, with notes on base image choice and native dep support.

Updated View as Markdown

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 runner

For 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_DIR at 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/cache

The 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!)
Navigation

Type to search…

↑↓ navigate↵ selectEsc close