I was debugging a CI failure when I finally had enough. The build was failing in production but working fine locally.
After digging through logs, I found the culprit: our ruby:3.3-alpine3.19 Docker image had Node 18, but we’d just
updated to ruby:3.3-alpine3.20 which ships with Node 20.11.0. The Dockerfile silently pulled in a different Node
version, and suddenly Yarn was included via Corepack when it wasn’t before, breaking our build process.
Here’s the thing that frustrated me most: this wasn’t obvious. When you see ruby:3.3-alpine3.20, you’re thinking about
Ruby 3.3. The .20 looks like a patch version. But that 3.20 is actually the Alpine Linux version, and each Alpine
version bundles a different Node.js version. Want to know which one? Good luck finding that without digging through
Alpine’s package database.
That’s when I started looking at Bun. Not because I was chasing performance benchmarks or jumping on the latest hype
train, but because I wanted to stop playing detective with Node versions hiding in Docker images. And here’s the thing:
it worked.
What is Bun (and why should Rails developers care)?
Bun is a JavaScript runtime, similar to Node.js, but it’s designed to be faster and all-in-one. It bundles a package
manager, test runner, and bundler into a single binary. For Rails developers, here’s what matters: we’re not replacing
Ruby with Bun. We’re just swapping out the Node.js runtime that Rails apps use for asset compilation and JavaScript
bundling.
If you’re using jsbundling-rails with esbuild, webpack, or rollup, and cssbundling-rails for Tailwind CSS, PostCSS,
or Sass, Node.js is sitting there in your stack purely to run these build tools. Bun can do the same job, but with some
nice benefits:
- Faster dependency installation (no separate package manager needed)
- Single binary to manage instead of Node + npm/yarn/pnpm
- Drop-in compatibility with existing Node.js tools
- Explicit version control (you choose the version, not your Docker base image)
The compelling bit for me wasn’t the speed (though that’s nice). It was the control. When you use ruby-alpine Docker
images, you get whatever Node version Alpine decided to bundle. With Bun, you explicitly install the version you want,
and it stays that version until you change it.
My setup before the switch
Let me give you some context. I was working with Rails 8 projects, using jsbundling-rails with esbuild for JavaScript
and cssbundling-rails with a mix of Tailwind CSS and Sass depending on the project. Pretty standard Rails 8+ setup,
really.
In a typical Rails app using these gems, Node.js gets involved at several points:
- When you run
bin/dev, it starts watching your JavaScript and CSS files - Your
Procfile.devhas entries likejs: yarn build --watch - In production,
rails assets:precompileruns Node to bundle everything - Your
package.jsonhas build scripts that Node executes
Node itself isn’t doing the heavy lifting — esbuild and Tailwind are — but Node is the runtime executing these tools.
That’s where Bun slots in.
The hidden Node version problem in Docker
Using asdf locally for version management works fine. I had different Node and Yarn versions across projects in my
.tool-versions files, and asdf handled the switching automatically. Not ideal, but manageable.
The real problem was in production. Most Rails projects I worked on used ruby-alpine Docker images. They look like
this:
FROM ruby:3.3-alpine3.19
When you look at that, you’re thinking “Ruby 3.3, great.” But here’s what you’re actually getting:
- Ruby 3.3.x (the version you care about)
- Alpine Linux 3.19 (determines everything else)
- Node 18.x (because Alpine 3.19 ships with Node 18)
- Yarn? Not included—you need to install it separately with Node 18
Now when your team updates the Dockerfile to get a newer Ruby patch version:
FROM ruby:3.3.5-alpine3.20
You’ve just changed:
- Ruby 3.3.5 (expected)
- Alpine Linux 3.20 (probably noticed)
- Node 20.11.0 (surprise! 🎉)
- Yarn is now included via Corepack (another surprise!)
The .20 looks like a minor version bump. It’s actually a completely different operating system version that ships
different Node and system packages. And nowhere in your Dockerfile does it say “Node 20.11.0” explicitly. You have to
know that Alpine 3.20 bundles Node 20, or you find out when CI fails.
And here’s the Yarn twist: newer Node versions (16.10+) include Corepack, which provides Yarn. Older Node versions
don’t. So your Dockerfile might need RUN npm install -g yarn for older Alpine versions, but not for newer ones. The
same Dockerfile that works with Alpine 3.19 breaks with Alpine 3.20 because now Yarn is already there via Corepack, and
you’re potentially installing a different version on top of it. It’s version management inception.
With Bun, this problem goes away:
# Install Bun explicitly at the version you choose
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.0.25"
One version, explicitly declared, same everywhere.
Making the switch
I decided to migrate one project first, expecting it to be a learning experience. It took me about half a day, though
honestly, most of that was reading Bun’s documentation, figuring out the bun.config.js setup, and being overly
cautious. The actual changes were straightforward once I understood the pattern.
Installing Bun
Since I was already using asdf for version management, I added the Bun plugin:
asdf plugin add bunasdf install bun latestasdf global bun latest```
If you're using Homebrew, it's even simpler: `brew install bun`. There's also a curl script if you prefer that.
### Installing dependencies
First thing I did was remove `node_modules` to start fresh:
```bash
rm -rf node_modulesbun install```
This is where I noticed the first difference. It was noticeably faster than `yarn install`, though I didn't time it
specifically. Bun creates a `bun.lockb` file (a binary lockfile) instead of `yarn.lock` or `package-lock.json`. I
committed this and removed the old lockfile.
### Updating build scripts
This was the part I expected to be painful, and it required a bit more than just swapping `yarn` for `bun`. I needed to
add a `bun.config.js` file to handle the JavaScript bundling. I copied this from a fresh Rails 8 app:
```javascript
// bun.config.js
import path from 'path';
import fs from 'fs';
const config = {
sourcemap: "external", entrypoints: ["app/javascript/application.js"], outdir: path.join(process.cwd(), "app/assets/builds"),};
const build = async (config) => {
const result = await Bun.build(config);
if (!result.success) { if (process.argv.includes('--watch')) { console.error("Build failed"); for (const message of result.logs) { console.error(message); } return; } else { throw new AggregateError(result.logs, "Build failed"); } }};
(async () => {
await build(config);
if (process.argv.includes('--watch')) { fs.watch(path.join(process.cwd(), "app/javascript"), {recursive: true}, (eventType, filename) => { console.log(`File changed: ${filename}. Rebuilding...`); build(config); }); } else { process.exit(0); }})();
Then I updated my package.json:
// package.json
{
"scripts": { // JavaScript build now uses bun.config.js instead of calling esbuild directly "build": "bun bun.config.js", // For Tailwind, use bunx to run the CLI "build:css": "bunx @tailwindcss/cli -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css --minify" }}
For projects using Sass instead of Tailwind, the CSS build command stayed the same:
"build:css": "sass ./app/assets/stylesheets/application.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules"
The bun.config.js approach is cleaner than having long esbuild commands in package.json, and it’s what Rails 8
generates by default now when you use Bun.
Updating Procfile.dev
My Procfile.dev needed updating to use the new build commands:
# Procfile.dev - Before
web: bin/rails server -p 3000
js: yarn build --watch
css: yarn build:css --watch
# Procfile.dev - After
web: bin/rails server -p 3000
js: bun run build --watch
css: bun run build:css --watch
The JavaScript watcher now runs bun.config.js with the --watch flag, and the CSS build uses bun run instead of
yarn.
Updating .tool-versions
I updated my .tool-versions file to replace Node and Yarn with Bun:
# Before
nodejs 20.10.0
yarn 3.6.0
ruby 3.3.0
# After
bun 1.0.25
ruby 3.3.0
Much cleaner.
Testing in development
At this point, I ran bin/dev and held my breath. Everything worked. Hot reloading worked. Asset compilation worked.
The browser console had no errors. It was almost anticlimactic how smooth it was.
Updating Docker and CI/CD
The production side took a bit more thought, but this is actually where Bun shines. I was using Docker with GitHub
Actions for CI/CD. Instead of switching to Bun’s official Docker image (oven/bun), I decided to install Bun explicitly
in my existing ruby-alpine Dockerfile.
Here’s the key change:
ENV BUN_INSTALL=/usr/local/bun
ENV PATH=/usr/local/bun/bin:$PATH
ARG BUN_VERSION=1.3.5
RUN curl -fsSL https://bun.sh/install | bash -s -- "bun-v${BUN_VERSION}"
# Install node modules
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
Notice that I’m explicitly specifying bun-v1.0.25. When the team updates the Alpine version in the future, the Bun
version won’t silently change. It’s right there in the Dockerfile, version-locked, same as Ruby and gems.
For GitHub Actions, I updated my workflow to install Bun instead of Node:
# .github/workflows/ci.yml
# Before - using Node with Yarn
- uses: actions/setup-node@v4
with: node-version: 20 cache: 'yarn'
# After - using Bun's official GitHub Action
- uses: oven-sh/setup-bun@v1
with: bun-version: latest # or pin to a specific version like '1.0.25'
Both the Docker build and CI pipeline worked on the first try. This probably shouldn’t have surprised me given how well
the local development switch went, but it did.
What went smoothly (and what surprised me)
The whole migration was easier than I expected. Here’s what stood out:
Complete compatibility: Every npm package I was using worked with Bun. esbuild, Tailwind, Sass, all the PostCSS
plugins—everything just worked. No compatibility issues, no weird errors, nothing.
Minimal changes: The main changes were adding a bun.config.js file (which I copied from a new Rails app), updating
the build scripts, and swapping the lockfile. Nothing complex, no debugging, no head-scratching moments.
Version consistency: This was the big win for me. My local .tool-versions file says bun 1.0.25, my Dockerfile
says bun-v1.0.25, and my GitHub Actions workflow uses the same version. No more detective work figuring out which Node
version Alpine bundled this month.
No more hidden version changes: When someone updates the Docker base image, the Bun version doesn’t change unless we
explicitly change it in the Dockerfile. It’s right there, visible, version-locked.
Removed Node entirely: After migrating my second and third projects, I uninstalled Node and Yarn completely from my
machine. One less thing to manage.
The biggest surprise? How anticlimactic it all was. I’d psyched myself up for troubleshooting and debugging, but there
wasn’t any. It just… worked.
The benefits I actually noticed
Let me be honest about what improved and what didn’t. I didn’t see dramatic speed improvements in day-to-day
development. Asset rebuilds felt about the same. Hot reloading wasn’t noticeably faster. If you’re switching to Bun
purely for speed in your Rails workflow, you might be disappointed.
Where I did notice improvements:
Dependency installation: When adding a new package, bun add <package> was faster than the equivalent yarn or npm
command. Not earth-shattering, but nice.
CI/CD pipeline: My GitHub Actions builds got a bit faster, mostly from quicker dependency installation. We’re
talking saving a minute or two, not halving the build time.
Mental overhead: I don’t think about JavaScript runtime versions anymore. When I start a new Rails project, I add
bun 1.0.25 to .tool-versions and the Dockerfile, and I’m done. No checking which Node version ships with which
Alpine version. No wondering why it works locally but fails in CI.
Easier debugging: When something breaks, I know my local environment is running the exact same Bun version as
production. It’s not “probably the same” or “close enough”—it’s identical.
Onboarding: New team members only need to install one JavaScript runtime instead of Node + a separate package
manager. The .tool-versions file is simpler to understand, and there’s no “wait, which Yarn version does this project
use?” confusion.
Should you switch?
After using Bun across 2-3 Rails projects for a few months now, I’d absolutely recommend trying it. Here’s my honest
assessment:
Switch to Bun if:
- You’re using ruby-alpine Docker images (or any base image where Node version is implicit)
- You’re tired of Node versions changing when you update your base image
- You’re working across multiple Rails projects with different Node/npm/Yarn versions
- You’re starting a new Rails project and want to keep things simple
- You’re comfortable being an early adopter
- You’re on Rails 7+ using jsbundling-rails and cssbundling-rails
Stick with Node if:
- You have complex custom build scripts that you don’t want to risk breaking
- Your team values stability over simplicity
- You’re working in a conservative environment where new tools need extensive vetting
- Your deployment pipeline is heavily optimized for Node and changing it isn’t worth the effort
For me, the risk was minimal. Bun’s Node.js compatibility meant I could easily roll back if something broke. But nothing
broke, and I haven’t looked back.
What’s next
Now that I’ve got Bun running smoothly across my Rails projects, I’m curious about bun test. Most Rails apps don’t
have extensive JavaScript test suites, but for the ones that do, Bun’s built-in test runner might be worth exploring.
It’d be nice to remove Jest or Vitest from the stack if Bun’s test runner can handle it.
Final thoughts
If you’re reading this and thinking “should I try this?”, my answer is yes. The worst case is you spend an afternoon
experimenting and then roll back. The best case is you stop debugging mysterious CI failures caused by Alpine Linux
version bumps.
I went into this expecting it to be a fun experiment. I came out of it having removed Node.js from my machine entirely
and never having to Google “which Node version does Alpine 3.X ship with?” again. That’s probably the best endorsement I
can give.
For Rails developers specifically, Bun feels like a natural fit. We’re already comfortable with tools that prioritize
convention over configuration and developer happiness. We version-lock our gems, we version-lock our Ruby—why not
version-lock our JavaScript runtime too? Bun makes that possible. Give it a shot.