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:

  1. Access-Control-Allow-Origin: * with credentials is invalid.
  2. add_header in Nginx does not reliably apply to every response unless you use always.
  3. 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-Origin mirrors the requesting origin only if it’s allowed.
  • Vary: Origin prevents caches from serving the wrong CORS response to the wrong site.
  • Access-Control-Allow-Credentials: true is only used with explicit origins.
  • Access-Control-Expose-Headers is there because frontend code needed access to ETag and 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.com
  • preview-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.com worked
  • preview deployments worked
  • preflight requests returned clean 204 responses
  • no duplicate or conflicting headers
  • CDN and proxy caching behaved because Vary: Origin was 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:

  1. Decide whether CORS lives in Nginx or the app. Pick one.
  2. If cookies or auth are involved, never use *.
  3. Validate origins against an allowlist or a strict pattern.
  4. Return Vary: Origin.
  5. Handle OPTIONS cleanly.
  6. Expose only the headers the frontend truly needs.
  7. 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.