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

> Documentation Index
> Fetch the complete documentation index at: https://ramonmalcolm10.github.io/next-bun-compile/llms.txt
> Use this file to discover all available pages before exploring further.

# Docker

## 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.

```dockerfile
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`:

```dockerfile
FROM gcr.io/distroless/cc-debian12:nonroot AS runner
```

For the full recipe (including fontconfig for libvips text
rendering with sharp), see
[Distroless + sharp](/next-bun-compile/next-bun-compile/recipes/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:

```dockerfile
ENV NBC_RUNTIME_DIR=/tmp/app
```

```yaml
# 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:

```dockerfile
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):

:::caution[Monorepos: the tree is one level down]
In a workspace repo the extracted tree keeps its workspace path, so it
lands at `$NBC_RUNTIME_DIR/<workspace-path>/.next` — `/runtime/apps/web/.next`
for an app at `apps/web`, not `/runtime/.next`. The binary prints the
path at build time:

```
next-bun-compile: monorepo layout — runtime tree extracts to <NBC_RUNTIME_DIR>/apps/web
```

Both the `mkdir` in your Dockerfile and the cache `mountPath` have to use
it. Aimed at `$NBC_RUNTIME_DIR/.next/cache` they miss the tree entirely,
and the app writes ISR output to a read-only image layer — which only
fails at runtime, on the first revalidation.
:::

```yaml
# 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:

```yaml
- 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](/next-bun-compile/next-bun-compile/guides/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!)

Source: https://ramonmalcolm10.github.io/next-bun-compile/guides/docker/index.mdx
