If you deploy APIs on Fly.io, CORS usually stops being “just a browser thing” the moment your frontend hits production. Locally, everything works. Then your app lands behind Fly Proxy, gets a custom domain, maybe a second region, and suddenly your browser starts yelling about preflights and missing Access-Control-Allow-Origin.
The good news: Fly.io doesn’t make CORS unusually hard. The bad news: Fly.io also doesn’t magically solve it for you. You still need to decide where CORS lives, how strict it should be, and how it behaves across preview apps, custom domains, and edge-facing traffic.
Here’s the practical comparison guide I wish more teams had before shipping.
The short version
For most Fly.io deployments, you have three realistic CORS strategies:
- Handle CORS in your application
- Handle CORS in a reverse proxy sitting in front of your app
- Use permissive wildcard CORS for public APIs
Each works. Each has tradeoffs. I usually prefer app-level CORS unless I have a very good reason not to.
Option 1: Handle CORS in the application
This is the default choice for most teams. Your app decides which origins are allowed, which methods are accepted, whether credentials are supported, and which headers get exposed.
Why this fits Fly.io well
Fly.io apps often stay pretty simple operationally: app container, public service, custom domain, done. If your API server already understands request headers, it’s the cleanest place to apply CORS policy. You can use environment variables for allowed origins and keep the policy close to the code that actually needs protecting.
Pros
- Single source of truth for API behavior
- Easy per-route logic if some endpoints are public and others are credentialed
- Simple to test locally and in staging
- No extra proxy layer to maintain
Cons
- Every app stack does it differently
- Easy to mess up preflight handling
- Can get ugly with many environments like preview apps and multiple frontend domains
Example: Express on Fly.io
import express from "express";
import cors from "cors";
const app = express();
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
"https://staging-app.example.com"
]);
app.use(cors({
origin(origin, callback) {
// Allow non-browser requests with no Origin header
if (!origin) return callback(null, true);
if (allowedOrigins.has(origin)) {
return callback(null, true);
}
return callback(new Error("CORS origin denied"));
},
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["ETag", "Link", "Location", "Retry-After"],
credentials: true,
maxAge: 600
}));
app.options("*", cors());
app.get("/api/health", (req, res) => {
res.json({ ok: true });
});
app.listen(process.env.PORT || 3000);
That credentials: true matters. If you send cookies or Authorization headers from the browser, wildcard origin is off the table. You must return a specific origin.
Best use case
- Your API is consumed by a known set of browser frontends
- You need cookies, sessions, or auth headers
- You want route-level control
Option 2: Handle CORS in a reverse proxy
Some teams put CORS in Nginx, Caddy, or another proxy layer in front of the app. On Fly.io, that usually means your container runs the proxy itself, or your app is structured so the proxy is the public-facing process.
I’ve done this. It can be nice, but only if you’re disciplined.
Pros
- Language-agnostic
- Consistent behavior across multiple services
- Good for standardizing preflight responses
- Can reduce duplicated CORS code
Cons
- One more moving part
- Proxy configs become security-sensitive
- App teams may forget CORS exists because “the proxy handles it”
- Dynamic allowlists are clumsier
Example: Nginx CORS config
server {
listen 8080;
server_name _;
location /api/ {
set $cors_origin "";
if ($http_origin ~* "^https://(app|admin)\.example\.com$") {
set $cors_origin $http_origin;
}
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Expose-Headers "ETag, Link, Location, Retry-After" always;
proxy_pass http://app:3000;
}
}
This works, but notice how quickly it gets brittle. Regex matching, conditional logic, and header behavior inside a proxy config is not where I want product logic to live unless several apps truly need the same standard policy.
Best use case
- You run multiple services behind a shared proxy pattern
- You want centralized operational control
- Your CORS policy is mostly static
Option 3: Wildcard CORS for public APIs
Sometimes your API is intentionally public, browser-consumable, and has no user-specific credentials. In that case, Access-Control-Allow-Origin: * is perfectly valid.
GitHub does this for its API in at least some responses:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That’s a good real-world reminder that permissive CORS is not automatically reckless. It depends on the API model.
Pros
- Very easy to implement
- No origin allowlist maintenance
- Great for public, read-heavy APIs
- Works well for browser developer tooling
Cons
- No credentials support
- Easy for teams to overuse
- Can expose more API surface to arbitrary browser contexts than intended
Example: simple public API headers
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader(
"Access-Control-Expose-Headers",
"ETag, Link, Location, Retry-After"
);
next();
});
If you need cookies or authenticated browser sessions, stop here and do not use *.
Best use case
- Public API
- No cookies
- No browser credentialed auth
- Broad third-party consumption is expected
Fly.io-specific gotchas
Fly.io itself doesn’t rewrite your CORS policy, but the deployment model creates a few common traps.
1. Preview and ephemeral app URLs
If you spin up temporary environments with unique hostnames, your strict allowlist can become a maintenance headache fast. Hardcoding origins works until CI starts creating review apps.
My usual fix is one of these:
- allow a controlled subdomain pattern like
https://*.preview.example.comin app logic, if your framework supports safe matching - inject environment-specific allowlists at deploy time
- keep preview frontends talking to preview APIs on the same site when possible
2. Custom domains vs .fly.dev
Teams often test from https://myapp.fly.dev and later switch to https://api.example.com. Then they leave both origins allowed forever. Sometimes that’s fine. Sometimes it’s accidental surface area.
Be explicit about which domains are production-facing and remove old ones when they’re no longer needed.
3. Preflight failures hidden by app routing
A lot of apps deployed on Fly.io have clean route handlers for GET and POST, but no explicit OPTIONS handling. Browsers send preflight requests for custom headers, JSON requests in some contexts, and non-simple methods. If OPTIONS falls through to a 404 or auth middleware, CORS “randomly” breaks.
I’ve seen this more times than I care to admit.
4. Caching without Vary: Origin
If you dynamically reflect allowed origins, you should usually send:
Vary: Origin
Without it, a cache can serve the wrong Access-Control-Allow-Origin value to another client.
Example in Express:
app.use((req, res, next) => {
res.vary("Origin");
next();
});
My opinionated recommendation
For Fly.io, I’d compare the options like this:
App-level CORS
Best default
Pick this if your API has authenticated browser clients or multiple routes with different rules. It’s the most maintainable choice for most teams.
Proxy-level CORS
Best for platform teams
Pick this if you truly need standardized policy across many services and you’re comfortable treating proxy config like application security code.
Wildcard public CORS
Best for intentionally public APIs
Pick this if the API is meant to be broadly readable from browsers and does not rely on credentials.
A sane production checklist
Before shipping a Fly.io API, I’d verify these:
OPTIONSrequests return success for preflighted routesAccess-Control-Allow-Originis specific when using credentialsAccess-Control-Allow-Credentials: trueis only used when actually neededAccess-Control-Expose-Headersincludes headers your frontend readsVary: Originis set for dynamic origin handling- old Fly and staging domains are not accidentally left allowed
- CORS policy is tested from the real deployed frontend origin
If you’re also tightening the rest of your response headers, CSP and related headers deserve separate attention. For that, see https://csp-guide.com.
CORS on Fly.io isn’t special. That’s actually the point. You’re still making the same security and architecture decisions you’d make anywhere else. Fly just makes it very easy to deploy something globally before you’ve thought through who should be allowed to call it from a browser.