kaiserlich blog

A Full Stack Migration in One Day

Back in mid 2025 I discovered Ruby LLM and was completely blown away by how nice it is. I was switching mostly to using Rails for everything around that time and it was just a magic moment. The API is beautiful, works exactly how you’d want it to.

I already had this client project, an invoice automation app. The client receives a ton of invoices, huge ones, and the app runs them through an extraction pipeline, classifies them, pulls out the data, then validates everything against different data points using various rules. I built it, and as the client wanted more automations for different invoice types, it grew and grew.

Back then I was still in this mindset of “I come from the SPA world, I can’t really work with Hotwire and this whole server-rendered stack.” But I also kept reading about going more vanilla. I read Jorge Manrubia’s post A vanilla Rails stack is plenty from 37signals and was intrigued. Still, I was using all the tools: PostgreSQL, GoodJob , the ruby-openai gem, Honeybadger , Inertia.js with React, Vite for bundling. A lot of gems to get everything running.

And it ran. Fine. I didn’t really have to touch it unless the client wanted new features.

Something Changed

This year something changed because skills unlocked something in me. I didn’t use slash commands or agents much last year, but skills really clicked. I started aggressively building them, 63 at this point, and most of them genuinely useful.

A few weeks ago, 37signals open sourced Fizzy , their kanban app. Mark Kohlbrugge analyzed 265 pull requests from the codebase and published an unofficial 37signals coding style guide . I made a skill out of it that took the guide and made it more searchable for an LLM, progressive disclosure style. The data became more useful to discover and it really advocated for the 37signals way of building apps. Very vanilla, very little dependencies, no build step.

I was like, this is so cool. And it basically teaches the agent how to do it right. I shared the skill as a gist if you want to try it.

I also took Stephen Margheim’s High Leverage Rails course on SQLite in production, which helped cement the confidence that SQLite is not just viable but actually preferable for apps like this.

Building With the New Stack

I started building small apps with this approach. An analytics platform, a calorie tracker, things that help me day to day. Nothing crazy for production, just utilities for myself. Around that time Ralph came out, the autonomous task loop pattern from Geoffrey Huntley. I was intrigued, tried it out, had a lot of attempts until I really liked the approach. Packaged it into a skill so I can scaffold Ralph loops for any project.

Especially for greenfield apps where you can just say what you want out of it, it works freaking good.

The Problem

So I have these two or three small apps that I really like, very simple, very vanilla. And then I have this one client app that uses all the old stuff. PostgreSQL, GoodJob, Inertia with React, Vite, ruby-openai, Honeybadger, Kamal, Thruster. Still kind of half Ruby LLM, half OpenAI gem. Deployed with Hatchbox on a Hetzner box.

Not really my stack anymore. I have everything in-house now. A full deployment pipeline, my Ansible project to provision servers, secure them, update them. Self-hosted GitHub runners. My own container registry with pre-built Ruby images so builds are fast. Any app I deploy gets automatic offsite backups, automatic deployment on push, SSL, the works.

This one app didn’t fit the bill anymore.

But I didn’t really want to do it. It would take too much time, who’s paying for it, and the app works fine. Why touch it?

Let’s Just Try It

One thing that gave me confidence: the app already had solid test coverage. 134 tests, 531 assertions, covering the core processing pipelines end to end. Document upload, classification, extraction, validation, reporting. The stuff that actually matters. That’s the safety net you need before you let an agent rip through your codebase.

So I was like, okay, let’s just try it. Let me write down the specs and give it to Ralph.

I know the app inside out, so writing the spec was easy. The migration plan had four phases:

  1. Database and backend. Switch PostgreSQL to SQLite. Replace GoodJob with Solid Queue . Consolidate everything to Ruby LLM. Replace Honeybadger. Move services to plain Ruby objects.
  2. Frontend. Convert all 20 Inertia/React pages to server-rendered ERB with Tailwind. Remove React, Vite, Node entirely.
  3. Deployment. Dockerfile, GitHub Actions CI/CD, data import from PostgreSQL to SQLite, Ansible playbook, DNS.
  4. Cutover. Swap DNS, decommission old infrastructure. (Manual, after QA.)

Set up Ralph, let it run. 37 iterations. Each one a fresh Claude session, reading the accumulated learnings from previous iterations, executing one task, committing, writing down what it learned, moving on to the next.

Going from cloning the database, migrating it to SQLite, migrating GoodJob to Solid Queue, migrating Inertia to Hotwire, consolidating two AI gems into one, removing eight gems from the Gemfile, converting every single page. And it just does all of it. The compound learning effect is real. What it discovers in task 3 prevents mistakes in task 15.

The Error Handler Thing

One thing I’m particularly happy about: I was using Honeybadger for error monitoring. Another third party service, another gem, another dashboard. Replaced it with a simple class that subscribes to Rails’ built-in ActiveSupport::ErrorReporter. When an error happens in production, it creates a GitHub issue with labels, deduplicates by fingerprint so the same error doesn’t flood the repo. And when that issue shows up in GitHub, the Claude app already picks it up and prepares a fix.

That’s what I had with Honeybadger before, roughly. But now it’s two external services less, a bunch of gems less, and the error-to-fix pipeline is tighter.

The Result

The app is running on a v2 domain now. Ready for me to switch over whenever I’m done with final QA.

Eight gems removed from the Gemfile: pg, good_job, inertia_rails, vite_rails, ruby-openai, honeybadger, kamal, thruster. The entire app/frontend/ directory with 50+ React components, gone. node_modules/, gone. Zero JavaScript build step.

What’s left is a Rails 8 app with SQLite, Solid Queue, Ruby LLM, server-rendered HTML. Deployed via Docker with a self-hosted runner that pushes to GitHub Container Registry. Watchtower picks up new images automatically. Ansible provisions the server. Backups run every six hours.

The app has processed around 3,000 invoices over the last few months and keeps chugging along reliably. Now it’s just much simpler to maintain.

Why It Matters

Does this matter for the client? Not really. The app worked before and works now.

Does it matter for me? A lot.

All my stacks are aligned now. There’s no big difference anymore between apps. Same patterns, same deployment, same everything. Less headache. And it enables me to hand maintenance to the agent. This is the job of my employee now.

I work long-term with customers and want to provide a good service after go-live. Less overhead, less moving parts, less external dependencies. That’s what makes it sustainable.

The whole thing was done in a day. I was doing other things alongside it. I wrote the spec, yes, but I know the app. So that was easy. The actual migration, the 37 iterations (migrating to the 37signals style in exactly 37 iterations, you can’t make this up), the deployment setup, all of it, one day.

Compounding is real.


Want help building AI automations? Let's talk