all posts

How to deploy a Bun app

Ajay Kumar··9 min read

Bun in production is much less exciting than Bun in a benchmark, which is a compliment. It starts fast, it installs dependencies fast, and for a plain HTTP server it mostly just works. The problems that do come up are not performance problems — they are places where a platform, a Dockerfile or a library still assumes Node, and the failure message does not mention Bun at all.

I'm Ajay, I build PandaStack. This is the practical version: what a platform needs to see to detect Bun, the three ways people get the port wrong, when the single-file build is worth it, and the compatibility edges that are still real in 2026.

Getting detected as Bun in the first place

Automatic detection reads your repository and picks a package manager. The lockfile is the ground truth, because it is the strongest evidence of how the repo was actually developed:

  • A Bun lockfile in the repo root selects Bun. This is the reliable signal and the one you should rely on.
  • No lockfile, but a `packageManager` field in package.json beginning with `bun`, also selects Bun.
  • Neither of those, and you get npm — even if every line of your code is Bun-specific. This is the single most common cause of 'it works locally, the deploy uses npm'.
Check your .gitignore. A surprising number of Bun projects ignore the lockfile out of habit carried over from another ecosystem, which removes the strongest detection signal and makes installs non-reproducible at the same time. Commit it.

Once Bun is selected, the commands follow from it: `bun install` to install, `bun run build` if you have a build step, and `bun run start` to start.

The port, and the three ways to get it wrong

Every platform assigns your app a port and expects it to listen there, on all interfaces. Bun's documentation examples mostly hardcode 3000, so this is copied wrong constantly.

const server = Bun.serve({
  // Read the platform's port; fall back for local development.
  port: Number(process.env.PORT ?? 3000),
  // Without this, Bun binds localhost and external health checks never connect.
  hostname: "0.0.0.0",

  fetch(req) {
    const url = new URL(req.url);
    if (url.pathname === "/healthz") {
      return new Response("ok");
    }
    return new Response("hello from bun");
  },
});

console.log(`listening on ${server.hostname}:${server.port}`);

The three mistakes, in order of how often I see them: hardcoding the port; setting the port but leaving the hostname at its default so nothing outside the machine can reach it; and reading `process.env.PORT` without coercing it to a number, which throws because environment variables are strings.

The start command

Give package.json a real `start` script rather than relying on a platform to guess an entry file:

{
  "name": "my-bun-api",
  "module": "src/index.ts",
  "type": "module",
  "scripts": {
    "start": "bun run src/index.ts"
  },
  "dependencies": { "hono": "^4.0.0" }
}

Note that Bun runs TypeScript directly, so there is genuinely no build step for a server — which is the main structural difference from deploying a Node or NestJS app, where the missing build step is the classic failure.

When to use bun build --compile

Bun can compile your app and its dependencies into a single executable. It is genuinely useful, and it is not always the right call.

# Produces a self-contained binary — no node_modules at runtime.
bun build src/index.ts --compile --minify --outfile server

./server

Use it when startup time matters a great deal — a function or a scale-to-zero app where you pay startup on every wake — or when you want a deployment artifact with no install step at all. Skip it when your app reads files relative to the source tree, or when a dependency loads native modules at runtime, since both interact badly with bundling. If you are unsure, deploy the ordinary way first; this is an optimisation, not a requirement.

The compatibility edges that are still real

Bun's Node compatibility is very good now, which means the remaining gaps are narrow and therefore surprising. The ones worth checking before you commit:

  • Native addons. Packages compiling against Node's C++ API are the most likely to misbehave. If you depend on one, test it on Bun early rather than at deploy time.
  • Deep internals. Anything reaching into node: internals or monkey-patching the module loader — some instrumentation and APM agents do exactly this — is the second most common source of trouble.
  • Your observability agent specifically. This is the one that catches teams out, because it works locally where you never run the agent, and then produces confusing behaviour in the one environment you cannot easily debug.

A fast way to find out: run your real test suite under Bun before you change anything about deployment. It exercises far more of the dependency tree than a health check does.

Deploying it

On PandaStack the base image pre-warms Bun through mise alongside Node, Python and Go, so a Bun repo with a committed lockfile deploys with no Dockerfile and no configuration — install, then your start script. If you want it explicit and version-controlled, commit a `pandastack.json`:

{
  "install_command": "bun install --frozen-lockfile",
  "start_command": "bun run src/index.ts",
  "port": 3000
}

`--frozen-lockfile` is worth setting deliberately: it makes the deploy fail loudly if the lockfile and package.json disagree, rather than quietly resolving to different versions than the ones you tested.

The place Bun's startup speed actually pays off is scale-to-zero. An app that sleeps when idle pays its start cost on every wake, and Bun's is small — which compounds with a platform that restores a microVM from a snapshot rather than cold-booting a container.

Pre-deploy checklist

  1. Lockfile committed and not gitignored.
  2. A `start` script in package.json that names your entry file explicitly.
  3. `Bun.serve` reads `process.env.PORT`, coerced with Number, and sets hostname to 0.0.0.0.
  4. A cheap `/healthz` route that does not touch the database.
  5. Test suite passes under Bun, not just under your editor.
  6. Install uses `--frozen-lockfile` so version drift fails the build instead of shipping.

None of this is Bun-specific except the first item, which is telling. Bun has reached the point where deploying it is mostly ordinary work, and the remaining friction is other people's tooling not having caught up.

Frequently asked questions

Why did my deploy use npm when my project uses Bun?

Almost certainly a missing lockfile. Package-manager detection treats the lockfile as ground truth, and with none present it falls back to npm regardless of what your code uses. Check that your Bun lockfile is committed and not caught by a .gitignore rule carried over from another project. A `packageManager` field in package.json starting with `bun` works as a secondary signal.

Do I need to build a Bun app before deploying?

For a server, no — Bun runs TypeScript directly, so there is no compile step and none of the missing-build-step failures that affect NestJS or other TypeScript Node apps. You would add a build step only for a front-end bundle, or deliberately with `bun build --compile` to produce a single executable.

Is bun build --compile worth it in production?

It is worth it when startup time is a recurring cost — a scale-to-zero app or a function that pays startup on every wake — or when you want a deployment artifact with no install step. It is not worth it if your app reads files relative to the source tree or depends on native modules loaded at runtime, since bundling interferes with both. Deploy normally first and treat it as an optimisation.

What still breaks on Bun in 2026?

Compatibility is good enough that the gaps are narrow and therefore surprising. The recurring ones are native addons compiled against Node's C++ API, code reaching into node: internals, and observability or APM agents that patch the module loader. Running your real test suite under Bun exercises far more of the dependency tree than a health check and will surface these before deploy day.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.