Caddy makes the easy path easy, but CORS is still CORS. The browser enforces it, the server has to answer correctly, and one wrong header can turn a simple API call into a weird frontend bug that eats an afternoon.
This guide is the version I wish I had the first few times I configured CORS behind a reverse proxy.
What CORS is actually doing
CORS is the browser asking:
- Can
https://app.example.comread responses fromhttps://api.example.com? - Can it send credentials like cookies or
Authorization? - Can it use non-simple methods like
PUTor custom headers likeX-API-Key?
The server answers with headers such as:
Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-CredentialsAccess-Control-Expose-Headers
For some requests, the browser sends a preflight OPTIONS request first.
Caddy doesn’t have “CORS mode” built in as a single switch. You usually implement it with header, @matchers, handle, and sometimes reverse_proxy.
The safest mental model
Use these rules:
- If your API is public and does not use cookies or auth tied to browser credentials,
Access-Control-Allow-Origin: *is often fine. - If you need cookies or
Authorizationin browser requests, do not use*forAccess-Control-Allow-Origin. - If you allow credentials, return the exact allowed origin.
- Handle preflight explicitly.
- Add
Vary: Originwhen the response changes based on the request’sOrigin.
That last one gets skipped a lot and causes cache bugs.
Minimal public API CORS in Caddy
If you want any site to read your API and you do not allow credentials:
api.example.com {
reverse_proxy localhost:8080
header {
Access-Control-Allow-Origin *
Access-Control-Expose-Headers "ETag, Link, Location, Retry-After"
}
}
```text
That’s enough for basic `GET` requests from browsers when no preflight is involved.
The `Access-Control-Expose-Headers` part matters if your frontend needs to read response headers from JavaScript. A nice real-world reference is GitHub’s API, which exposes headers like this:
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 access-control-allow-origin: *
That’s a good pattern for public APIs: broad origin access, but explicit exposed headers.
## Public API with preflight support
Once the browser sends `OPTIONS`, you need to answer it cleanly.
```caddyfile
api.example.com {
@preflight {
method OPTIONS
header Origin *
header Access-Control-Request-Method *
}
handle @preflight {
header {
Access-Control-Allow-Origin *
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
Access-Control-Max-Age "86400"
}
respond "" 204
}
reverse_proxy localhost:8080
header {
Access-Control-Allow-Origin *
Access-Control-Expose-Headers "ETag, Link, Location, Retry-After"
}
}
A few opinions here:
204is nicer than200for preflight. No body, no ambiguity.Access-Control-Max-Agereduces preflight spam.- Don’t blindly allow every header unless you actually want to.
Credentialed CORS with an allowlist
This is the setup people usually need for SPAs talking to an API with cookies or bearer tokens.
You must return the exact origin, not *.
api.example.com {
@allowed_origin header Origin https://app.example.com
@preflight {
method OPTIONS
header Origin https://app.example.com
header Access-Control-Request-Method *
}
handle @preflight {
header {
Access-Control-Allow-Origin "https://app.example.com"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
Access-Control-Allow-Credentials true
Access-Control-Max-Age "86400"
Vary Origin
}
respond "" 204
}
reverse_proxy localhost:8080
header @allowed_origin {
Access-Control-Allow-Origin "https://app.example.com"
Access-Control-Allow-Credentials true
Vary Origin
}
}
```text
If your frontend uses `fetch(..., { credentials: "include" })`, this is the kind of config you need.
## Multiple allowed origins
Caddyfile doesn’t have the world’s fanciest dynamic logic, but you can still do this cleanly with named matchers.
```caddyfile
api.example.com {
@origin_app header Origin https://app.example.com
@origin_admin header Origin https://admin.example.com
@preflight_app {
method OPTIONS
header Origin https://app.example.com
header Access-Control-Request-Method *
}
@preflight_admin {
method OPTIONS
header Origin https://admin.example.com
header Access-Control-Request-Method *
}
handle @preflight_app {
header {
Access-Control-Allow-Origin "https://app.example.com"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
Access-Control-Allow-Credentials true
Access-Control-Max-Age "86400"
Vary Origin
}
respond "" 204
}
handle @preflight_admin {
header {
Access-Control-Allow-Origin "https://admin.example.com"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
Access-Control-Allow-Credentials true
Access-Control-Max-Age "86400"
Vary Origin
}
respond "" 204
}
reverse_proxy localhost:8080
header @origin_app {
Access-Control-Allow-Origin "https://app.example.com"
Access-Control-Allow-Credentials true
Vary Origin
}
header @origin_admin {
Access-Control-Allow-Origin "https://admin.example.com"
Access-Control-Allow-Credentials true
Vary Origin
}
}
A little repetitive, yes. Also very readable. I’ll take readable over clever for security config every time.
Reflecting the Origin header: usually a bad shortcut
People often want this behavior:
- If the request
Originis in my allowlist, echo it back. - Otherwise, send nothing.
That’s valid. But if you implement it sloppily and reflect any origin, you’ve basically disabled origin protection.
If you really need dynamic behavior, prefer your app to do it, where allowlist logic is easier to maintain and test. Let Caddy handle static cases well.
CORS only on /api/*
Don’t spray CORS headers on your whole site unless you mean to.
example.com {
@api path /api/*
handle @api {
@preflight {
method OPTIONS
header Origin *
header Access-Control-Request-Method *
}
handle @preflight {
header {
Access-Control-Allow-Origin *
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
Access-Control-Max-Age "86400"
}
respond "" 204
}
reverse_proxy localhost:8080
header {
Access-Control-Allow-Origin *
Access-Control-Expose-Headers "ETag, Link"
}
}
handle {
root * /var/www/html
file_server
}
}
```text
That keeps static pages and assets separate from API behavior.
## Common mistakes
### 1. Using `*` with credentials
This is invalid:
Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true
Browsers reject it.
### 2. Forgetting preflight
Your API works in `curl`, but the browser fails because it sends:
OPTIONS /endpoint Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: authorization,content-type
If Caddy or the upstream app doesn’t answer that correctly, the real request never happens.
### 3. Forgetting `Vary: Origin`
If you return different `Access-Control-Allow-Origin` values for different callers and omit:
Vary: Origin
shared caches can serve the wrong CORS response.
### 4. Allowing too many headers and methods
This works:
Access-Control-Allow-Methods: * Access-Control-Allow-Headers: *
Well, sometimes, depending on browser behavior and context. I avoid it. Be explicit.
### 5. Thinking CORS protects your API from non-browser clients
It doesn’t. `curl`, backend jobs, mobile apps, and attackers can ignore CORS completely. CORS is a browser read-sharing policy, not an authentication system.
## Debugging with curl
Simple request:
curl -i https://api.example.com/data
-H ‘Origin: https://app.example.com’
Preflight request:
curl -i -X OPTIONS https://api.example.com/data
-H ‘Origin: https://app.example.com’
-H ‘Access-Control-Request-Method: POST’
-H ‘Access-Control-Request-Headers: content-type, authorization’
You want to see the matching CORS headers in the response.
## When to set CORS in Caddy vs the app
My rule:
- Set it in **Caddy** when the policy is simple and edge-wide.
- Set it in the **app** when policy depends on routes, tenants, databases, or dynamic allowlists.
If your API already emits CORS headers, don’t also bolt on conflicting ones in Caddy unless you enjoy debugging duplicate header behavior.
## A solid default for many public APIs
This is a practical baseline:
```caddyfile
api.example.com {
@preflight {
method OPTIONS
header Origin *
header Access-Control-Request-Method *
}
handle @preflight {
header {
Access-Control-Allow-Origin *
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
Access-Control-Max-Age "86400"
}
respond "" 204
}
reverse_proxy localhost:8080
header {
Access-Control-Allow-Origin *
Access-Control-Expose-Headers "ETag, Link, Location, Retry-After"
}
}
And for credentialed browser apps, switch to explicit origins and add:
Access-Control-Allow-Credentials: true
Vary: Origin
That’s the whole game.
For Caddy syntax details, check the official docs: https://caddyserver.com/docs/