Handling Multiple DNS Challenges in Traefik with Let’s Encrypt

Handling Multiple DNS Challenges in Traefik with Let’s Encrypt

Traefik is a reverse proxy and load balancer that integrates seamlessly with Let’s Encrypt for automated TLS certificate management using the ACME protocol. One common challenge arises when you need to obtain certificates for multiple domains managed across different DNS providers or accounts (e.g., separate Cloudflare accounts). As of Traefik v3.5 (the latest version as of August 2025), Traefik does not natively support multiple DNS challenge providers in a single instance, but you can work around this limitation using DNS CNAME records to delegate the ACME DNS-01 challenge validation from one domain to another. This allows a single DNS provider/account to handle validations for multiple domains.

This guide explains the concept, why it works, and provides a step-by-step setup based on Traefik’s documentation and real-world examples. We’ll focus on the DNS-01 challenge (preferred for wildcard certificates) and assume you’re using a provider like Cloudflare, but the principles apply to others supporting API tokens for DNS updates. The setup incorporates a secure Docker Compose configuration using Traefik v3, mounted static files, dynamic rules, and Docker secrets for the API token.

Understanding the DNS-01 Challenge and Multi-DNS Limitations

What is the DNS-01 Challenge?

  • In the ACME protocol (used by Let’s Encrypt), the DNS-01 challenge requires proving domain ownership by creating a TXT record under _acme-challenge.yourdomain.com.
  • Traefik’s built-in ACME client handles this automatically if configured with a DNS provider (e.g., via environment variables or API tokens for Cloudflare).
  • For wildcard certificates (e.g., *.example.com), DNS-01 is required, as HTTP-01 can’t validate wildcards.

The Multi-DNS Problem

  • If your domains are in separate DNS accounts/providers, you can’t configure multiple DNS challenge providers in one Traefik instance.
  • Solution: Use CNAME delegation. By creating a CNAME record for _acme-challenge.subdomain.example.org pointing to a challenge domain in another account (e.g., challenge.example.com), you delegate the validation. Let’s Encrypt follows the CNAME and checks the TXT record on the target domain, allowing one account to validate multiple domains.

Why Does This Work? (The “Magic” Explained)

  • DNS resolution is hierarchical and follows aliases (CNAMEs).
  • When Let’s Encrypt queries _acme-challenge.example.org, it resolves the CNAME to challenge.example.com and then looks for the TXT record there.
  • This doesn’t grant “permission” in a security sense—it’s just DNS redirection. However, since you control both DNS zones (even if in separate accounts), you maintain ownership proof.
  • Security note: This is safe as long as you trust the target domain’s DNS provider. It’s “scary” only if misunderstood, but it’s a standard technique documented in ACME specs and tools like cert-manager or Traefik.
  • Let’s Encrypt rate limits still apply (e.g., 50 certs/week per domain), but this method consolidates validations.

Prerequisites

  • Traefik v3.x installed (e.g., via Docker; we’ll use v3 in examples).
  • Domains registered and managed in DNS providers that support API access (e.g., Cloudflare API tokens with DNS edit permissions).
  • Separate accounts for domains if needed (e.g., one for domain01.tld and another for domain02.tld).
  • Basic understanding of Traefik configuration (static and dynamic configs).
  • Docker Compose or similar for managing services (examples assume this).
  • Docker secrets: Create a secret for the primary Cloudflare token (e.g., via docker secret create cf_dns_api_token token.txt or in Compose with secrets section).
  • Directory structure:
    • ./traefik/traefik.yml: Static config.
    • ./rules/dynamic_config.yml: Dynamic routers/services.
    • ./traefik/acme.json: For cert storage (created automatically if missing).
    • ./logs, ./traefik/plugins-storage, ./certs: As needed.
    • ./secrets/cf_dns_api_token.txt: Primary API token file.
  • Optional: A setup like “Pangolin” (appears to be a custom Traefik-based stack for multi-domain management; if not using it, adapt configs to standard Traefik).

Step-by-Step Setup

