DEV Community

Cover image for Four Ways to Deploy a Bun Server
John Fay
John Fay

Posted on Originally published at jfay.dev on

Four Ways to Deploy a Bun Server

Most Bun Dockerfiles start with FROM oven/bun:1 and never move again. That tag is an alias for oven/bun:1-debian — the full Debian image, not slim — and it floats, which bit me later. There are three other ways to ship the same server, and I wanted to know what I was giving up by not using them.

So I benchmarked all four. I got a clear, satisfying answer, wrote it up, then re-ran the benchmark properly and got a completely different one. The second answer is boring, and it's the one that's true.

Michael Scott - Tell me more

The four options

  1. oven/bun:1-slim — Debian-slim, full Bun runtime, has apt and a shell. One step down from the oven/bun:1 default.
  2. oven/bun:1-distroless — Bun on Google's distroless. No shell, no apt, no package manager.
  3. oven/bun:1-alpine — Bun on Alpine + musl. Busybox shell and apk included.
  4. bun build --compile + chainguard/glibc-dynamic — a single self-extracting binary on a hardened base. No Bun runtime in the image at all.

First, check what you're actually shipping

Before the base image matters, what you copy into it does. Plenty of Bun Dockerfiles are still a single stage: copy the source in, bun install, CMD ["bun", "run", "start"]. That ships your whole toolchain to production — the linter, the formatter, TypeScript, the test runner, every @types/* package, and the tests themselves. None of it executes once the build is done.

The gap isn't subtle. node_modules in my project is 1.9 GB. The image that serves traffic is 186 MB. Almost all of that difference is build-time machinery that exists so bun build can run exactly once.

And it costs more than disk. Every dev dependency is a package your scanner has to read, so you spend real time triaging a CVE in a formatter that will never be invoked in production. Every extra binary in the image is one more tool available to anyone who gets a shell inside it.

The fix is a build stage that stays behind: install and bundle in a full oven/bun:1, then copy only the output into the runtime stage. All four images below do this — it's how a 1.9 GB node_modules becomes an image measured in hundreds of megabytes, and it means the numbers that follow compare base images and nothing else.

Which raises the obvious question. If the runtime stage holds nothing but a bundle, why does it still need a Bun runtime sitting next to it? That's what bun build --compile answers — one file, runtime embedded, nothing else in the image.

The numbers

A dedicated Hetzner CPX31 — 4 vCPU, amd64, Bun 1.3.14. The container under test is pinned to 2 cores and 512 MB via cgroups, and the load generator (oha) runs on the other two so it never steals CPU from the thing it's measuring. 5 s warm-up, then four 20 s runs at 50 concurrency against a health endpoint, first discarded.

Image Size Cold start RPS RSS
slim 285 MB 538 ms ~23.4k rps 64 MiB
distroless 193 MB 587 ms ~22.8k rps 78 MiB
alpine 186 MB 629 ms ~22.2k rps 71 MiB
compiled 186 MB 548 ms ~23.2k rps 76 MiB

Throughput is identical. The whole spread — 22.2k to 23.4k — is narrower than the run-to-run noise on any single image. There is no winner. Cold start doesn't separate them either, and --compile notably does not win it: Bun's runtime already starts fast enough that there's no daylight to take.

Size is the only axis that actually moves. Compiled and alpine tie at 186 MB, distroless sits at 193 MB, and slim carries about 100 MB of Debian you will never once use.

Why the base image can't matter

Watch the container under load and it sits at ~105% CPU. Not 200%. I gave it two cores and it is using one.

Bun.serve is single-threaded. One process saturates one core and stops, so the bottleneck is the Bun event loop — not the base OS, not libc, not the package manager you removed. Swapping Debian for distroless for musl for a bare chainguard base moves the thing that isn't the constraint. A single-threaded JS HTTP workload genuinely cannot tell what it's standing on.

That's the whole result. Once you see the CPU number, the identical throughput column stops being surprising and starts being obvious.

One process per core

The fix isn't a base image, it's reusePort. Fork a process per core, let every one bind the same port, and the kernel load-balances across them through SO_REUSEPORT. No reverse proxy, no worker library:

const workers = Number(process.env.WEB_CONCURRENCY) || navigator.hardwareConcurrency;

if (workers > 1 && !process.env.IS_WORKER) {
  for (let i = 1; i < workers; i++) {
    Bun.spawn([process.execPath], { env: { ...process.env, IS_WORKER: "1" } });
  }
}

Bun.serve({ port, reusePort: true, fetch: app.fetch });

Enter fullscreen mode Exit fullscreen mode

Two things bite. navigator.hardwareConcurrency reports the host's cores, not your cgroup limit — give a container 2 CPUs on a 16-core box and it will cheerfully fork 16 workers to fight over them. Hence the WEB_CONCURRENCY override.

And under --compile, the obvious way to re-exec yourself is wrong. process.argv[0] is the sentinel string "bun", which isn't a file that exists in a minimal image, and argv[1] points into the embedded /$bunfs/ virtual filesystem. You have to spawn process.execPath.

reusePort is also a no-op on macOS, so local dev quietly stays single-process — one more reason laptop numbers and container numbers don't compare.

How to pick

Speed is off the table, so this is a question about size and attack surface.

Compiled on chainguard if attack surface is what you're optimizing. Ties for smallest, and there's no Bun binary on $PATH, no shell, no busybox, no package manager — one self-contained executable and dramatically less for a scanner to flag. The cost is that --compile is younger and still has corners: the process.execPath one above, plus import.meta.dir resolving through the embedded virtual filesystem, which made a package.json read fail silently until I switched to a JSON import. Both share a shape — they pass locally, build clean, and only fall over inside the image.

Distroless if you want the smaller evolutionary step. 7 MB more than compiled for none of the --compile maturity risk. It keeps the runtime as its own layer, so a Bun CVE means rebuilding one stage with your bundle untouched, and you can still get a shell by running the same Bun version from the full image.

Alpine if you run many small instances and want a real shell during incidents. Tied for smallest, and the musl tax I thought I measured turned out not to exist. You give up attack surface for the busybox ergonomics.

Slim only if you need apt at runtime. You're carrying ~100 MB for it. It's the thing you replace last, not first.

I shipped two wrong results before this one

Both were measurement, not Bun.

First : compiled came out at roughly half the throughput of slim. I was running unlimited containers on a laptop under Docker Desktop, so the compiled image ate a cold filesystem cache during its window while everything else had been warmed by earlier runs. Fixed with cgroup limits and a discarded first run.

Second, and more embarrassing : with those fixed, I got a clean story — distroless ~20% ahead of slim, alpine paying a ~10% musl tax. It was tidy, it was explicable, I wrote it up. Then I re-ran on a real amd64 host with the load generator pinned off the server's cores, and both gaps vanished. They had been my laptop's scheduler all along.

So: use cgroup limits. Discard the first run. Keep the load generator on different CPUs than the thing you're measuring. And check that your server is actually using the cores you gave it before you attribute anything to a base image — the ~105% CPU reading would have saved me both write-ups.

Dwight - False

What I shipped, and the caveats

Compiled on chainguard. Speed is a wash, so it came down to size and surface, and there it wins outright. On a team with an on-call rotation I'd have taken distroless — interactive debugging is worth more when the person paged at 3am isn't you.

Hold this loosely in two ways. It's a single-host benchmark on a workload that's mostly "JSON 200 OK"; real traffic with database contention will rank differently. And it's Bun 1.3.14 on oven/bun:1.3.14-*, May 2026 — pinned explicitly, because at the time the floating oven/bun:1 tag still resolved to 1.3.13. Re-check on bumps, and re-check on a host that matches your deploy architecture, because neither image sizes nor per-core throughput survive the trip across arch.

Coming next: the same four on Bun 1.4

Same Dockerfiles, same cgroup limits, same isolated load generator, re-run on 1.4 — this time with clustering on from the start, so the numbers describe a server using its whole box. The question I want answered is whether the base image stays irrelevant once the event loop isn't the ceiling. I'll post the numbers either way, including the boring outcome where nothing changed.

Subscribe to the RSS feed if you want it when it lands.

What's your go-to base image for Bun in production, or are you still relying on the default Debian tag? Do you agree with skipping the runtime entirely via --compile, or do you prefer having a fallback shell? Let me know in the comments below!

Top comments (0)