Railway DB vs Granite.so: migrate your database for predictable costs

By Mark Hayes
Railway DB vs Granite.so: migrate your database for predictable costs

Railway is a great place to start a project. You click "New", pick Postgres, and a few seconds later you have a connection string. That speed is exactly why so many solo founders put their first Railway DB next to their first Railway service and stop thinking about infrastructure.

Then the invoice arrives. It isn't catastrophic, maybe $180 when you budgeted $40, but it's enough to make you open the usage tab and stare at RAM graphs you never asked for. Six weeks from launch, with ten beta users waiting, "I don't know what next month will cost" is a bad place to be.

This post covers three things: why Railway database costs are hard to predict, what you actually give up and gain by moving, and how to move your Dockerized app and database to Granite.so in an afternoon. If you decide to stay on Railway, the cost-control section is still worth reading.

How a Railway DB is billed, and why it surprises people

Railway bills on usage. At the time of writing, you pay a monthly plan fee that includes some usage credit, and on top of that you pay for what your services consume: memory and CPU over time, persistent volume storage, and network egress. Check Railway's pricing page for current rates, because they have changed more than once.

A Railway DB is not a special managed-database product in the classic sense. It's a container (Postgres, MySQL, MongoDB, Redis) running from a template, with a persistent volume attached. That model is simple and flexible, and it's billed like any other container. Several things follow from that:

  • Databases like RAM. Postgres uses memory for shared buffers, the OS page cache and per-connection work memory. A database that sits at 200 MB on day one can sit at 1–2 GB a month later just because you have more data and more connections. You pay for that memory every minute.
  • Connection storms cost money. A serverless function or a misconfigured ORM pool that opens 100 connections pushes memory up, and on usage billing that's a line item.
  • Staging environments double everything. PR environments and a permanent staging copy are handy. Each one is another database container using memory around the clock.
  • Egress is easy to forget. Connecting to your Railway DB from outside Railway's private network (a local script, a BI tool, a worker on another host) goes through the public TCP proxy and counts as network traffic.

None of this is hidden. It's all in the usage dashboard. The problem is that you can't predict it before deploying, and predictability is what a founder with an $80 monthly ceiling actually needs.

To be fair to Railway: it lets you set usage limits and alerts, and the developer experience is excellent. If your workload is small and steady and you've set a hard limit, Railway can be cheap. The trouble starts when the workload grows faster than your attention span.

Railway vs Granite.so: a fair head-to-head

Both platforms are in the same category: managed cloud infrastructure where you deploy from Git or a Docker image and don't touch servers. The differences are in the pricing model and in how much database operations you're expected to own.

ConcernRailwayGranite.so
Deploy from Git with builds on pushYesYes
Deploy a Docker imageYesYes
DatabasesTemplate containers with volumes (Postgres, MySQL, MongoDB, Redis)Managed databases with automatic backups
Object storageAvailable (check current offering)S3-compatible storage with CDN
Pricing modelPlan fee plus metered usagePlan fee with included usage credits (Hobby: $9/mo with $15 of credits)
ScalingVertical and replicas, billed by usageAutomatic scaling
Best fitFast prototyping, lots of small servicesA small production stack that needs a known monthly number

Neither column is "all good". Railway's template ecosystem is broader, and its canvas UI is one of the nicest in the industry. Granite.so is built around the idea that a founder should be able to say what next month costs before shipping, and that the database should be someone else's pager.

The question that actually decides it

Ask yourself: can I write my exact monthly infrastructure cost on a sticky note right now? If you can, and it's within budget, stay where you are. If you can't, the platform isn't serving the stage you're in.

Before you migrate: a 20-minute inventory

Most failed migrations aren't technical. Something gets forgotten. Before touching anything, list what's actually running on Railway:

  1. Services. Each web app, worker and cron job. Note the start command and whether it builds from a Dockerfile or from Railway's auto-detected build (Nixpacks or Railpack).
  2. Databases. Engine and major version. Run SELECT version(); in Postgres or SELECT VERSION(); in MySQL. You'll need a matching client version for dumps.
  3. Variables. Export them from each service's Variables tab. Watch for Railway reference variables like ${{Postgres.DATABASE_URL}}. Those resolve only inside Railway and must become real values on the new platform.
  4. Volumes. Anything besides the database that writes to disk (uploads, SQLite files, caches). Uploads should move to object storage anyway.
  5. Domains. Custom domains and their current DNS records.
  6. Traffic pattern. When are your users least active? That's your cutover window.