Step 1: Configure Your DNS Provider Accounts

  • Create API tokens in each DNS provider account with permissions for DNS zone edits.
  • Decide on a “primary” domain/account for handling challenges (e.g., domain01.tld in account “foo”). This will be the target for CNAMEs.
  • In the secondary domain’s DNS (e.g., domain02.tld in account “bar”):
    • Add a CNAME record:
      • Name: _acme-challenge.domain02.tld (or _acme-challenge if using root).
      • Target: challenge.domain01.tld (a subdomain in the primary account).
      • Proxy: DNS-only (not proxied, as it must resolve directly).
      • TTL: Auto or low (e.g., 300 seconds).
    • Example screenshot reference: The provided image shows this setup in Cloudflare, with a comment noting it’s for testing a single API token across multiple domains/accounts.
  • In the primary domain’s DNS (domain01.tld):
    • No special records needed yet—Traefik will create TXT records dynamically under challenge.domain01.tld.

Step 2: Configure Traefik Static Configuration

  • Create ./traefik/traefik.yml with ACME DNS-01 setup. Use the primary Cloudflare token (loaded via environment variable from a secret file).
  • Enable file provider for dynamic configs in ./rules.

Example traefik.yml:

global:
  checkNewVersion: true
  sendAnonymousUsage: false

entryPoints:
  web:
    address: :80
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: :443

providers:
  docker:
    endpoint: unix:///var/run/docker.sock
    exposedByDefault: false
  file:
    directory: /rules  # Watches for dynamic configs here
    watch: true

certificatesResolvers:
  letsencrypt:
    acme:
      email: your@email.com
      storage: /acme.json
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"

log:
  level: INFO
  filePath: /logs/traefik.log

# Additional options as needed (e.g., plugins via /plugins-storage)
  • Note: The DNS provider (cloudflare) reads the token from CF_DNS_API_TOKEN (set via the _FILE env var in the container). Only the primary token is needed.

Step 3: Set Up Dynamic Configuration for Routers and Certificates

  • Create ./rules/dynamic_config.yml (or similar file in ./rules).
  • Define routers with certResolver: letsencrypt.
  • Specify domains in the tls.domains section. You can combine multiple mains in one router if they share a service, or use separate routers.
  • For wildcards, set them in sans.
  • Address common questions:
    • PathPrefix: Optional; use if routing specific paths (e.g., exclude /api/v1 for backend APIs). It doesn’t affect cert issuance.
    • Same service: Yes, you can use the same backend service (e.g., next-service for a Next.js app). The app handles requests based on the Host header.
    • New entry points: No, reuse existing ones (e.g., websecure for HTTPS).
    • Multiple routers vs. one: Multiple allow finer control; one with combined domains works if they share logic.

Example dynamic_config.yml (adapted from provided config; assumes a Next.js service):

http:
  routers:
    next-router:
      rule: "Host(`domain01.tld`,`domain02.tld`) && !PathPrefix(`/api/v1`)"  # Combined rule
      service: next-service
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt
        domains:
          - main: "domain01.tld"  # No wildcard
          - main: "domain02.tld"
            sans:
              - "*.domain02.tld"  # Wildcard for domain02

  services:
    next-service:
      loadBalancer:
        servers:
          - url: http://next-app:3000  # Your backend (e.g., Next.js container)

# Alternative: Separate routers
# http:
#   routers:
#     router-dom01:
#       rule: "Host(`domain01.tld`) && !PathPrefix(`/api/v1`)"
#       service: next-service
#       entryPoints:
#         - websecure
#       tls:
#         certResolver: letsencrypt
#         domains:
#           - main: "domain01.tld"
#
#     router-dom02:
#       rule: "Host(`domain02.tld`) && !PathPrefix(`/api/v1`)"
#       service: next-service
#       entryPoints:
#         - websecure
#       tls:
#         certResolver: letsencrypt
#         domains:
#           - main: "domain02.tld"
#             sans:
#               - "*.domain02.tld"
#
#   services:
#     next-service:
#       loadBalancer:
#         servers:
#           - url: http://next-app:3000
  • If using a custom setup like Pangolin, adapt to its domain mapping (e.g., config.yml):
domains:
  domain01:
    base_domain: domain01.tld
    cert_resolver: letsencrypt
    prefer_wildcard_cert: false  # No wildcard needed
  domain02:
    base_domain: domain02.tld
    cert_resolver: letsencrypt
    prefer_wildcard_cert: true   # Enable wildcard

Step 4: Docker Compose Setup

  • Use the provided Traefik service example. Add secrets and other services (e.g., your Next.js app).
  • Ensure ports are exposed (e.g., 80, 443) and networks if needed.
  • Create the secret file: Echo your primary Cloudflare token into ./secrets/cf_dns_api_token.txt.

Full example docker-compose.yml:

version: '3.8'

