Blog
CORS error
railwaynestjsdebuggingdevopsmytreda

The CORS Error That Wasn't a CORS Error

July 5, 20264 min read

Users on MyTreda couldn't log in. The browser console said this:

Access to fetch at 'https://*******/api/v1/auth/login' from origin 'https://mytreda.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Read literally, that's the server refusing to whitelist the frontend's origin. It wasn't that. This is one of the more common traps in web debugging: a missing CORS header almost always means the request never made it to application code, not that CORS is misconfigured.

I used Claude Code to help pull logs and narrow things down faster, but the actual reasoning — ruling theories in and out against the evidence — was still on me. Worth walking through, because two of the wrong turns were genuinely convincing.

First suspect: the CORS config itself

Obvious place to start. I checked `app.enableCors()` in `main.ts`. Origins array had `https://mytreda.com`, credentials and methods were set correctly. A recent commit had even added `https://www.mytreda.com` to the allow-list as a hardening pass. Reasonable change, but the failing request's `Origin` header was `https://mytreda.com` without `www` — already covered before that commit. Not the fix.

The actual signal was a 502

Browser console only tells you what the browser saw, not what happened upstream. Pulling the edge logs showed the request never reached the app at all:

error messgae
{ 
  "httpStatus": 502, 
  "responseDetails": "Retried single replica", 
  "upstreamErrors": [ 
    { "error": "connection refused" }, 
    { "error": "connection refused" }, 
    { "error": "connection refused" } 
  ]
}

Connection refused at the infrastructure layer. A process that isn't reachable doesn't send back any headers, and Chrome reports that as a CORS failure because that's the only category it has for "no headers came back."

Lesson worth keeping: if a CORS error shows up on an endpoint that used to work, check the HTTP status and upstream logs before touching CORS config at all.

Second suspect: a missing env var

A recent commit had added a new required environment variable, `WHATSAPP_APP_SECRET`, to startup validation. Missing required env vars are a classic way to crash an app on boot, and "process never started" fit the symptom well. Strong theory.

Checked Railway's deploy logs. The app booted cleanly, validated config, and printed `Nest application successfully started` with every route mapped, including `/auth/login`. Ruled out.

Third suspect: an OOM (Out-of-Memory) kill

Timestamps lined up too well to ignore: `Starting Container` appeared twice about five minutes apart on the same replica, and a burst of scheduled cron jobs (WhatsApp daily reports, low-stock alerts) fired right around then. The memory graph showed a spike to about 280 MB right before the restart. Cron burst causing an out-of-memory kill — specific, plausible, and it matched the timeline.

It didn't survive one check: the service's configured memory limit was 8 GB. A 280 MB spike isn't anywhere close to triggering an OOM kill. The timing lined up, but it was coincidence, not cause.

What was actually happening

Railway's service settings had **Public Networking → Target Port** set to `3000`. The app's own startup log read:

Server: http://localhost:3001

The app was listening on a different port than the one Railway was forwarding traffic to. Every request hitting an instance in that state gets connection refused, because nothing is listening on port 3000 — not just some requests, all of them.

Traced back to a single commit, bundled into an unrelated "security improvements" change, that bumped two port defaults with no explanation:

API configuration
// apps/api/src/config/configuration.ts
process.env.PORT || '3000' → process.env.PORT || '3001' 

// apps/api/src/config/env.validation.ts
PORT: number = 3000 → PORT: number = 3001

Railway's Target Port was never updated to match, and no explicit `PORT` env var was set to override the new default. The two values quietly drifted apart. That also explains the restart loop: the healthcheck at `/health` was being probed on port 3000 too, kept failing, and Railway kept cycling the deployment. That's what produced the memory-graph pattern that briefly looked like an OOM story.

The fix

Reverted both port defaults to `3000`, matching Railway's Target Port. No infrastructure change needed. The mismatch was entirely on the app side.

Takeaways

  • A CORS error on a previously-working endpoint is a symptom, not a diagnosis — check transport-level status codes before touching CORS headers.
  • Unrelated changes shouldn't ride along in a "security" or cleanup commit. The port bump had nothing to do with that commit's stated purpose and got missed in review because of it.
  • A timeline correlation is a lead, not proof. The cron-burst theory fit the clock perfectly and still turned out to be irrelevant once I checked the actual configured limit.
  • Config drift between app code and infra settings — ports, healthcheck paths, env vars — stays invisible until something restarts. Worth a periodic check that both sides still agree.