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-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Credentials
  • Access-Control-Expose-Headers
  • Access-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:

  • accessControlAllowOriginList
  • accessControlAllowOriginListRegex
  • accessControlAllowMethods
  • accessControlAllowHeaders
  • accessControlExposeHeaders
  • accessControlAllowCredentials
  • accessControlMaxAge
  • addVaryHeader

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.