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.
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 Dockerfile accepts CORS_ORIGINS as a build arg and bakes it into the image:
ARG CORS_ORIGINS
ENV CORS_ORIGINS=$CORS_ORIGINSNo 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-apifly 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 ARG → ENV 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.