Vercel → Railway Migration Plan

Migrate Eko's hosting from Vercel to Railway, consolidating web apps and workers onto a single platform.

Motivation

  • Unified hosting: Railway natively hosts long-running workers alongside web apps (5 Bun workers currently need separate infra)
  • Simpler ops: One platform, one billing surface, one deploy pipeline
  • Claude Code integration: Railway's native MCP server integration

Audit Summary

CategoryCountEffort
Hard blockers0
Config swaps5Trivial
Code changes1Small
AI rewrite0

Not used (no migration needed)

@vercel/blob, @vercel/edge-config, @vercel/analytics, @vercel/speed-insights, @vercel/kv, @vercel/postgres, AI Gateway, OIDC, Edge Functions, Vercel Firewall.


Phase 1: Pre-Migration Prep (no downtime)

1.1 Image optimization — Cloudflare Polish + CDN

Vercel's edge image CDN is replaced by routing domains through Cloudflare with Polish enabled. Zero code changes to next/image components.

Setup:

  1. Add app.eko.day, admin.eko.day, and eko.day to Cloudflare DNS (orange-cloud proxied)
  2. Point DNS A/CNAME records to Railway service domains
  3. Enable Polish in Cloudflare dashboard → Speed → Optimization → Image Optimization
    • Mode: Lossy (best compression, visually identical)
    • Enable WebP conversion
  4. Enable Auto Minify for JS/CSS/HTML while there (free bonus)

What Polish does:

  • Automatically compresses images passing through the CDN
  • Converts to WebP/AVIF based on browser Accept header
  • Caches optimized variants at 300+ edge locations globally
  • No code changes, no custom loaders, no sharp dependency needed

Cost: Included in Cloudflare Pro ($20/mo) — which you may already have since packages/r2 uses Cloudflare.

Fallback: If Cloudflare is not desired, install sharp as a self-hosted image optimizer instead:

bun add sharp --filter=@eko/web --filter=@eko/public

Next.js auto-detects sharp when self-hosting. No config changes needed — images are optimized per-request on the Railway instance and cached locally in .next/cache/images/.

1.2 Remove maxDuration export

File: apps/web/app/api/feed/route.ts

Delete the Vercel-specific line:

- export const maxDuration = 30

The route already has its own 20-second race timeout (ROUTE_TIMEOUT_MS), so this is redundant on Railway.

1.3 Remove automaticVercelMonitors from Sentry

Files: apps/web/next.config.ts, apps/admin/next.config.ts

  export default withSentryConfig(nextConfig, {
    org: 'lab90-llc',
    project: 'eko-web',
    widenClientFileUpload: true,
    webpack: {
-     automaticVercelMonitors: true,
      treeshake: { removeDebugLogging: true },
    },
  })

Sentry cron monitoring can be added back via Sentry's own cron check-in API if needed.

1.4 Remove Sentry edge config (if not using edge runtime)

Files: apps/web/sentry.edge.config.ts, apps/web/instrumentation.ts

Review instrumentation.ts — remove the NEXT_RUNTIME === 'edge' branch since Railway runs Node.js only. Keep the Node.js Sentry init.


Phase 2: Railway Project Setup

2.1 Create Railway project

Create a single Railway project with 8 services:

ServiceSourceType
eko-webapps/webWeb (Next.js)
eko-adminapps/adminWeb (Next.js)
eko-publicapps/publicWeb (Next.js)
worker-ingestapps/worker-ingestWorker (Bun)
worker-factsapps/worker-factsWorker (Bun)
worker-validateapps/worker-validateWorker (Bun)
worker-reel-renderapps/worker-reel-renderWorker (Bun)
worker-smsapps/worker-smsWorker (Bun)

2.2 Configure environment variables

Migrate all env vars from Vercel to Railway. Key variables:

# External services (unchanged)
SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
SENTRY_DSN, SENTRY_AUTH_TOKEN

# AI provider keys (unchanged — no Vercel OIDC)
ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY
XAI_API_KEY, DEEPSEEK_API_KEY, MISTRAL_API_KEY, MINIMAX_API_KEY

# Cron auth
CRON_SECRET

# Remove these (Vercel-only, auto-injected)
# VERCEL_ENV, VERCEL_URL, VERCEL_GIT_COMMIT_*, VERCEL_OIDC_TOKEN, VERCEL_TARGET_ENV

2.3 Configure custom domains

DomainService
app.eko.dayeko-web
admin.eko.dayeko-admin
eko.dayeko-public

2.4 Dockerfiles / Nixpacks

