CORS in Traefik is mostly a headers middleware problem. Once you get that, the config becomes pretty mechanical.
The annoying part is that browsers are strict, Traefik is flexible, and bad examples online often mix app-level CORS with proxy-level CORS. I usually prefer handling CORS at the edge in Traefik when multiple services need the same behavior. It keeps backend apps simpler and avoids five different teams all inventing slightly broken header logic.
What Traefik actually does for CORS
Traefik sets CORS response headers through the headers middleware. You define things like:
- allowed origins
- allowed methods
- allowed headers
- exposed headers
- whether credentials are allowed
- preflight caching with
Access-Control-Max-Age
If CORS headers are configured in the middleware, Traefik can also answer preflight OPTIONS requests directly instead of forwarding them to your app. That’s often exactly what you want.
Quick CORS header refresher
These are the headers you’ll care about most:
Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-CredentialsAccess-Control-Expose-HeadersAccess-Control-Max-Age
A real-world example helps. 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, Warning
That’s a good reminder that Expose-Headers matters when frontend code needs to read non-simple response headers like ETag, pagination Link, or rate-limit metadata.
The Traefik middleware fields you’ll use
Traefik’s CORS support lives under the headers middleware. The common options are:
accessControlAllowOriginListaccessControlAllowOriginListRegexaccessControlAllowMethodsaccessControlAllowHeadersaccessControlExposeHeadersaccessControlAllowCredentialsaccessControlMaxAgeaddVaryHeader
My default opinionated setup includes addVaryHeader: true. If you allow specific origins, you want Vary: Origin so caches don’t serve the wrong CORS result.
File provider example
This is the cleanest reference format because it shows the whole middleware shape.
http:
middlewares:
cors-api:
headers:
accessControlAllowOriginList:
- "https://app.example.com"
- "https://admin.example.com"
accessControlAllowMethods:
- "GET"
- "POST"
- "PUT"
- "PATCH"
- "DELETE"
- "OPTIONS"
accessControlAllowHeaders:
- "Authorization"
- "Content-Type"
- "If-None-Match"
- "X-Requested-With"
accessControlExposeHeaders:
- "ETag"
- "Link"
- "Location"
- "Retry-After"
- "X-RateLimit-Limit"
- "X-RateLimit-Remaining"
- "X-RateLimit-Reset"
accessControlAllowCredentials: true
accessControlMaxAge: 600
addVaryHeader: true
routers:
api:
rule: "Host(`api.example.com`)"
service: "api-service"
middlewares:
- "cors-api"
services:
api-service:
loadBalancer:
servers:
- url: "http://api:8080"
Good fit for cookie-based auth or bearer-token APIs used by a browser frontend.
Docker labels example
Same thing, but with labels. This is where people usually make quoting mistakes.
services:
api:
image: my-api:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.example.com`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls=true"
- "traefik.http.routers.api.middlewares=api-cors"
- "traefik.http.services.api.loadbalancer.server.port=8080"
- "traefik.http.middlewares.api-cors.headers.accesscontrolalloworiginlist=https://app.example.com,https://admin.example.com"
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowmethods=GET,POST,PUT,PATCH,DELETE,OPTIONS"
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowheaders=Authorization,Content-Type,If-None-Match,X-Requested-With"
- "traefik.http.middlewares.api-cors.headers.accesscontrolexposeheaders=ETag,Link,Location,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset"
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowcredentials=true"
- "traefik.http.middlewares.api-cors.headers.accesscontrolmaxage=600"
- "traefik.http.middlewares.api-cors.headers.addvaryheader=true"
If you’re debugging this in Docker, inspect the rendered labels first. Half of “Traefik CORS is broken” reports are just malformed label strings.
Kubernetes CRD example
If you use Traefik with Kubernetes CRDs, define a Middleware and attach it to an IngressRoute.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: api-cors
namespace: default
spec:
headers:
accessControlAllowOriginList:
- "https://app.example.com"
- "https://admin.example.com"
accessControlAllowMethods:
- "GET"
- "POST"
- "PUT"
- "PATCH"
- "DELETE"
- "OPTIONS"
accessControlAllowHeaders:
- "Authorization"
- "Content-Type"
- "If-None-Match"
- "X-Requested-With"
accessControlExposeHeaders:
- "ETag"
- "Link"
- "Location"
- "Retry-After"
- "X-RateLimit-Limit"
- "X-RateLimit-Remaining"
- "X-RateLimit-Reset"
accessControlAllowCredentials: true
accessControlMaxAge: 600
addVaryHeader: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: api
namespace: default
spec:
entryPoints:
- websecure
routes:
- match: Host(`api.example.com`)
kind: Rule
middlewares:
- name: api-cors
services:
- name: api-service
port: 8080
Public API config with wildcard origin
For a truly public API with no cookies and no credentialed browser requests, wildcard origin is fine.
http:
middlewares:
cors-public:
headers:
accessControlAllowOriginList:
- "*"
accessControlAllowMethods:
- "GET"
- "HEAD"
- "OPTIONS"
accessControlAllowHeaders:
- "Content-Type"
accessControlExposeHeaders:
- "ETag"
- "Link"
- "Location"
- "Retry-After"
accessControlAllowCredentials: false
accessControlMaxAge: 86400
addVaryHeader: true
This is roughly the style used by public APIs like GitHub for Access-Control-Allow-Origin, though your exposed headers will depend on what clients need.
The credential rule people keep breaking
If you set:
accessControlAllowCredentials: true
you should not use * as the allowed origin. Browsers reject that combination for credentialed requests.
Bad:
accessControlAllowOriginList:
- "*"
accessControlAllowCredentials: true
Good:
accessControlAllowOriginList:
- "https://app.example.com"
accessControlAllowCredentials: true
If your frontend sends cookies or uses fetch(..., { credentials: "include" }), use explicit origins.
Regex origin matching
Sometimes you need preview environments or many subdomains.
http:
middlewares:
cors-preview:
headers:
accessControlAllowOriginListRegex:
- "^https://([a-z0-9-]+)\\.preview\\.example\\.com$"
accessControlAllowMethods:
- "GET"
- "POST"
- "OPTIONS"
accessControlAllowHeaders:
- "Authorization"
- "Content-Type"
accessControlAllowCredentials: true
accessControlMaxAge: 300
addVaryHeader: true
I use this carefully. Regex makes life easier, but it also makes mistakes easier to hide. Keep the pattern tight.
Preflight test commands
Use curl to test Traefik before opening browser devtools.
Simple origin test:
curl -i https://api.example.com/users \
-H "Origin: https://app.example.com"
Preflight test:
curl -i -X OPTIONS https://api.example.com/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization, Content-Type"
You want to see headers like:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Authorization,Content-Type,If-None-Match,X-Requested-With
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin
If those are missing, either the middleware isn’t attached to the router, or you’re hitting a different router than you think.
Common Traefik CORS mistakes
1. Middleware exists but isn’t attached
This is the classic one. You created the middleware, but forgot:
middlewares:
- "cors-api"
No attachment, no CORS headers.
2. Backend also sets conflicting CORS headers
Pick one place to own CORS. I strongly prefer Traefik or the app, not both. Mixed ownership creates weird duplicate or contradictory headers.
3. Missing OPTIONS in allowed methods
Preflight requests use OPTIONS. If it’s missing, browsers complain and users blame the frontend.
4. Forgetting exposed headers
Frontend code cannot read arbitrary response headers unless they’re simple headers or listed in Access-Control-Expose-Headers.
If your JS needs ETag or pagination Link, expose them:
accessControlExposeHeaders:
- "ETag"
- "Link"
5. Wildcard origin with credentials
Still broken. Still common.
When proxy-level CORS is a bad fit
I don’t force CORS into Traefik for everything. App-level CORS can be better when:
- origin rules depend on tenant data
- different endpoints need different CORS behavior
- auth logic and CORS decisions are tightly coupled
- non-browser clients and browser clients need very different policies
But for standard frontend-to-API traffic, Traefik middleware is a solid place to centralize it.
Security boundaries worth remembering
CORS is not authentication and not CSRF protection. It only controls what browsers allow frontend JavaScript to read across origins.
If you’re also tightening security headers, handle those separately. Traefik can set many of them through the same headers middleware. For broader header policy guidance beyond CORS, see https://csp-guide.com or Traefik’s official documentation.
Minimal copy-paste templates
Strict single-origin API
headers:
accessControlAllowOriginList:
- "https://app.example.com"
accessControlAllowMethods:
- "GET"
- "POST"
- "OPTIONS"
accessControlAllowHeaders:
- "Authorization"
- "Content-Type"
accessControlAllowCredentials: true
accessControlMaxAge: 600
addVaryHeader: true
Public read-only API
headers:
accessControlAllowOriginList:
- "*"
accessControlAllowMethods:
- "GET"
- "HEAD"
- "OPTIONS"
accessControlAllowHeaders:
- "Content-Type"
accessControlExposeHeaders:
- "ETag"
- "Link"
accessControlAllowCredentials: false
accessControlMaxAge: 86400
addVaryHeader: true
SPA + rate limit metadata
headers:
accessControlAllowOriginList:
- "https://app.example.com"
accessControlAllowMethods:
- "GET"
- "POST"
- "OPTIONS"
accessControlAllowHeaders:
- "Authorization"
- "Content-Type"
accessControlExposeHeaders:
- "ETag"
- "Link"
- "X-RateLimit-Limit"
- "X-RateLimit-Remaining"
- "X-RateLimit-Reset"
- "Retry-After"
accessControlAllowCredentials: true
accessControlMaxAge: 600
addVaryHeader: true
For the exact field names and provider-specific syntax, check the official Traefik docs. The main trick is simple: define one sane middleware, attach it to the right router, and test preflight with curl before blaming the browser.