Website migrated to Ruby on Rails 8

code projects
#rails #ruby #docker #fly-io

For most of the life of this blog (since 2008), it has been powered by WordPress. In olden days, I organized WordPress conferences and meetups in Seattle.

WordPress served me well for a long time, but I’ve increasingly wanted something simpler to maintain, easier to version control, and with simpler management that doesn’t involve logging into wp-admin all the time and updating WordPress, worrying about PHP, or otherwise dealing with a WordPress website. (Managed WordPress would alleviate many of these problems, but I've self-hosted WordPress on a Virtual Private Server for a long time.)

I started to investigate the migration earlier this year, and this week, on Christmas Eve, I finally did it! I migrated my website to Ruby on Rails 8. (Cursor + Opus 4.5 helped me get over the final barriers.)

What I wanted out of the migration

  • Keep the site simple and fast. Web performance matters.
  • Make content file-based (Markdown in git). No database. (I use Sanity and recommend it for content-heavy sites, but I don't need a full CMS on this website.)
  • Preserve the date-based blog URLs (/blog/YYYY/MM/DD/${slug}).
  • SEO friendly: robots.txt, opengraph, and a sitemap.
  • Make things deployable with Docker.
  • Host it somewhere I like: Fly.io
  • Have a way to do QA beyond “click around and hope”.

The stack

Here’s what I’m using now (high-level):

File-based content (Markdown, not a CMS)

The core change is that posts and pages now live in the repo:

  • Posts: output/posts/**.md
  • Pages: output/pages/*.md

Posts and pages use YAML frontmatter (title, date, etc.), and the body is just Markdown. I put my opengraph information in here, too, for SEO.

Markdown rendering is handled with Redcarpet.

Routing + archives

I wanted to keep classic blog URLs and archives:

  • /blog (index, paginated)
  • /blog/:year/:month/:day/:slug (individual posts)
  • /blog/:year, /blog/:year/:month, /blog/:year/:month/:day (archives)

Rails makes this easy, and it also makes it easy to keep adding polish (pagination, nav states, etc.). I've got more cleanup to do, but the basics were faster to do than I thought.

Docker + Fly.io deployment

I got a standard Rails Docker file from the initial Rails 8 generator and that made it super easy to start this.

This included a multi-stage Dockerfile to help optimize build speeds. Upon deciding to use Fly.io, it took me one Cursor prompt w/ Opus to go from container to a CI/CD setup using fly.toml and a GitHub Actions workflow that runs flyctl deploy after CI passes.

QA: validate the migration with the sitemap

The part I didn’t want to skip was QA.

When you migrate a site, the failure modes are endless:

  • a route that worked before now 404s
  • titles don't match
  • Open Graph metadata is missing
  • “it loads” but the content is wrong

So I created a small validator using a Bun script that:

  1. Parses public/sitemap.xml
  2. Fetches each page from the old site and the new site
  3. Compares status codes and <title>
  4. Produces a report (migration-report.md)

This gave me a quick way to validate all my pages were migrated on localhost and that I hadn't lost any pages (or added any extra ones) in the migration.

Cleanup along the way

As I wrapped up the move, I cleaned up parts of the scaffolding that aren't part of how the site runs today (for example, some Kamal setup that I don't need with Fly).

What's next

Merry Christmas and a Happy New Year! I'm enjoying time wrapping up the holidays with my family and expect to keep experimenting with Rails, Cursor, and leading LLMs (currently Claude Opus 4.5) in 2026.

If you've got questions, reach out.