If your app doesn't have a Dockerfile yet, write one now. It makes the app portable to any platform, not just the next one:

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

Make sure the app listens on the port from the PORT environment variable (with a fallback) rather than a hard-coded value. That one habit saves an hour on almost every platform move.

Step 1: deploy the app on Granite.so

The app goes first, because you can deploy it, point it at a test database, and check it without any user seeing it.

  1. Connect your GitHub or GitLab repository in the Granite.so dashboard, or point the service at a Docker image you already publish.
  2. Choose the branch that should auto-deploy on push, usually main.
  3. Add your environment variables. Paste the values you exported from Railway, but leave DATABASE_URL for later. Replace any ${{...}} reference variables with literal values.
  4. Deploy and open the generated URL. A crash at this point is almost always a missing variable or a wrong port.

You don't write a platform config file for this. The Dockerfile is the contract.

Step 2: create a managed database and move your data

Create a managed database in Granite.so with the same engine and major version as your Railway DB. Automatic backups are on for managed databases, so there's no cron job for you to write. Copy the connection string it gives you.

Next, get Railway's public connection string. Inside Railway, services use a private hostname. From your laptop you need the public TCP proxy URL, usually exposed as DATABASE_PUBLIC_URL on the database service.

PostgreSQL

Use a client that matches the server's major version. The easiest way is to run it from the official image so you don't touch your local install:

export RAILWAY_DB_URL="postgresql://postgres:...@<proxy-host>:<port>/railway"
export GRANITE_DB_URL="postgresql://...your Granite.so connection string..."

# Dump in custom format (compressed, restorable in parallel)
docker run --rm -v "$PWD":/backup postgres:16 \
  pg_dump "$RAILWAY_DB_URL" -Fc --no-owner --no-acl -f /backup/railway.dump

# Restore into the managed database
docker run --rm -v "$PWD":/backup postgres:16 \
  pg_restore --no-owner --no-acl -j 4 -d "$GRANITE_DB_URL" /backup/railway.dump

Swap postgres:16 for your actual major version. --no-owner --no-acl matters: the role names on Railway won't exist on the new server, and without these flags the restore will throw permission errors.

Then check that the data arrived:

docker run --rm postgres:16 psql "$GRANITE_DB_URL" -c \
  "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"

Compare those row counts with the same query against Railway. If you use extensions (pgvector, postgis, pg_trgm), confirm they're available on the target before the real cutover.

MySQL

docker run --rm -v "$PWD":/backup mysql:8.4 sh -c \
  'mysqldump --single-transaction --routines --triggers --set-gtid-purged=OFF \
   -h <proxy-host> -P <port> -u root -p"$MYSQL_PASSWORD" railway > /backup/railway.sql'

Restore with the mysql client against the new host. --single-transaction gives you a consistent snapshot of InnoDB tables without locking them.

MongoDB

docker run --rm -v "$PWD":/backup mongo:7 \
  mongodump --uri="$RAILWAY_MONGO_URL" --archive=/backup/railway.archive --gzip

docker run --rm -v "$PWD":/backup mongo:7 \
  mongorestore --uri="$GRANITE_MONGO_URL" --archive=/backup/railway.archive --gzip
Rehearse first. Do the whole dump and restore once as a dry run while Railway keeps serving traffic. Time it. For most MVP databases (under a few GB) it takes minutes, and that number tells you how long your maintenance window has to be.

Step 3: the cutover

With a rehearsal behind you, the real switch is short:

  1. Lower DNS TTL on your custom domain to 60–300 seconds a day ahead, if you can.
  2. Stop writes on Railway. Put the app in maintenance mode or scale the Railway service to zero. If writes keep happening, the dump will be stale the moment it finishes.
  3. Take the final dump and restore it, exactly as in the rehearsal.
  4. Set DATABASE_URL on the Granite.so app to the managed database and redeploy.
  5. Smoke test. Sign up, log in, create a record, trigger a background job, upload a file.
  6. Point DNS at Granite.so and add the custom domain in the dashboard.
  7. Keep Railway paused, not deleted, for a week. It's your rollback.

For an MVP with a few thousand rows, the actual downtime is usually a coffee break. If you can't accept any downtime, you need logical replication, and honestly that's a lot of effort for ten beta users.

Step 4: move file uploads to object storage

If your Railway service writes user uploads to a volume, this is the moment to fix it. Containers on any platform should be treated as disposable. Create a bucket in Granite.so's S3-compatible storage and copy existing files with any S3 tool:

aws s3 sync ./uploads s3://your-bucket/uploads \
  --endpoint-url "$S3_ENDPOINT"

Your app keeps using the standard AWS SDK. You only change the endpoint, keys and bucket name through environment variables. Serving files through the CDN also takes load off the app.

What predictable actually looks like after the move

Here's an honest version of the budget for a typical MVP (one web service, one worker, one Postgres, a bucket):

  • Hobby plan: $9/month, which includes $15 of usage credits. A small app and database at MVP traffic will often fit inside that.
  • If you outgrow it, there's the Advanced plan ($39/month with $50 of usage credits). Credits reset monthly, and usage beyond them is billed at $1 per extra credit.

The difference from what you had isn't that usage stops mattering. It's that you start from a known fixed number with a known allowance, instead of a meter you have to watch. Check the usage view weekly for the first month so you know where you stand. After that you'll rarely need to.

Trade-off to accept: any platform with included credits can still charge for usage beyond them. Predictable means you know the baseline and where the overage would come from, not that overage can't happen. Set your resource limits deliberately.

If you stay on Railway: reduce the bill anyway

Maybe migrating isn't worth it this month. You can still take the edge off:

  • Set a hard usage limit in your workspace settings, and an alert below it.
  • Cap your connection pool. For a single small app, 5–10 Postgres connections are enough. With Prisma, set connection_limit in the URL. With node-postgres, set max on the pool.
  • Delete idle environments. Old PR environments with their own databases add up.
  • Use private networking between services so internal traffic doesn't go through the public proxy.
  • Tune Postgres memory for your actual size instead of the defaults, if your template allows it.

These help, but they're things you have to remember. That's the core argument of this post: at the MVP stage, anything you have to remember is a tax on shipping.

Checklist: Railway DB to Granite.so in an afternoon

  • Inventory services, database versions, variables, volumes and domains
  • Dockerfile in the repo, app reads PORT from the environment
  • App deployed on Granite.so from Git with variables set
  • Managed database created with the same engine and major version
  • Rehearsal dump/restore done and timed
  • Writes stopped, final dump restored, DATABASE_URL switched
  • Smoke tests pass, DNS switched
  • Uploads moved to S3-compatible storage
  • Railway paused for a week, then removed

The bottom line

A Railway DB is a fine way to start, and for some teams it's a fine way to keep going. But if the last invoice made your stomach drop, or you have to explain infrastructure spend to a co-founder who doesn't read RAM graphs, the problem isn't your code. It's the pricing model you picked for this stage.

If you want one dashboard for your app, a managed database with backups and object storage, starting from a number you can write down, try moving a staging copy of your stack to Granite.so this afternoon. Rehearse the migration, time it, and decide with real data. Then go back to onboarding those first ten users.

Keep reading

More posts tagged “Community”.
Stop S3 storage bill shock: migration to low-cost storage

Your S3 storage bill is rarely about gigabytes stored. Here is where the money actually goes, and a step-by-step plan to move your files somewhere predictable.

Fri, Oct 16, 2026
Scale 12 factor apps: 12-factor app principles on Granite.so

Your Docker container works on localhost and beta users are waiting. Here is how to apply the 12 factors in one afternoon, without Terraform.

Wed, Oct 7, 2026
Railway Pricing 2026: What Web + Worker + Postgres Costs

Railway bills the memory your services hold, by the second. Worked costs for a web app, a worker and Postgres on Hobby and Pro — and when usage-based pricing stops being cheap.

Tue, Oct 6, 2026
Ditch brittle DIY: a stable platform as a service for $9/mo

Your droplet isn't saving you money if it costs you a 6-hour outage. Here's what a platform as a service actually takes off your plate, and how to move a Docker app onto one.

Fri, Sep 25, 2026
GHCR vs Granite: Docker registry or managed deploys?

GHCR is a great place to store your Docker images — but it doesn't run them. This guide breaks down what GitHub Container Registry actually does, where teams hit the wall trying to deploy on top of it, and how to decide between rolling your own infrastructure, pairing GHCR with a managed platform, or skipping the registry question entirely. Includes a concrete GitHub Actions walkthrough and an honest take on the trade-offs.

Mon, May 11, 2026
Docker vs Kubernetes: when Docker Compose is enough

A solo-founder decision framework for choosing between Docker Compose, Kubernetes, and managed platforms. Includes a five-step decision tree, signals that you've outgrown Compose, and common mistakes early-stage teams make.

Mon, May 4, 2026
Be first in line for updates
and special pricing
Get early access to new features and exclusive discounts delivered straight to your inbox