Railway auto-detects Next.js and Bun. For monorepo, configure each service's root directory and build command:

  • Web apps: Root apps/web, build turbo run build --filter=@eko/web
  • Workers: Root apps/worker-ingest, start bun run start

If Nixpacks doesn't handle the Turborepo monorepo correctly, add Dockerfiles per service.


Phase 3: Cron Migration

3.1 Railway cron setup

Railway supports cron jobs natively. For each of the 5 scheduled crons in apps/web/vercel.json:

Cron routeScheduleRailway config
/api/cron/payment-reminders0 9 * * *Railway cron service hitting https://app.eko.day/api/cron/payment-reminders
/api/cron/payment-escalation0 9 * * *Same pattern
/api/cron/monthly-usage-report0 9 1 * *Same pattern
/api/cron/account-anniversaries0 9 * * *Same pattern
/api/cron/daily-cost-report0 6 * * *Same pattern

No code changes needed — cron routes already accept Authorization: Bearer {CRON_SECRET} as a fallback alongside the Vercel-specific header.

3.2 Remaining cron routes (not in vercel.json)

These 8+ routes (ingest-news, cluster-sweep, generate-evergreen, import-facts, archive-content, validation-retry, topic-quotas, daily-digest) are not scheduled in vercel.json. Determine if they're triggered externally or are stubs, and add Railway cron entries for any that need scheduling.

3.3 Delete vercel.json

File: apps/web/vercel.json

Remove after Railway crons are confirmed working.


Phase 4: Deploy Scripts

4.1 Update package.json deploy commands

File: package.json

- "deploy:web": "vercel link --project=eko-web --yes && vercel --prod",
- "deploy:admin": "vercel link --project=eko-admin --yes && vercel --prod",
- "deploy:public": "vercel link --project=eko-public --yes && vercel --prod",
+ "deploy:web": "railway up --service eko-web",
+ "deploy:admin": "railway up --service eko-admin",
+ "deploy:public": "railway up --service eko-public",

Or rely on Railway's GitHub integration for auto-deploy on push.

4.2 Clean up Vercel artifacts

rm -rf .vercel apps/web/.vercel apps/admin/.vercel

Remove vercel from global/dev dependencies if installed.


Phase 5: Cutover

5.1 Parallel run

  1. Deploy all services to Railway
  2. Verify health endpoints: GET /api/health on each web app
  3. Run cron jobs manually once, verify execution
  4. Verify workers consume from Upstash queues correctly
  5. Test Stripe webhooks by updating webhook URL to Railway domain

5.2 DNS cutover (via Cloudflare)

DNS is managed through Cloudflare (set up in Phase 1.1). Update CNAME records for app.eko.day, admin.eko.day, eko.day to point to Railway service domains. Keep orange-cloud proxy enabled for Polish image optimization and edge caching.

5.3 Decommission Vercel

  1. Disable Vercel GitHub integration
  2. Remove Vercel projects (or leave dormant)
  3. Cancel Vercel plan if no longer needed

What stays the same

ComponentWhy
Supabase (Postgres + RLS)External service, no change
Upstash Redis (queues)External service, no change
Drizzle ORMDatabase-agnostic
AI SDK v6 + direct provider keysNo Vercel coupling
Model router (DB-driven tiers)Platform-agnostic
Stripe billingWebhook URL update only
Sentry error trackingDSN stays the same, remove Vercel-specific flags
next/fontSelf-hosts fonts at build time
proxy.ts middlewareRuns on Node.js, works anywhere
revalidatePath()Standard Next.js, works self-hosted
Turborepo buildsLocal/CI tool, not Vercel-dependent

Risk register

RiskLikelihoodMitigation
Image optimization quality differs from Vercel CDNLowCloudflare Polish provides equivalent edge optimization (WebP/AVIF conversion, global cache); test with Lighthouse before cutover
Proxy.ts latency increase (no edge execution)LowRailway regions are close to users; auth check adds ~50ms at worst
Railway monorepo build detection issuesMediumPrepare Dockerfiles as fallback for each service
Cron timing driftLowRailway cron uses standard cron syntax; same guarantees
Cold start latency for Next.js appsMediumRailway keeps services warm with health checks; configure min instances if needed

Estimated effort

PhaseTime
Phase 1: Code prep1-2 hours
Phase 2: Railway setup2-3 hours
Phase 3: Cron migration1 hour
Phase 4: Deploy scripts30 minutes
Phase 5: Cutover1-2 hours (plus monitoring)
Total~1 day