Back to 2026

Debugging CORS across a micro-frontend fleet on Fly.io

Listen to this articleAI narration
0:00 / 0:00

Five Next.js frontends. One Express backend. All deployed on Fly.io. The platform splits features across subdomains: share, convert, compress, resize, and feedback. Each is a standalone Next.js 15 app with its own Dockerfile, its own port, its own GTM container.

The question was simple: what environment variables does each frontend need to deploy?

The audit

Every frontend follows the same pattern. One required variable: NEXT_PUBLIC_API_URL. It points to the shared Express backend. The share and feedback apps use it heavily—file operations, feedback CRUD. Convert, compress, and resize use it only for an embedded feedback widget.

// lib/api.ts — identical across all five apps
function getApiBaseUrl(): string {
  const url = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
  if (url.startsWith("http://") || url.startsWith("https://")) {
    return url;
  }
  return `https://${url}`;
}

Because it's prefixed NEXT_PUBLIC_, Next.js inlines it at build time. For Docker, it must be a --build-arg, not a runtime -e flag.

Frontend Fleet Architectureshare:3002convert:3003compress:3004feedback:3005resize:3006Express BackendFly.io — platform-api :8080Neon (PostgreSQL)RedisS3 (Files)

Each Dockerfile sets the remaining variables internally—NODE_ENV, NEXT_TELEMETRY_DISABLED, HOSTNAME, and a unique PORT. Nothing else to configure. Clean and consistent.

Then the CORS error appeared.

The error

Access to fetch at 'https://backend.example.com/api/feedback'
from origin 'https://share.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

The feedback widget on the share subdomain makes a client-side fetch to the backend. The browser fires an OPTIONS preflight. The backend wasn't returning the right headers.

Investigating the backend

The Express backend applies cors globally before all routes:

app.use(
  cors({
    origin: process.env.CORS_ORIGINS?.split(",") || [
      "http://localhost:3000",   // web
      "http://localhost:3002",   // share
      "http://localhost:3003",   // convert
      // ...all local ports
      "https://example.com",
      "https://www.example.com",
      "https://share.example.com",
      "https://convert.example.com",
      "https://compress.example.com",
      "https://feedback.example.com",
      "https://resize.example.com",
    ],
    credentials: true,
  })
);

share.example.com is right there in the fallback list. Should work.

The curl test

We hit the preflight manually:

curl -v -X OPTIONS https://backend.example.com/api/feedback \
  -H "Origin: https://share.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type"

Response: HTTP 204. access-control-allow-credentials: true. All methods allowed. But no Access-Control-Allow-Origin header. The vary: Origin header confirmed the middleware ran—it just didn't match.

Root cause

The || operator. If CORS_ORIGINS is set to anything, the entire fallback list is ignored:

origin: process.env.CORS_ORIGINS?.split(",") || [/* fallback gone */]
The CORS_ORIGINS Override ProblemCORS_ORIGINS?.split(",") || [fallback]env var wins if set — fallback ignored entirelyenv var existsenv var unsetBuild arg baked inroot domain + www subdomainonly 2 origins in the build argFallback list ignored entirely.5 subdomain frontends → blockedHardcoded fallback used13 origins (all subdomains)share, convert, compress, feedback, resize...All frontends in the allow-list.CORS works correctly.vs

The Dockerfile accepts CORS_ORIGINS as a build arg and bakes it into the image:

ARG CORS_ORIGINS
ENV CORS_ORIGINS=$CORS_ORIGINS

No runtime env var was set on Fly.io. But the value was baked in during docker build with only two origins—matching the migration docs. The five subdomain frontends weren't included.

The trailing slash trap

While investigating, we found the origin URLs had trailing slashes:

https://share.example.com/   ← configured (with slash)
https://share.example.com    ← what the browser sends (no slash)

CORS origin matching is exact string comparison. The browser never includes a trailing slash in the Origin header. One extra character, no match, no header.

The fix

One command:

fly secrets set CORS_ORIGINS="https://example.com,\
https://www.example.com,\
https://share.example.com,\
https://feedback.example.com,\
https://compress.example.com,\
https://resize.example.com,\
https://convert.example.com" --app platform-api

fly secrets set injects runtime env vars that override the baked-in build-arg value. The app restarts automatically. CORS headers started appearing immediately.

Lessons

Build args persist. Docker's ARGENV pattern bakes values into images. Pass CORS_ORIGINS during build and it lives there forever—even if you never set it at runtime. The || fallback never fires.

The || operator is all-or-nothing. If the env var exists, the entire fallback vanishes. No merge, no append. One incomplete value means the complete hardcoded list is overridden.

Trailing slashes break CORS. https://example.com/ and https://example.com are different strings to the middleware. Browsers never send the slash. Your allow-list shouldn't include one.

curl your own preflight. The browser just says "blocked." A manual OPTIONS request shows exactly which headers come back and which don't. That's where debugging starts.