I run a small studio with ten live or nearly live apps. Between them they translate conversations and videos as people speak, explain restaurant menus from a photo, run film party games, coach daily saving, quiz you on flags and capitals, value a home library, read a daily verse aloud, build a daily word puzzle and warn you when your train is late. Most of them have a backend. Until this month, all of those backends lived on Railway.
Railway is lovely to start on. You push, it deploys. But it bills by the minute for memory, and my bill had crept up to around $40 to $45 a month, with memory making up about 93% of it. Every new app made it bigger. I wanted one bill that didn't move.
So on 9 November I moved everything to a single Hetzner Cloud server. I didn't do it by hand. I did it with Claude Code, Anthropic's coding agent, working in my terminal with access to my repos, the Railway and Cloudflare APIs, and, once it was built, the new server.
Who did what
I want to be straight about this, because "AI did my migration" can mean anything.
My part: deciding to move, and the order. The train alerts app first, because it had no users yet, and the busiest service, the voice backend behind the two translation apps, last. I created the accounts and handed over the access: a Hetzner API token, a Cloudflare DNS token, a Tailscale invite. I changed the delivery address in the Rail Data Marketplace portal, which only I can log in to. And every irreversible step waited for my go: each cut-over, each deleted Railway project.
Claude's part: everything else. It read every Railway project and wrote down what each one ran. It wrote the plan, sized the server, built and locked it down, and set up the deploy tool. It moved each database and checked it table by table, switched the domains, and built monitoring, alerts and nightly backups. It also kept a running log of every step, with a way back written next to each one. That log is how I can write this post with exact times.
The plan
Before anything was touched, Claude listed what lived where. There were thirteen Railway projects. Some were mine to move. Some were old services with no traffic, to retire rather than move. A few belonged to other products, and it left those for me to decide.
The target was simple on purpose:
- One Hetzner Cloud server in Nuremberg, 8 vCPUs and 16 GB of memory, with Hetzner's daily backups on.
- Coolify on top, an open-source, self-hosted alternative to Railway, building the same Dockerfiles from the same branches.
- One Postgres and one Redis for everything, each app with its own database, login and Redis user, instead of a database per app.
- Cloudflare in front of every domain, with the server only accepting connections that come from Cloudflare.
- No public SSH. The server and the Coolify dashboard are only reachable over Tailscale.
- Logs, metrics and alerts in Grafana Cloud's free tier, uptime checks from outside the server with Better Stack, and nightly database dumps encrypted with age and stored in Cloudflare R2.
The estimate came to around €20 to €25 a month, flat, including backups. That's roughly half what Railway was charging, and it doesn't grow with each app.
The night of the move
Each app followed the same routine. Copy the database, compare row counts on every table, deploy on a temporary hostname and test it. Then stop Railway, take a final copy, switch the domain, and watch.
The times are from the log, all on 9 November, UTC:
- 02:15 The train alerts app: a Rust core, an API, a push dispatcher and a nightly job. 20 of 20 tables matched.
- 02:25 The SFTP server that receives the daily rail timetable moved, keeping its original host key so the sender saw nothing change. The first test delivery landed at 02:36.
- 02:47 The word puzzle. 8 of 8 tables.
- 02:52 The daily verse app and the geography quiz, which share a backend. The write gap was 59 seconds. Before switching, Claude checked that the new server returned exactly the same responses as Railway, down to the quiz's top leaderboard player.
- 03:05 The film party games: a 547 MB database. The film catalogue job ran on Hetzner ten minutes later and rebuilt 16 decks.
- 03:06 The menu explainer. 03:09 The savings coach, both of its services.
- 08:45 The home library app.
- 22:24 The voice backend for the two translation apps, the one that earns the most, after a full round of tests: live sessions, two-speaker turns, recordings, push tokens and the scheduled nudges.
By the end of the day the whole lot was using about 4.3 GB of the server's 15 GB.
The awkward part: apps hard-wired to Railway
Some of my apps, already installed on people's phones, call Railway's own *.up.railway.app addresses. I don't own those names, so I couldn't point them anywhere else.
Claude's fix was neat. It gave each backend a proper domain on my side. Then it replaced each Railway service with a tiny Caddy proxy that forwards every request to the new server. Old app versions keep working, and new builds use the new domain. Once Railway's logs show no traffic from old builds, the proxies go. They cost cents.
What went wrong
Plenty, and I think this is the useful part.
Railway kept answering for my domains. Railway's edge also sits on Cloudflare. Even after the DNS pointed at Hetzner, Railway kept winning the hostname until the custom domain was deleted on Railway's side. Deleting it then caused about two minutes of intermittent 404s while Cloudflare caught up. The lesson: do that step at the quietest hour.
The firewall locked out the deploy tool. Coolify runs commands on its own host over SSH. The firewall was tight enough that at 02:35 every Coolify start, stop and deploy began timing out. Claude found the cause and added one narrow rule.
Grafana rejected the container metrics because Coolify adds so many Docker labels that each series went over Grafana Cloud's limit of 60 labels. Claude dropped the extra labels at the agent.
A shell comment ate a password. Redis password hashes are written with a # in front. Passed through sh -c, the shell treats everything after # as a comment, so one user was created with no password at all. It was caught, and now Claude passes those commands without a shell in between.
A Firebase key lost its newlines on the way out of Railway. One library accepted it anyway. Node's crypto didn't.
The real one: the morning after. At 04:30 UTC on 10 November, the server rebooted itself to apply a kernel update. That was planned. What I hadn't planned for was this: the night before, Claude rotated every Redis password with no downtime, changing them live. But Coolify's stored copy of the Redis settings was never updated, and the log said it had been. When Docker brought Redis back up after the reboot, it came back with the old passwords. Every app that uses Redis answered with errors from 04:31 until just after 06:00: the translation apps, the savings coach, the verse app, the quiz, the word puzzle and the library app. The train alerts, the film games and the menu explainer don't use Redis, so they carried on.
Claude traced it and put the users and passwords back from the server's own record. This time it saved the settings in Coolify itself and recreated Redis, so the next reboot would start correctly. No data was lost. The rule now written in its notes is the one I'd give any engineer: after changing anything live, check that the stored config matches, and don't trust a note that says it does.
That was the lowest point. It's also the clearest example of the risk with an agent. It works fast and writes confident notes. A wrong "done" in a log looks exactly like a right one.
What made it work
- A written plan before any change. I read it and agreed it at the start, and every later step was checked against it.
- Proof at every step. Row counts per table, identical responses before a switch, health checks after. Nothing was "probably fine".
- A way back for every step, written before the step. Railway projects were only deleted after 30 minutes or more of clean traffic and a final encrypted dump in R2.
- Clear lines on what needed me. Money, deletions, cut-overs of live apps and anything only I could log in to waited for me. Everything else didn't.
- Secrets never printed. Every variable went from Railway straight into Coolify without being shown on screen or written to disk.
Where it landed
One server, one flat bill. Seven Grafana dashboards, from server load down to every request through Cloudflare. Eleven alert rules that email me. Uptime checks from outside the server. Encrypted dumps every night at 02:30, with an alert if one is missing. A Hetzner snapshot every day.
Deploys still happen when I merge. Coolify can't be reached from GitHub, so a small timer on the server checks each repo every two minutes and deploys whatever changed.
I've been building software long enough to know what this would have cost me alone. Sizing, hardening, ten careful database moves, monitoring, backups, the incident: easily a week or two of evenings, and I would have cut corners on the checks. It took a day, with the checks.
Credit where it's due. The judgement calls were mine. The work, the care in the checks, and the clean-up after the bad morning were Claude's. It did an excellent job.