Case study · Real audit findings

$16.27 → $1.37 / mo on one service

96% of this Railway service’s bill was outbound bandwidth — ~313 GB/month at $0.05/GB. We moved static and media bytes to Cloudflare R2 + CDN. Numbers, implementation checklist, pitfalls — and a separate “at 10× scale” table for a typical mid-size content site (~$1,790 / yr saved).

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

  1. Run npm run audit against your Railway workspace — note the Net column for the service you're moving.
  2. Cut over to R2 + CDN.
  3. Wait one full billing window (Railway egress is metered per-GB, normalised to 30 days).
  4. Re-run npm run audit. The Net column for that service should drop into the Strong → Very strong rows above.
  5. 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.