We run an internal Nuxt service on GKE. It talks to the Kubernetes API from inside the cluster, manages some infrastructure, nothing exotic. Last year we moved it from a Node base image to oven/bun, and it mostly went fine. This is a field report on the part that didn't, because the failure mode was quiet and the debugging took longer than the migration.
why we switched
The honest answer is CI time. Our npm install step took a couple of minutes on a cold cache, and bun install did the same work in seconds. Multiply by every PR build and it adds up. Nitro already supported the Bun runtime, the app had no native addons, and the dependency tree looked clean. It felt like a low-risk win.
We changed the Dockerfile base to oven/bun:alpine, swapped the CMD to bun server/index.mjs, ran the test suite, clicked around staging. Everything passed. We shipped it.
what worked
Credit where due, most of the pitch held up.
Installs got dramatically faster. Cold installs in CI went from minutes to under ten seconds, and local installs stopped being a coffee break. This alone paid for the migration.
The image got smaller. The oven/bun:alpine image plus our app came out meaningfully leaner than the equivalent Node Alpine build, partly the base image, partly that Bun didn't need some of the toolchain layers we'd been carrying.
Memory usage dropped. Steady-state RSS on the pods came down noticeably, enough that we tightened the memory requests. I won't quote exact numbers because they're workload-specific, but the direction was consistent across restarts.
Startup was quicker too, which made rollouts and crash-loop recovery a little less tense.
So far, the brochure version. Now the other part.
what broke
A few days after the rollout, a feature that lists cluster resources started returning empty results. No crash, no 500s, no restarts. The logs showed TLS errors from the Kubernetes client, the kind you get when certificate verification fails against a self-signed chain.
Some background on how in-cluster auth works. A pod talks to the Kubernetes API using a service account token and a cluster CA certificate, both mounted at /var/run/secrets/kubernetes.io/serviceaccount/. The API server's certificate is signed by that cluster CA, not by anything in the public trust store. So every request has to present that CA as trusted, or verification fails.
We were on @kubernetes/client-node v1.x, which routes its requests through node-fetch. To trust the cluster CA, the library builds a custom https.Agent with the CA loaded into it and passes it to node-fetch via the agent option. Under Node, this works. It has worked for years.
Under Bun, the agent option never took effect. No warning, no error at the point of configuration. The requests went out without the cluster CA, TLS verification failed against the API server's cert, and the library surfaced that as request failures. From the app's perspective, the Kubernetes API was just unreachable.
The part that made this painful is the silence. If Bun had thrown "agent option not supported," we'd have fixed it in an hour. Instead the config appeared to apply and the failure showed up two layers away, dressed up as a certificate problem, in code we hadn't touched.
the debugging detour
My first theory was the CA file itself, so I exec'd into the pod and verified the mount. Fine. Second theory was NODE_EXTRA_CA_CERTS, the usual escape hatch for custom CAs. I pointed it at the mounted CA and restarted. It didn't rescue the client's requests, and at the time I couldn't find a clear statement of how completely Bun honored that variable across its socket paths. Maybe it works for some code paths now. That's exactly the problem, "maybe" is not a property you want in your trust chain.
The breakthrough was reproducing outside the cluster. I wrote a ten-line script that made an HTTPS request to a server with a private CA, passing the CA through a custom agent the way the client library does. Run with node, it connected. Run with bun run, verification failed. Same script, same machine, different runtime. That was the whole bug in miniature, and it took ten minutes once I stopped assuming the two runtimes behaved the same.
the fix
We stopped fighting the abstraction. For the handful of in-cluster API calls this service makes, we dropped the client library's request layer and used node:https directly, passing the CA in the request options.
import { readFileSync } from 'node:fs';
import { request } from 'node:https';
const SA_DIR = '/var/run/secrets/kubernetes.io/serviceaccount';
const ca = readFileSync(`${SA_DIR}/ca.crt`);
const token = readFileSync(`${SA_DIR}/token`, 'utf8');
function k8sGet(path: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const req = request(
{
host: process.env.KUBERNETES_SERVICE_HOST,
port: 443,
path,
ca,
headers: { Authorization: `Bearer ${token}` },
},
(res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => resolve(JSON.parse(body)));
},
);
req.on('error', reject);
req.end();
});
}
Bun's node:https implementation honors the ca option in request options, and that's been stable for us since. We kept the client library's types and config parsing and replaced only the transport. It's more code than I wanted to own, and it's also code whose behavior I can verify in one place instead of trusting a compatibility matrix.
Could we have pinned an older client version that used a different HTTP stack, or patched node-fetch? Probably. I preferred the option with no hidden layers, given that this exact class of hidden layer caused the outage.
reproduce with bun run, not node
The process lesson matters more than the fix. Locally, everyone was running the service with familiar Node tooling. Tests ran under Node. The dev server ran under Node. Only the container ran Bun, so the one environment that exhibited the bug was the one nobody used day to day.
Now the rule is simple. If prod runs Bun, dev and CI run bun run, including one-off repro scripts. A repro under node proves nothing about a Bun deployment. This sounds obvious written down. It did not feel obvious during the week we spent staring at certificate errors that no one could reproduce locally.
The same reasoning applies to any environment-shaped config. Don't trust NODE_EXTRA_CA_CERTS, custom agents, proxy env vars, or TLS knobs under Bun until you have watched them work under Bun, in a test that fails when they don't.
when I'd stay on Node
Bun is still in that image and I'd make the same call again for this service. I would not make it for every service. Stay on Node, at least for now, if any of these describe you.
- Your dependencies configure HTTP behavior through custom agents, proxies, or private CAs. This is the exact seam that bit us.
- You rely on native addons or anything that compiles against Node internals.
- You depend on mature APM, profiling, or heap-analysis tooling. The Node ecosystem there is years ahead.
- You have compliance requirements around TLS behavior (corporate CAs, FIPS-adjacent policies) where "silently different" is unacceptable.
- Your team can't or won't run Bun everywhere. A prod-only runtime is a standing invitation for this whole story to repeat.
- The service is critical and the win is only install speed. You can get fast installs in CI with caching and keep Node in the runtime image.
None of these are permanent objections. Bun's Node compatibility improves steadily, and half the items on this list may age badly. They're just the questions I'd answer before switching, rather than after.
The takeaway I keep coming back to is that "drop-in replacement" describes the happy path, not the guarantee. Bun ran our app, passed our tests, and served traffic while ignoring a security-relevant option without a word. The wins were real and we kept them. But the migration wasn't done when the tests went green. It was done when we'd verified, under Bun, every place our code touches the runtime's edges.
Top comments (0)