services:
  traefik:
    image: traefik:v3
    container_name: traefik
    restart: always
    environment:
      - CF_DNS_API_TOKEN_FILE=/run/secrets/cf_dns_api_token
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - ./traefik/traefik.yml:/traefik.yml:ro
      - ./rules:/rules:ro
      - ./traefik/acme.json:/acme.json:rw
      - ./logs:/logs:rw
      - ./traefik/plugins-storage:/plugins-storage:rw
      - ./certs:/certs:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro  # For Docker provider
    labels:
      # blah (add your labels here, e.g., for Traefik to route to itself if needed)
    security_opt:
      - no-new-privileges:true
    read_only: true
    ports:
      - "80:80"
      - "443:443"
    secrets:
      - cf_dns_api_token

  # Example backend service (e.g., Next.js)
  next-app:
    image: your-nextjs-image
    container_name: next-app
    restart: always
    labels:
      - "traefik.enable=true"  # If using Docker provider for auto-discovery

secrets:
  cf_dns_api_token:
    file: ./secrets/cf_dns_api_token.txt  # Path to your token file

Step 5: Deploy and Test

  • Run: docker compose down && docker compose up -d.
  • Traefik will load static config from traefik.yml, dynamic from /rules, and use the secret token for DNS challenges.
  • Monitor logs for ACME challenges: Traefik will create TXT records on the primary domain (following CNAME delegation).
  • Test: Access https://domain02.tld—it should serve with a valid cert. Use curl https://domain02.tld or browser access. Check logs in ./logs/traefik.log.
  • Verify certs: Check ./traefik/acme.json (or docker exec traefik cat /acme.json) or use openssl s_client -connect domain02.tld:443 or tools like ssllabs.com for cert details.

Troubleshooting

  • Challenge fails: Ensure CNAME resolves correctly (dig _acme-challenge.domain02.tld). Propagation can take time.
  • Rate limits: If hit, wait or use staging server (add caServer: "https://acme-staging-v02.api.letsencrypt.org/directory" in acme section).
  • Wildcard issues: DNS-01 required; ensure provider supports it.
  • Logs: Enable debug (set level: DEBUG in traefik.yml log section).
  • Multi-account security: Use least-privilege API tokens.
  • Token issues: Ensure secret is readable; check logs for auth errors.
  • Config reload: Traefik watches files; edit ./rules/dynamic_config.yml and it auto-applies.
  • v3 specifics: If plugins are used, store them in ./traefik/plugins-storage.
  • Read-only container: Ensures security; configs are ro where possible.

Advanced Tips

  • For more domains, add more CNAMEs pointing to the same primary challenge domain.
  • If using wildcards on multiple, combine in one domains block to minimize cert requests.
  • Alternatives: If Traefik evolves further, check docs for native multi-provider support; or use external tools like certbot for manual certs.
  • Resources: Traefik ACME docs (search for “CNAME” in multi-provider sections), Let’s Encrypt community forums.

This setup leverages DNS delegation for efficient multi-domain cert management in a secure, production-ready environment. If you encounter issues, provide logs for further help!

Hi

Thank you for the guide. I followed the guide, but traefik (pangolin) still gives the following error for other than the main domain:

traefik | 2025-10-01T11:03:53Z ERR Unable to obtain ACME certificate for domains error=“unable to generate a certificate for the domains [xxxx<.com]: order identifiers have been by the ACME server (RFC8555 §7.1.3): [{Type:dns Value:xxxxcom} {Type:dns Value:kl-xxxxx.com}] != [{Type:dns Value:xxxx.com}]” ACME CA=hXttps://acme-v02.api.letsencrypt.org/directory acmeCA=htxtps://acme-v02.api.letsencrypt.org/directory domains=[“xxxx<.com”,“xxxx.comi”] providerName=letsencrypt.acme routerName=next-router@file rule=“Host(proxy.yyyy.cloud) && !PathPrefix(/api/v1)”

I added dns CNAME for _acme-challenge.xxxx.com –> challenge01.yyyy.cloud

This error keeps repeating. I also changed the configuration files to reflect the guide.

How do i go about troubleshooting this?

I also have problems with pangolin giving 404 errors for sites that i create.

I see what’s happening — your logs are showing two separate but related issues:


1. ACME / Let’s Encrypt error

The key part of the error is:

order identifiers have been by the ACME server (RFC8555 §7.1.3):
[{Type:dns Value:xxxxcom} {Type:dns Value:kl-xxxxx.com}] != [{Type:dns Value:xxxx.com}]

