How I Moved My Blog to Railway
My old hosting subscription ran out, and there was no backup. Here’s how I relaunched the blog on Railway and the pitfalls I ran into.

This blog used to live on a different host. At some point the subscription ran out, and the database and all uploaded images went with it. I didn’t have a backup. So the first lesson of this story showed up before the move even started: even for a small personal project, backups are not a formality.
Luckily, drafts of the posts were partly saved in my notes, so I managed to restore the texts. The infrastructure, though, had to be rebuilt from scratch. I decided to use that as a chance to set things up a bit more carefully than before.
Why Railway
I needed a few things from the new host:
- deploys from GitHub on every push, with no manual server setup;
- managed Postgres next to the app;
- persistent storage for images;
- servers in Europe;
- the ability to describe infrastructure in code, not just click around a dashboard.
Railway covers all of that out of the box, so I didn’t spend long choosing.
What the blog needs
The blog itself is Nuxt 3 with SSR, Postgres via Prisma, Google sign-in and image uploads to disk. So on the infrastructure side I need three things: a Node service, a database, and a disk that doesn’t disappear between deploys.
How it’s set up
The Railway project currently has two services, both in the EU West (Amsterdam) region:
- web — built from the GitHub repository using a Dockerfile. Every push to main automatically triggers a new deploy.
- Postgres — Railway’s managed database with its own volume. The connection string isn’t copied by hand into the web service; it’s injected as a reference:
DATABASE_URL=${{Postgres.DATABASE_URL}}.
The Dockerfile is multi-stage: dependencies are installed in one stage, Nuxt is built in another, and only production dependencies plus the built Nitro output end up in the final image. When the container starts, migrations run first and then the server starts:
CMD ["sh", "-c", "npx prisma migrate deploy && exec node .output/server/index.mjs"]There’s no demo data in production: I only run the seed locally.
Images are stored on a volume mounted at /data, and the path is set via NUXT_UPLOAD_DIR=/data/uploads. Without a volume, every uploaded file would vanish on each deploy, because the container’s file system is ephemeral. After what happened with the old host, that would have been especially ironic.
For the healthcheck I use /robots.txt — a lightweight route that responds quickly and shows the server is actually up.
Infrastructure as code
At first I described the deploy in railway.json, but almost immediately found out that Railway is dropping support for that format on December 1, 2026. So I moved the config to .railway/railway.ts using their TypeScript SDK:
import { defineRailway, github, project, service, volume } from "railway/iac";
export default defineRailway(() => {
const uploads = volume("web-volume", { region: "ams", sizeMB: 5000 });
const web = service("web", {
source: github("killingHail606/zhurbiy-com", { branch: "main" }),
build: { builder: "DOCKERFILE", dockerfilePath: "Dockerfile" },
healthcheck: "/robots.txt",
replicas: { ams: 1 },
domains: ["zhurbiy.com", "www.zhurbiy.com"],
volumeMounts: { "/data": uploads },
});
return project("zhurbiy-com", {
variables: { managed: false },
resources: [web, uploads],
});
});A few things I made a note of:
- Environment variables are not managed via IaC (
variables: { managed: false }). That way secrets never end up in the repository, and the values live only in Railway. - Always review the plan before applying it: first
railway config plan, thenrailway config apply. If you accidentally remove the volume from the config,applywill detach it from the service along with the images. - IaC commands need Node 22+. My local default was Node 18, so I run these commands with a newer version.
Pitfalls I ran into
Variables baked in at build time
Part of the Nuxt config is read not while the server is running but during npm run build. In my case that’s the auth origin (NUXT_AUTH_ORIGIN) and the site URL (SITE_URL). In the Dockerfile they’re passed as ARG. The consequence is simple but not obvious: after changing these variables you need a new build. A plain service restart won’t change anything.
The rest of the runtime config is picked up from NUXT_-prefixed variables, so a restart is enough there.
Regions and replicas
In IaC the Amsterdam region is called ams, while the railway scale command calls it eu-west. More importantly, railway scale adds a region rather than moving the service. If you don’t explicitly turn off the old replica, the service will simply run in two regions at once. For the app that’s wasted money; for the database it also means two replicas where there should be one.
Cloudflare and infinite redirects
My domain’s DNS is on Cloudflare: two CNAME records pointing to Railway domains, plus _railway-verify TXT records that Railway uses to verify the domain. Those must not be deleted.
The Cloudflare proxy (the orange cloud) can stay on, but the SSL/TLS mode has to be Full or Full (strict). In Flexible mode Cloudflare talks to Railway over HTTP, Railway redirects to HTTPS, and the site ends up in an infinite redirect loop.
Google sign-in on the new domain
After the move, Google sign-in won’t work until Google Cloud Console has a redirect URI for the new domain. In my case that’s https://zhurbiy.com/api/auth/callback/google. There’s a separate localhost URI there for local development.
Secrets in the console
The Railway CLI happily prints variable values straight to the terminal, for example with railway variables. So now I set secrets in a way that keeps the output from going anywhere:
railway variables --service web --set "KEY=$VALUE" > /dev/nullOtherwise keys easily end up in your terminal history or logs, and then you have to rotate them.
What I’d do differently
- Set up database and image backups from day one. That’s now the first item on my list.
- Describe the infrastructure in code right away, instead of starting with a format that’s about to be deprecated.
- Think through the www domain up front. I still need to set up a redirect from www to the main domain in Cloudflare.
Wrapping up
The move was forced, but useful. Now the blog deploys itself on every push, the database and images live on persistent disks in Europe, and the whole configuration is described in code and kept in the repository. And the main thing I took away from this: the infrastructure of a personal project deserves the same attention as a work one. Especially when it comes to backups.
Comments 0
to post
No comments yet. Be the first to share your thoughts.