Back to the blog
BunUbuntuLinuxDevOps

Bun and Ubuntu: when the system changes under your script

November 03, 2026·6 min read·Diego Horvatti

You upgrade Ubuntu on a Friday afternoon. On Monday, the deploy script that worked for two years stops working. Not a single line of your code changed. The ground did. That's the spirit of WTF Ubuntu?, a post by Chalmovsky. It's a rant from someone tired of being surprised by their own distro. I read it and immediately thought about my daily work with Bun and Ubuntu. There's a practical lesson here, and it has little to do with switching distros.

What's bothering Ubuntu users

The frustration doesn't come from one isolated bug. It comes from a pattern: Canonical makes big decisions and we only find out when something breaks.

Some examples every dev on Ubuntu has seen:

  • Snap by default. Firefox became a snap in 22.04. Slower startup, different file paths, confined permissions. Anyone with browser automation felt it.
  • Coreutils in Rust. In 25.10, Ubuntu switched to uutils, a Rust rewrite of ls, cp, date and friends. The idea is good. But early on, a date -r that behaved differently broke the system's own automatic update check.
  • sudo-rs. Also in 25.10, the classic sudo was replaced by a Rust reimplementation.

I'm not against any of these changes on their own. Memory safety is a real win. The problem is something else: coreutils is a contract. Millions of scripts depend on flags and error messages that nobody documented properly, but everybody uses.

Your build script depends on a lot more than your package.json shows.

Why this matters if you use Bun on Ubuntu

Think about your package.json. How many scripts there call the system?

{
  "scripts": {
    "clean": "rm -rf dist && mkdir -p dist",
    "build": "bun build ./src/index.ts --outdir dist",
    "stamp": "date -u +%Y%m%d%H%M > dist/BUILD",
    "release": "cp -r public/* dist/ && sudo systemctl restart app"
  }
}

Four scripts, and at least five operating system binaries: rm, mkdir, date, cp, sudo. Bun handles the TypeScript, but the rest is up to the distro. If date changes behavior, your stamp breaks. If cp handles globs or symlinks differently, your release ships incomplete.

The worst case is when there's no error at all. The command runs, returns 0 and delivers a slightly different result. You only notice in production.

If you develop on macOS and deploy to Ubuntu, you already know the drama: sed -i has different syntax on BSD and GNU. Now the drama can happen between two versions of the same Ubuntu.

Bun Shell: your script stops depending on the distro

Bun has an answer here that few people use. Bun.$, the Bun Shell, is a shell built into the runtime. And here's the key point: common commands like ls, rm, mkdir, cp, mv, cat, echo, which and basename are implemented by Bun itself. It doesn't call the system binary for those.

So that fragile script becomes a TypeScript file:

// scripts/release.ts
import { $ } from "bun";

await $`rm -rf dist`;
await $`mkdir -p dist`;
await $`bun build ./src/index.ts --outdir dist`;
await $`cp -r public dist`;

// no dependency on the distro's `date`
await Bun.write("dist/BUILD", new Date().toISOString());

And in package.json:

{
  "scripts": {
    "release": "bun scripts/release.ts"
  }
}

What you get in practice:

  • The same behavior on Ubuntu 22.04, on 25.10, on macOS and even on Windows.
  • Safe interpolation. $\rm ${path}`` escapes the variable and avoids that classic bug with spaces in folder names.
  • Errors become exceptions. If a command fails, the await throws and the script stops. No more forgetting set -e.

There are limits, of course. sudo and systemctl still belong to the system, and Bun Shell will call the real binary. If a command isn't a builtin, it looks it up in PATH like any shell would. The win is getting the trivial stuff out of the way, which is usually 80% of the script.

Pin your versions, including Bun's

Complaining that Ubuntu changed under you while leaving Bun on "latest" in CI doesn't add up. The logic applies to everything: whatever changes without you asking will break on a bad day.

In GitHub Actions:

- uses: oven-sh/setup-bun@v2
  with:
    bun-version: 1.3.1

In Docker, stay away from the latest tag:

FROM oven/bun:1.3.1-slim
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
CMD ["bun", "src/index.ts"]

--frozen-lockfile makes the install fail if the lockfile doesn't match package.json. It sounds annoying. It's exactly the behavior you want on a server.

And here's my strong opinion: in production, the host's Ubuntu version shouldn't matter to your application. If it does, you're too coupled to the machine. The host runs Docker and the kernel. Everything else comes from the image, with a pinned version, built the same way anywhere.

What about your dev machine?

On your own machine, you can't put everything in a container without losing speed. A few simple things help:

  1. Install Bun with the official script or with mise, not with snap. Snap confines the binary and can cause headaches with access to files outside your home folder and with symlinks in node_modules.
  2. Don't upgrade your distro in the middle of a delivery. Sounds obvious. Still, everyone has run do-release-upgrade on a Thursday night.
  3. Prefer LTS for work. Interim releases like 25.10 exist precisely to test changes like uutils. If you run one on your work laptop, you're the beta tester. That's fine, as long as you know it.
  4. Test the build script in a clean container before blaming the code. A docker run --rm -v $PWD:/app -w /app oven/bun:1.3.1 bun run release tells you in seconds whether the problem is yours or the environment's.

A confession: I once lost a whole morning hunting a build "bug" that turned out to be a cp alias in my .zshrc. The system hadn't changed at all. It was me. Isolating the environment also protects you from yourself.

Is it worth switching distros?

Maybe. Fedora, Debian and Pop!_OS all have fans with good arguments. But switching distros just moves the surprises somewhere else. Debian will change something one day too. Fedora changes even faster.

The real problem is letting your build depend on things you don't control. Ubuntu just made that visible. If your package.json is full of rm, cp and date, now is the time to move those scripts to Bun.$ and pin versions on everything that runs in CI. It takes one afternoon, and you stop fearing the next upgrade.

Ubuntu will keep making decisions that annoy people. It just doesn't have to be your distro that decides whether Monday's deploy works. If you want to see how I set this up in my Bun projects, take a look at what I've been building.

LinkedIn summary

I upgraded Ubuntu and the deploy broke. I hadn't changed a single line of code.

The culprit was the ground beneath it: snap, coreutils rewritten in Rust, sudo-rs. Coreutils is a contract, and millions of scripts depend on flags nobody ever documented.

I looked at my package.json and saw rm, cp, date and sudo. All of them are distro binaries. The worst bug is the one that runs, returns 0 and delivers something else.

My fix was moving the trivial stuff to Bun Shell, which implements ls, rm, cp and mkdir inside the runtime itself. Now the behavior is the same on Ubuntu, macOS and Windows.

And pin versions on everything: Bun in CI, Docker images without latest, --frozen-lockfile. In production, the host's Ubuntu version shouldn't matter to your application.

Switching distros just moves the surprise somewhere else. It takes one afternoon to stop fearing the next upgrade.

If you want to see how I set this up in my projects, the link is in the comments.

#Bun #Ubuntu #DevOps #JavaScript #Linux