← All work

Friedrich Report · Platform migration

A paid finance newsletter moves from Ghost to its own Rails platform without losing a single member.

Rails takes over login, members, Stripe subscriptions, courses and audio articles. Ghost stays for what it's good at: writing and sending. Tens of thousands of members migrated, zero discrepancies.

Client
Friedrich Report
Period
since June 2025
Stack
Rails 8, SQLite, Hotwire, Ghost, Stripe, Cloudflare Stream, OpenRouter, Hetzner

Starting point

The Friedrich Report is a paid newsletter for investors, with articles, video courses, webinars and an overview page with stocks and entry zones. Technically it ran on Ghost with a custom theme, a Flask service for payments and courses, and a few servers on AWS.

Ghost was good for writing. For everything else it became a black box: subscription logic, courses, custom pages, reporting. The team is small, so the solution had to stay runnable by few people.

I have a weekly meeting with the team. Since June 2025 I’ve been technically responsible for the platform. I started on Ghost, with a lot of stabilization and security work. Then I did the full rebuild and the migration to Rails. These days I work more and more closely with the Friedrich Report’s own developer team.

What I build here

Out of AWS, into Docker

The old production server was still on Ghost 5, and an update meant a rebuild. In May 2026 I moved Ghost to version 6 in Docker on a Hetzner server: maintenance page on, content and database over, rows counted, DNS switched. The old server stayed up as a way back. Since then, encrypted backups go offsite every day.

Rails next to Ghost, not instead of Ghost

Since March 2026 I’ve been building a Rails 8 application that takes over everything that isn’t writing: magic link login, members, Stripe subscriptions, checkout, coupons, video courses with progress, full-text search and an admin area for the team. Ghost stays the editor and newsletter sender. Rails pulls the content through the API and renders the public site itself.

The decision was deliberately boring: one Rails app, SQLite, Hotwire, few dependencies, deployed with Docker to one server. No React, no microservice zoo.

The move in one night

We switched over on June 5, 2026. Beforehand I built a script that syncs members, Stripe customers and subscriptions from Ghost, and rehearsed it several times against real data. That evening: notice email to readers, old server frozen, final sync, DNS switched.

Afterwards I didn’t trust a green dashboard. I counted. Ghost and Rails have exactly the same number of members, and no email exists on only one side. That drift check has kept running as a standing check ever since.

Video courses on Cloudflare Stream

Vimeo had to go. I compared Cloudflare and Bunny and in July 2026 moved course playback to Cloudflare Stream. Lessons sync through structured metadata. During the migration, course members saw a short notice instead of a broken player.

Every article as audio

The editors wanted articles you can listen to. My position in the meeting: “On-the-fly TTS makes no sense anyway.” We generate one file per article. That’s how it’s built: a Ghost webhook triggers generation, Rails stores a hash of title and text and only regenerates when the text actually changes. Long articles are generated in chunks and can resume after an interruption. Audio only plays for people who are allowed to read the article. Over 200 existing articles ran through as a throttled batch.

With the team I prepared the switch to Stripe Managed Payments for international subscriptions, adjusted onboarding and reconciled stale subscription states with Stripe. In July 2026 the EU withdrawal button required by § 356a BGB (German Civil Code) was added, as a lean form with name and member email.

One design system for every page

The team built its own component library, “Friedrichwind”, based on Tailwind. I first inventoried all 32 remaining Ghost pages and decided what could go. Then I moved the Rails pages to the library, including navigation, legal pages, FAQ and sitemap. After that, 1,298 tests ran green on main. Only the articles still come from Ghost.

A custom CMS, and when to pause it

For job listings I built a small CMS in the admin, with a template, an archive and an online toggle. For articles I built a prototype with Lexxy: fixed sections like chart analysis or investment check, revisions instead of overwrites, editor on the left, preview on the right. When it became clear that the final design was still open, we deliberately put the article CMS on ice. Better to wait than to build it twice.

Operations with simple tools

I measure capacity with Rails logs, a small load probe script and clear health levels: a cheap check for uptime, and a deeper hourly one that also verifies the database backups are fresh. Production errors land automatically as GitHub issues.

How I work

Most of the work is done by agents in cloud sandboxes. I set the direction and review. Every Rails change gets a full review against 37signals conventions before merge, not just a scan for blockers. I only look at production data read-only, and changes go through pull requests to dev and then to main. Dependency updates are handled weekly by a dedicated agent skill: merge to dev first, test, then a single pull request to main. The team’s developer builds features in parallel, so I keep the branches clean and the docs in the repo current.

Results

The platform now belongs to the Friedrich Report. One server, one Rails app, Ghost as a tool for the editors. Tens of thousands of members moved over without a discrepancy, courses run on Cloudflare, articles are available as audio, and the team can build new pages from its own library.

In the last meeting the verdict was: technically everything is stable. The focus is now on the watchlist, dashboard and app, not on firefighting.

Got a similar problem? Get in touch.