That means Let’s Encrypt is rejecting the certificate request because the domain names in your router rule and the ACME challenge don’t line up. Common causes:

  • Typo in domain: I see xxxx<.com and xxxx.comi in your log — those look like copy/paste or config typos. Even a stray < or i will cause a mismatch.
  • CNAME delegation: You added _acme-challenge.xxxx.com → challenge01.yyyy.cloud. That’s correct in principle, but you need to confirm with dig or nslookup that it resolves properly:
    dig TXT _acme-challenge.xxxx.com
    
    It should return the TXT record Traefik created under challenge01.yyyy.cloud. If it doesn’t, Let’s Encrypt won’t validate.
  • Wildcard vs. base domain: If you’re requesting *.xxxx.com, you must use DNS‑01 (which you are), but your tls.domains block must explicitly include both:
    tls:
      certResolver: letsencrypt
      domains:
        - main: "xxxx.com"
          sans:
            - "*.xxxx.com"
    

Troubleshooting steps

  1. Clean up the domain list in your router/dynamic config — make sure there are no stray characters.
  2. Run dig on _acme-challenge.xxxx.com and confirm it resolves to the TXT record under challenge01.yyyy.cloud.
  3. If propagation is slow, Let’s Encrypt may fail repeatedly until DNS caches update. Lower TTLs help.
  4. If you’re testing, switch to the staging CA to avoid hitting rate limits:
    certificatesResolvers:
      letsencrypt:
        acme:
          caServer: "https://acme-staging-v02.api.letsencrypt.org/directory"
    

Next steps for you

  1. Verify DNS delegation with dig to ensure _acme-challenge is resolving correctly.
  2. Confirm your router rules match the domains you’re actually visiting.
  3. If you’re still stuck, enable debug logging in Traefik:
    log:
      level: DEBUG
    
    That will show exactly which domains Traefik is requesting certs for and why it’s rejecting others.

Share your yamls files here so that i can have look.

Hi

Those are placeholder domain names. Post-editor doesn’t like links, so i removed "h".

Here is the docker-compose for Traefik:

> traefik:
> image: ttp://docker.io/traefik:v3.5
> container_name: traefik
> restart: unless-stopped
> network_mode: service:gerbil # Ports appear on the gerbil service
> depends_on:
> pangolin:
> condition: service_healthy
> command:
>
> * –configFile=/etc/traefik/traefik_config.yml
>   environment:
>   CLOUDFLARE_DNS_API_TOKEN: ‘HERE IS TOKEN’
>   volumes:
> * ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
> * ./config/letsencrypt:/letsencrypt # Volume to store the Let’s Encrypt certificates
> * ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
> * ./rules:/rules:ro
> * ./traefik/acme.json:/acme.json:rw
> * ./logs:/logs:rw
> * ./traefik/plugins-storage:/plugins-storage:rw
> * ./certs:/certs:ro
> * /var/run/docker.sock:/var/run/docker.sock:ro  # For Docker provider
>
> # Shared volume for certificates and dynamic config in file mode
>
> * pangolin-data:/var/certificates:ro
> * pangolin-data:/var/dynamic:ro

Pangolin config.yml (relevant part, edited out domain names and made the unlinkable. Removing “h”)

> gerbil:
> start_port: 51820
> base_endpoint: “proxy.yyyy.cloud”
>
> app:
> dashboard_url: “ttps://proxy.yyyy.cloud”
> log_level: “info”
> telemetry:
> anonymous_usage: true
>
> domains:
> domain1:
> base_domain: “yyyy.cloud”
> cert_resolver: “letsencrypt”
> prefer_wildcard_cert: true
> domain2:
> base_domain: “ttp://zzzz.org”
> cert_resolver: “letsencrypt”
> prefer_wildcard_cert: true
> domain3:
> base_domain: “ttp://ppppp.com”
> cert_resolver: “letsencrypt”
> prefer_wildcard_cert: true
> domain4:
> base_domain: “ttp://ooooo[.](http://kl-invest.fi)wtf”
> cert_resolver: “letsencrypt”
> prefer_wildcard_cert: true

Traefik_config.yml (e-mail addresses obscured)

