Every PaaS migration post is written from vibes: "it was pretty easy" or "it was a nightmare," with no receipts. This one has receipts. On July 11, 2026, a one-maintainer open-source project called strava-distance-fixer migrated off Railway the week its trial credit ran out — and the entire exit fits in a single commit, f541fb7, "v2.5: migrate Railway to Fly.io, persist credentials on a mounted volume." Here is the whole bill, up front:
| Exit-cost line item | The number |
|---|---|
| Files changed | 15 (+274 / −165 lines) |
| Monthly bill before (Railway Hobby floor) | $5.00 |
| Monthly bill after (Fly.io shared-cpu-1x 256 MB + 1 GB volume) | ~$2.09 |
| Trigger | Trial credit expired; no permanent free tier |
| App code coupled to a vendor API | One function + four env vars |
| Downtime / data loss | None reported; verified end-to-end same day |
Three dollars a month is not the story. The story is the shape of the cost: a config-file rewrite, a secrets-model rethink, and a docs rewrite — and which of those three recurs at the next vendor's pricing change. That is what this post itemizes, line by line, from the actual diff.
The app in one paragraph
Strava-distance-fixer is a tiny Python webhook service with one job: it rewrites each new running activity's distance on Strava to a repeating-digit form (19.200 km → 19.19 km) by driving Strava's web Crop feature with a session cookie. Strava pushes activity-create events to the app's webhook; the app crops within seconds. One maintainer, one always-on process, a credential store, and a log file. It is about as small as a production service gets — which is exactly why it makes a good measuring stick. If this app's exit costs 15 files, your app's exit costs at least that, scaled by how much of your code knows which vendor it runs on.
Line item 1: the config-file rewrite
The cheapest line item, and the most visible. Out went the Railway contract:
# railway.toml (deleted, 6 lines)
[deploy]
startCommand = "gunicorn sync_server:app --workers 1 --threads 2 --timeout 180 --bind 0.0.0.0:$PORT"
healthcheckPath = "/"
healthcheckTimeout = 30
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3plus a one-line Procfile. In came the Fly.io contract — a 13-line Dockerfile, a 22-line fly.toml, and a .dockerignore:
# fly.toml (added, 22 lines)
app = "strava-distance-fixer"
primary_region = "sjc"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
[[vm]]
size = "shared-cpu-1x"
memory = "256mb"
[[mounts]]
source = "strava_data"
destination = "/data"Count the lines and this looks like a wash: ~7 lines of vendor config swapped for ~50. But look at what the new lines are. The Dockerfile — FROM python:3.12-slim, install requirements, run gunicorn — is vendor-neutral. It runs on Fly.io today, on Railway tomorrow, on your own Hetzner box next year. Only fly.toml is rented: auto_stop_machines, [[mounts]], primary_region are Fly.io vocabulary, and they will be rewritten again at the next exit.
That is the first lesson the commit teaches: every migration leaves behind one artifact that survives the next migration, and the job is to maximize it. Railway's Nixpacks-based build needed no Dockerfile, which felt like simplicity — until the exit, when the maintainer had to write one anyway. The platform that "saves" you from a Dockerfile is really deferring it to moving day.
Line item 2: the secrets-model rethink (the real cost)
Here is where the diff gets interesting, because this line item is measured in coupling, not lines. On Railway, the app persisted its rotating Strava tokens by writing them back into Railway itself: a function called _railway_upsert_vars() called Railway's GraphQL variableCollectionUpsert mutation, fed by four RAILWAY_* env vars (API_TOKEN, PROJECT_ID, SERVICE_ID, ENVIRONMENT_ID). Clever — and load-bearing in the worst way. Every write restarted the container, which meant a token rotation landing mid-webhook could bounce the very process handling the request.
The migration replaced all of that with a file. Two new functions, load_persisted_creds() and _write_volume_creds(), persist tokens and the session cookie to $DATA_DIR/creds.env on a 1 GB Fly volume mounted at /data. No API call, no restart, survives redeploys. The Garmin token cache, history.json, and sync.log moved onto the same volume via the same DATA_DIR variable.
Stop and notice what happened: the exit forced an architectural improvement. A file on a mounted volume is simpler, safer, and more portable than upserting env vars through a vendor's GraphQL API — but the maintainer only built it when leaving made the old design's cost undeniable. Vendor-coupled code has a way of looking "fine" until the invoice arrives.
This is also the line item that scales. This app had one function coupled to one vendor API. Now imagine the same exit for an app whose code calls Railway's API for Postgres provisioning, or reads Render's injected RENDER_* variables in twelve places, or stores uploads in a vendor-specific blob API. The config rewrite is roughly constant per migration; the decoupling cost is proportional to every vendor API your code ever touched. Audit that surface before the trial credit runs out, not after.
Line item 3: the docs rewrite and the re-verification
The diff stat says 15 files, but three of the most important ones produce no runtime behavior at all: README.md (deploy steps rewritten — fly apps create, fly volumes create, fly secrets set), .env.example (the four RAILWAY_* vars dropped, volume semantics documented), and docs/journey.md (a migration epilogue). Plus subscribe_webhook.py got its default callback URL repointed to the new fly.dev domain and its body wrapped in main() so importing it no longer fires a live API call — the kind of small hardening that always ships with a migration because the migration is the first time anyone re-read the file in months.
The webhook re-registration deserves its own callout, because it is the hidden state no file contains. Strava allows exactly one webhook subscription per app, so moving hosts meant deleting the Railway-pointing subscription and registering a fresh one against the fly.dev domain — a subscribe_webhook.py run against production, with a window where events could go nowhere if the timing was wrong. Every migration has one of these: the dashboard click, the DNS cutover, the OAuth redirect URI that lives outside the repo and therefore outside the diff.
Then the step no diff can show: verification. The changelog records it plainly — same-day end-to-end run on Fly: Garmin token loads from the volume, Strava OAuth refreshes, the session cookie crops an activity to 11.11 km from Fly's datacenter IP, volume contents survive restarts and redeploys. (One honest footnote for the ledger: the commit also deleted the app's iOS Shortcut path — a POST /sync endpoint and its auth — as opportunistic cleanup. Real migrations always bundle a little of this. The true exit cost is the migration minus the spring cleaning, and spring cleaning is never itemized.)
Docs plus verification is the line item teams consistently under-budget. Nobody's migration estimate includes "rewrite the runbook and re-prove the system works," yet it is often the longest pole — not in lines, but in wall-clock attention.
The punchline the commit log never states
Zoom out from the diff and the economics look almost silly: ~$3/month saved, maybe a Saturday spent. No CFO approves that trade. But the maintainer didn't buy savings; they bought a cheaper next exit. Before the migration, the deploy contract was rented end to end: Railway's builder, Railway's env-var API, Railway's domain. After it, the contract is mostly owned: a portable Dockerfile, a volume that is just a filesystem path behind $DATA_DIR, an env file — with only fly.toml left as rented vocabulary.
This is one app, one maintainer, one Saturday — an n=1, honestly labeled. But the pattern around it is well-trodden: "start on Railway, migrate to Fly.io when costs matter" is close to a documented playbook at this point, with the Dockerfile-as-portable-contract cited as the reason the second move stays cheap. And the ladder doesn't stop at Fly.io: the common next rung is a Hetzner VPS once the hosted bill consistently clears $40–50/month, where a single €9-and-change box absorbs the same workload. Each rung down the ladder is the same three line items — config, coupling, docs — repriced.
That reframes the question every team on a hosted PaaS should ask. Not "could we migrate?" — a one-maintainer side project just proved the mechanics fit in one commit. The question is "how much of our deploy contract do we own, and how much do we rent?" Every vendor API your app code calls, every build step only one platform understands, every secret that lives solely in a vendor dashboard is a line item on a future invoice, payable the week a trial expires, a price changes, or a feature is retired. Railway's one-time $5 trial credit and $5/month Hobby floor are reasonable prices — this is not a complaint about Railway. It is an observation about all rented contracts: the price is never the whole cost. The exit terms are the rest of it.
So here is the cheapest migration insurance available, derived directly from this commit: keep a Dockerfile in the repo even when your platform doesn't need one. Persist state to paths, not vendor APIs. Keep secrets in a file your app reads, not in calls your app makes. Each of these shrinks the next exit's diff before the next exit exists.
And if you'd rather stop paying the exit invoice entirely, consider the platform whose deploy contract is yours by construction.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with a Dockerfile-and-volume deploy contract no vendor can reprice. Star the repo on GitHub or deploy your first app today.



