Case study: cutting one service's Railway bill by ~95%
One public web app. One lever (outbound bandwidth). Modelled monthly bill down from ~$16.27 to ~$0.75.
All dollar figures use Cost Doctor's estimate model, not a Railway invoice. Confirm in the Railway billing dashboard before you budget.
TL;DR
| Before | After (95% offload) | Δ | |
|---|---|---|---|
| Modelled monthly cost | $16.27 | $1.37 | −$14.90 / mo |
| Of which: outbound bandwidth | $15.69 (96%) | $0.78 | −$14.91 |
| Egress measured (30 days) | ~313.7 GB | ~15.7 GB | −298 GB |
| Annualised | ~$195 / yr | ~$16 / yr | ~$179 / yr |
The lever: move static and media bytes to Cloudflare R2 + CDN. Railway keeps HTML, APIs, and tiny redirects.
Cost shape (one service, before)
| Line item | $ / mo | Share |
|---|---|---|
| RAM | $0.48 | 3% |
| CPU | $0.11 | 1% |
| Outbound bandwidth | $15.69 | 96% |
| Disk | $0.00 | 0% |
| Total | $16.27 | 100% |
Pricing model used: $0.05 / GB outbound, $10 / GB-month RAM, $20 / vCPU-month CPU, $0.15 / GB-month disk.
Cost shape after, by offload quality
"Offload" = share of the original ~313.7 GB/mo that no longer leaves Railway. Everything else is residual egress (APIs, HTML, bots, uncached paths).
| Scenario | Offload | Residual egress | Bandwidth $ | New total $ | Saved / mo |
|---|---|---|---|---|---|
| Conservative (Cost Doctor default) | 70% | ~94 GB | $4.71 | $5.30 | $10.97 |
| Strong | 90% | ~31 GB | $1.57 | $2.16 | $14.11 |
| Very strong | 95% | ~16 GB | $0.78 | $1.37 | $14.90 |
| Near-complete | 99% | ~3 GB | $0.16 | $0.75 | $15.52 |
RAM + CPU ($0.59/mo combined) don't change — they're not the lever.
Net savings after R2 / CDN overhead
| Component | Typical $ / mo (small/medium scale) |
|---|---|
| R2 storage (first GBs) | $0 (free tier) |
| R2 Class A/B operations | $0–1 |
| CDN egress to Internet | $0 (R2 has no per-GB egress) |
| R2 + CDN overhead total | ~$0–2 / mo |
So at 95% offload: ~$14.90 / mo gross saving − ~$0–2 / mo overhead ≈ $13–15 / mo net, ~$179 / year.
At 10× scale — what this looks like for a typical mid-size content site
The audit above is a real one and the numbers are small because the site is small. The percentages and rules of thumb (70 / 90 / 95 / 99% offload bands, $0.05/GB Railway egress, the 96% bandwidth share) come from real data — they do not change with scale. The absolute numbers do.
Here is the same case scaled to a typical paying-customer profile: a mid-size content / education / SaaS site with ~3 TB/month outbound traffic on the same Railway pricing model.
| Real audit (this case) | Mid-size content site (×10) | |
|---|---|---|
| Modelled monthly bill (one service) | $16.27 | $162.70 |
| Of which: bandwidth | $15.69 (96%) | $156.90 (96%) |
| Egress / 30 days | ~313 GB | ~3.1 TB |
| Bandwidth $ after 95% offload | $0.78 | $7.85 |
| New total / mo | $1.37 | $13.70 |
| Saved / mo | $14.90 | ~$149 |
| Annualised saving | ~$179 / yr | ~$1,790 / yr |
| Portfolio total (whole audit) | ~$62 / mo | ~$620 / mo |
What scales differently at this size:
| Component | Small scale | At ~3 TB/mo |
|---|---|---|
| R2 storage cost | ~$0 (free tier) | ~$5–25 / mo (depends on corpus size — paid storage is $0.015/GB-month) |
| R2 egress to Internet | $0 | $0 (Cloudflare R2 has no per-GB egress fee) |
| R2 Class A/B operations | ~$0–1 | ~$1–5 / mo |
| Net overhead | ~$0–2 / mo | ~$6–30 / mo |
| Net savings (95% offload) | ~$13–15 / mo | ~$120–145 / mo |
Why this matters for the implementation ROI: the £450 R2 cutover (small) tier on the Done-for-you page pays back in ~3 months at this scale, vs ~2.5 years on the small original audit. At 10 TB/mo egress (a content-heavy SaaS) the same fix pays back in ~3 weeks.
What we actually did (compressed implementation log)
| Phase | Action |
|---|---|
| Design | Pick prefix-based 301 for known asset trees (/uploads/, /shared/, /media/, /assets/, /documents/, science trees), extension-based 302 for media file types. Persist object keys in DB; build public URLs as [CDN_BASE_URL] + key. |
| Object storage | Create R2 bucket. Issue an API token scoped to the bucket only (read/write). Store secrets in the Railway env UI, never in the repo. |
| Sync | npm run media:verify-r2 (small Put/Delete) → then media:sync-r2 with idempotent HeadObject skip. Optional media:fix-content-types for .md / .csv. |
| Origin behaviour | Express returns 301 for configured prefixes (small response from Railway; client follows to CDN), 302 for media extensions outside those prefixes. Origin still serves HTML + APIs. |
| Health check | GET /__health/cdn exposes cdnBaseConfigured, extension302Enabled, legacyPrefix301Enabled — no secrets. Used to confirm cutover after every deploy. |
| Cutover | Verify curl -I https://[ORIGIN_HOST]/shared/... returns 301 with Location: [CDN_BASE_URL]/.... Re-run media:diagnose until DB/CMS stragglers referencing the origin host are cleaned up. |
Common pitfalls (lessons from the real cutover)
| Symptom | Cause | Fix |
|---|---|---|
PutObject Access Denied |
Read-only or wrong-scope token; bad endpoint URL; bucket mismatch | Reissue an R2 API token with read/write, bucket scope; verify with media:verify-r2 |
Origin still returns 200 for /shared/... even though "CDN configured" |
R2_BLOCK_LEGACY_PREFIXES=0 |
Remove or set to a non-disabling value; prefix 301 is independent of media extension 302 |
cdnBaseConfigured: false in health output |
R2_PUBLIC_BASE_URL only set at build time, not runtime on the PaaS |
Add it in the Railway env UI; redeploy |
| Dry run lists 0 files | Default upload roots empty | Set UPLOAD_DIRS to your real content roots |
| Bill didn't move after cutover | Crawler / bot traffic not yet migrated; URLs in DB still point at origin | Re-run media:diagnose and media:railway-egress; rewrite stragglers |
Same audit, rest of the portfolio (one snapshot)
| Service | $ / mo | Main driver | Suggested follow-up |
|---|---|---|---|
| Public content web app (this case) | $16.27 | Egress | Offload to object storage + CDN |
| Legacy API | $5.31 | RAM, idle | Confirm still used; pause / archive |
| Front-end API | $2.89 | RAM vs low CPU | Right-size memory after metrics review |
| Small API | $1.74 | RAM | Monitor |
| Worker / API | $1.68 | RAM + small net | Monitor |
| Portfolio total | ~$62 / mo | — | — |
The R2 cutover alone targets ~$11–15/mo of that ~$62/mo. Right-sizing RAM on the legacy/idle services adds another $5–7/mo of runway.
Verify the savings on your own account
- Run
npm run auditagainst your Railway workspace — note the Net column for the service you're moving. - Cut over to R2 + CDN.
- Wait one full billing window (Railway egress is metered per-GB, normalised to 30 days).
- Re-run
npm run audit. The Net column for that service should drop into the Strong → Very strong rows above. - Cross-check against Railway's own billing dashboard — Cost Doctor estimates are not invoices.
Figures are estimates for planning and communication, not tax or contractual advice. Real outcomes depend on cached-asset ratio, traffic mix, and how completely URLs are migrated.