> api:
> insecure: true
> dashboard: true
>
> providers:
> http:
> endpoint: “ttp://pangolin:3001/api/v1/traefik-config”
> pollInterval: “5s”
> file:
> filename: “/etc/traefik/dynamic_config.yml”
>
> experimental:
> plugins:
> badger:
> moduleName: “ttp://github.com/fosrl/badger\](htp://github.com/fosrl/badger)
> version: “v1.2.0”
>
> log:
> level: “INFO”
> format: “common”
> maxSize: 100
> maxBackups: 3
> maxAge: 3
> compress: true
>
> certificatesResolvers:
> letsencrypt:
> acme:
> dnsChallenge:
> provider: cloudflare
> resolvers:
>
> * “1.1.1.1:53”
> * “8.8.8.8:53”
>   email: “xxx@yyyy.cloud”
>   storage: “/letsencrypt/acme.json”
>   caServer: “htps://acme-v02.api.letsencrypt.org/directory”
>
> entryPoints:
> web:
> address: “:80”
> websecure:
> address: “:443”
>
> ```
> transport:
>   respondingTimeouts:
>     readTimeout: "30m"
> http:
>   tls:
>     certResolver: "letsencrypt"
> ```
>
> serversTransport:
> insecureSkipVerify: true
>
> ping:
> entryPoint: “web”

Traefik dynamic_config.yml (domains obscured and unlinkable)

> http:
> middlewares:
> redirect-to-https:
> redirectScheme:
> scheme: https
>
> routers:
>
> # HTTP to HTTPS redirect router
>
> main-app-router-redirect:
> rule: “Host(`proxy.yyyy.cloud`)”
> service: next-service
> entryPoints:
>
> * web
>   middlewares:
> * redirect-to-https
>
> ```
> # Next.js router (handles everything except API and WebSocket paths)
> next-router:
>   rule: "Host(`proxy.yyyy.cloud`) && !PathPrefix(`/api/v1`)"
>   service: next-service
>   entryPoints:
>     - websecure
>   tls:
>     certResolver: letsencrypt
>     domains:
>       - main: "yyyyy.cloud"
>         sans: "*.yyyyy.cloud"
>       - main: "yyyy.org"
>         sans: "*.yyyy.org"
>       - main: "xxxx.fi"
>         sans: "xxxx.fi"
>       - main: "pppp.fi"
>         sans: "pppp.fi"
> # API router (handles /api/v1 paths)
> api-router:
>   rule: "Host(`proxy.yyyy.cloud`) && PathPrefix(`/api/v1`)"
>   service: api-service
>   entryPoints:
>     - websecure
>   tls:
>     certResolver: letsencrypt
> 
> # WebSocket router
> ws-router:
>   rule: "Host(`proxy.yyyy.cloud`)"
>   service: api-service
>   entryPoints:
>     - websecure
>   tls:
>     certResolver: letsencrypt
> ```
>
> services:
> next-service:
> loadBalancer:
> servers:
>
> * url: “ttp://pangolin:3002”  # Next.js server
>
> ```
> api-service:
>   loadBalancer:
>     servers:
>       - url: "ttp://pangolin:3000"
> ```

"  # API/WebSocket server

> ```
> 
> ```

DIG TXT \_acme-challenge.xxxx.fi gives following answer (i obscured domains)

> ;; OPT PSEUDOSECTION:
> ; EDNS: version: 0, flags:; udp: 65494
> ;; QUESTION SECTION:
> ;\_acme-challenge.xxxx.fi. IN      TXT
>
> ;; ANSWER SECTION:
> \_acme-challenge.xxxx.fi. 120 IN   CNAME   challenge01.yyyy.cloud.
> challenge01.yyyy.cloud. 120    IN      TXT     “tQFCVnCw7E8L-NCgbJc6Yf0dB8DttsiUnKpGUYfPhfM”
> challenge01.yyyy.cloud. 120    IN      TXT     “Xq2TUFUeIrGmHJauTSA581jJLZz8BeuDyE2smHUEbv8”

It seems that the challenge TXT is there. But i don’t see where the error is. Restarted the containers. Still getting the errors.

Sorry about the formatting. The post editor also doesn’t allow links in the post. If you need the original files, I can send them via message or e-mail.

Thanks.

same mistake throughout

1 Like
  1. Clean up all base_domain entries to be just the domain (no scheme).
  2. Correct typos in caServer and plugin URLs.
  3. Restart Pangolin + Traefik.
  4. Run dig TXT _acme-challenge.yourdomain.com again to confirm delegation.
  5. Watch Traefik logs with log.level=DEBUG to see exactly which domains it’s requesting certs for.

Once those are fixed, your ACME errors and 404s should clear up or share your logs

1 Like