I’ve seen the same CORS mess play out on Hetzner boxes more than once: the app works locally, staging kind of works, then production starts throwing browser errors that look random until you realize the reverse proxy, the API, and the frontend all disagree about who is allowed to talk to whom.
This case study comes from a very normal setup on Hetzner Cloud:
- frontend on
app.example.com - API on
api.example.com - Nginx on the VPS as reverse proxy
- Node.js API behind it
- TLS terminated at Nginx
- a second environment for previews on
*.staging.example.com
The team had deployed cleanly. DNS was right. Certificates were fine. Curl looked fine. The browser was not fine.
The symptom
The frontend made a fetch() call with credentials:
fetch("https://api.example.com/account", {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json"
}
});
In Chrome DevTools, they saw:
Access to fetch at 'https://api.example.com/account' from origin 'https://app.example.com'
has been blocked by CORS policy:
The value of the 'Access-Control-Allow-Origin' header in the response must not be '*'
when the request's credentials mode is 'include'.
And sometimes this one:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Classic. Two different failures, same root problem: a half-configured CORS setup split between Nginx and the app.
The “before” setup
This was the original Nginx config on Hetzner:
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
}
}
Looks harmless. It isn’t.
Three problems jumped out immediately:
Access-Control-Allow-Origin: *with credentials is invalid.add_headerin Nginx does not reliably apply to every response unless you usealways.- There was no explicit preflight handling for
OPTIONS, so some requests never got the headers they needed.
The Node app was also trying to help, which made things worse:
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Credentials", "true");
next();
});
That combo is just broken. Browsers reject it, and they should.
Why Hetzner deployments hit this a lot
Hetzner isn’t the cause. The architecture is.
On a Hetzner VPS, you usually manage the whole stack yourself:
- Nginx or Caddy at the edge
- app server behind localhost
- maybe Docker, maybe systemd
- maybe one box running frontend and API together
- maybe a load balancer later
That flexibility is great, but it means CORS can get configured in the wrong layer. I’m opinionated about this: pick one layer to own CORS and make the other layers stay out of it.
For most Hetzner deployments, I prefer this split:
- Nginx handles TLS, proxying, and maybe static assets
- the application decides CORS because it knows the real allowed origins, routes, and credential requirements
If you do it in Nginx, do it completely. Don’t do half in Nginx and half in Express.
The browser behavior that fooled the team
Simple GET requests sometimes looked okay in curl:
curl -i https://api.example.com/public
They saw:
HTTP/2 200
access-control-allow-origin: *
content-type: application/json
So they assumed CORS was configured.
But the frontend wasn’t just doing simple public requests. It also sent cookies and custom headers, which triggers stricter browser checks and often a preflight OPTIONS request first.
That preflight must succeed before the browser sends the real request.
What “good” looks like in the wild
A useful sanity check is how public APIs expose CORS. For example, api.github.com returns:
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
That works because GitHub’s public API model is designed for broad access and not browser cookies. The wildcard is fine there.
For a private Hetzner-hosted API using session cookies, wildcard is the wrong move. You need explicit origins.
The fix
We ripped out the duplicate CORS logic and moved it into the Node app.
After: Express CORS with explicit origin allowlist
const express = require("express");
const app = express();
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
"https://preview-42.staging.example.com"
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With"
);
res.setHeader(
"Access-Control-Expose-Headers",
"ETag, Link, Location, Retry-After"
);
res.setHeader("Access-Control-Max-Age", "600");
}
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});
A few details matter here:
Access-Control-Allow-Originmirrors the requesting origin only if it’s allowed.Vary: Originprevents caches from serving the wrong CORS response to the wrong site.Access-Control-Allow-Credentials: trueis only used with explicit origins.Access-Control-Expose-Headersis there because frontend code needed access toETagand pagination links.
That last part gets missed a lot. If your frontend needs to read a response header in JavaScript, you may need to expose it.
Nginx after cleanup
Nginx became boring again, which is good:
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
No CORS headers here. No surprises.
Handling preview environments on Hetzner
The next issue was staging. The team spun up preview apps like:
preview-41.staging.example.compreview-42.staging.example.com
Hardcoding every possible subdomain was annoying, but blindly allowing *.staging.example.com without checking carefully can be risky if your DNS and tenant model are messy.
I usually handle this with strict pattern validation:
function isAllowedOrigin(origin) {
if (!origin) return false;
const exact = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
if (exact.has(origin)) return true;
const previewPattern = /^https:\/\/preview-\d+\.staging\.example\.com$/;
return previewPattern.test(origin);
}
Then:
app.use((req, res, next) => {
const origin = req.headers.origin;
if (isAllowedOrigin(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});
That’s far safer than reflecting any Origin header you receive. I still see people do this in production:
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
Without validation, that’s basically “allow every website on earth.”
The result
After the fix:
- authenticated requests from
app.example.comworked - preview deployments worked
- preflight requests returned clean
204responses - no duplicate or conflicting headers
- CDN and proxy caching behaved because
Vary: Originwas present
The frontend could also read selected headers:
const res = await fetch("https://api.example.com/projects", {
credentials: "include"
});
console.log(res.headers.get("ETag"));
console.log(res.headers.get("Link"));
That only worked after we added:
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After
Again, GitHub’s API is a good real-world example here. Their access-control-expose-headers list is long because clients genuinely need access to metadata like rate limits and pagination.
What I’d do on day one for any Hetzner CORS setup
My default checklist:
- Decide whether CORS lives in Nginx or the app. Pick one.
- If cookies or auth are involved, never use
*. - Validate origins against an allowlist or a strict pattern.
- Return
Vary: Origin. - Handle
OPTIONScleanly. - Expose only the headers the frontend truly needs.
- Test in a browser, not just curl.
And if you’re already touching Nginx, don’t stop at CORS. Review the rest of your response headers too. If you want a broader guide for security headers, https://csp-guide.com is a solid reference.
One final “before and after” worth keeping
Before
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Broken for credentialed browser requests.
After
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After
That’s the version that survived production traffic.
If you’re deploying on Hetzner, the trap isn’t Hetzner itself. The trap is thinking CORS is just three headers you paste into Nginx and forget. It’s a policy decision, and your browser is the one enforcing it. Treat it like application behavior, not decoration.