# Fix Traefik Wildcard Certificate Failures: ACME DNS-01 in Docker
**Applies to:** Traefik v3 in Docker/Docker Compose, Let's Encrypt via ACME, DNS-01 challenge through lego DNS providers (Cloudflare, Route53, etc.), wildcard (`*.example.com`) certificates.
## The problem this solves
You run Traefik in Docker, you want a wildcard cert (`*.example.com`) from Let's Encrypt, and you keep hitting one of these:
- `unable to obtain certificate` in the Traefik logs and no `acme.json` entry
- The TXT record appears in your DNS but Let's Encrypt says the challenge failed
- Config that "looked right" does nothing because you put it in the wrong config file
- You burned through Let's Encrypt rate limits while debugging and now every attempt fails for a different reason
Wildcards **only** work through the DNS-01 challenge. There is no HTTP-01 or TLS-ALPN-01 path to a wildcard cert.
## 1. The one decision that bites everyone: static vs dynamic config
Traefik has two separate configurations, and `certificatesResolvers` lives in exactly one of them:
- **Static config** (loaded once at startup; changing it requires a Traefik restart): entrypoints, providers, and `certificatesResolvers`. This is `traefik.yml`, CLI flags (`--certificatesresolvers.NAME...`), or env vars.
- **Dynamic config** (reloaded without restart): routers, services, middlewares. Docker labels, file provider, etc.
Consequences:
- The resolver *definition* (`provider`, `email`, `storage`, `dnsChallenge`) goes in **static** config only. Putting `certificatesResolvers` in a dynamic file (e.g. `dynamic.yml` or a compose label) is silently ignored — nothing is requested, nothing errors loudly.
- The resolver *reference* goes in **dynamic** config: `traefik.http.routers.myapp.tls.certresolver=DNS_RESOLVER_NAME` on the router. The name must match the key under `certificatesResolvers` in static config.
## 2. Which challenge to use
| Challenge | How it proves ownership | Requirements | Wildcard certs? |
|---|---|---|---|
| HTTP-01 (`httpChallenge`) | Serves a token over HTTP on a well-known URI | Let's Encrypt must reach Traefik on **port 80**; `httpChallenge.entryPoint` must point at your port-80 entrypoint | **No** |
| TLS-ALPN-01 (`tlsChallenge`) | Serves a special TLS cert during handshake | Let's Encrypt must reach Traefik on **port 443** | **No** |
| DNS-01 (`dnsChallenge`) | Creates `_acme-challenge` TXT record via your DNS provider's API | API credentials for your DNS provider as env vars on the Traefik container | **Yes — the only way** |
Use DNS-01 when you need wildcards, when port 80/443 is blocked or proxied (e.g. behind Cloudflare), or when Traefik isn't directly internet-facing.
## 3. Static config: the resolver (traefik.yml)
This is the canonical v3 shape. Note the `propagation` block — the old flat keys `delayBeforeCheck` / `disablePropagationCheck` are **deprecated in v3** and must not be used.
```yaml
# traefik.yml — STATIC config, loaded once at startup
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
certificatesResolvers:
dns-resolver: # <- the name routers reference as tls.certResolver
acme:
email: "ACME_CONTACT_EMAIL"
storage: "/letsencrypt/acme.json" # path INSIDE the container; must be writable + persisted
caServer: "https://acme-v02.api.letsencrypt.org/directory"
dnsChallenge:
provider: cloudflare # lego provider code, e.g. cloudflare, route53, digitalocean
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"
propagation:
delayBeforeChecks: 30s # wait before verifying TXT propagation (Go duration string, not an integer)
# disableChecks: true # only if the container cannot reach external DNS at all; not recommended
```
TOML equivalent for the resolver block:
```toml
[certificatesResolvers.dns-resolver.acme]
email = "ACME_CONTACT_EMAIL"
storage = "/letsencrypt/acme.json"
caServer = "https://acme-v02.api.letsencrypt.org/directory"
[certificatesResolvers.dns-resolver.acme.dnsChallenge]
provider = "cloudflare"
resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
[certificatesResolvers.dns-resolver.acme.dnsChallenge.propagation]
delayBeforeChecks = "30s"
```
Key details:
- `storage` is **required**. Create the file before first start and give it owner-only permissions, then mount it as a file or use a named volume: `touch acme.json && chmod 600 acme.json`. An unreadable or unwritable storage path is a common silent failure.
- `email` is **required** for ACME registration.
- `dnsChallenge.provider` is the lego provider code — lowercase, e.g. `cloudflare`, `route53`. Full list: lego DNS providers documentation.
- `delayBeforeChecks` is a **Go duration string** (`30s`, `2m`), default `0s`. Traefik verifies the TXT record itself before telling Let's Encrypt; this delays that check. Useful when your network blocks the container's outbound DNS queries, or when your provider's API is slow to propagate.
- `resolvers` overrides which DNS servers lego uses to check propagation (default: system resolvers). Set `1.1.1.1:53`/`8.8.8.8:53` when the container's DNS is unreliable.
- Traefik supports only **one** DNS provider per resolver. For multiple providers/accounts, CNAME `_acme-challenge.example.com` to a domain handled by the configured provider (CNAME following is on by default; `LEGO_DISABLE_CNAME_SUPPORT=true` turns it off).
## 4. Docker Compose: credentials live on the Traefik container
Lego reads provider credentials **only from environment variables** (a `_FILE` suffix reads the value from a file instead). These go on the **traefik** service, not your app services.
```yaml
services:
traefik:
image: traefik:v3
command:
- --configFile=/etc/traefik/traefik.yml
- --log.level=DEBUG # drop to INFO once issuance works
environment:
# Cloudflare, preferred: set CF_DNS_API_TOKEN to an API token
# with Zone:Read + DNS:Edit on every zone involved.
# (legacy alternative: CF_API_EMAIL + CF_API_KEY — grants full account access, avoid)
# Aliases also work: CLOUDFLARE_DNS_API_TOKEN
volumes:
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./acme.json:/letsencrypt/acme.json
ports:
- "80:80"
- "443:443"
```
Provider env vars (verified against lego docs):
| Provider | Code | Env vars |
|---|---|---|
| Cloudflare | `cloudflare` | `CF_DNS_API_TOKEN` (preferred; needs Zone:Read + DNS:Edit) or legacy `CF_API_EMAIL` + `CF_API_KEY`. Optional split: `CF_ZONE_API_TOKEN` (Zone:Read only) |
| Amazon Route 53 | `route53` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`; optional `AWS_HOSTED_ZONE_ID` to pin the zone. IAM needs `route53:ChangeResourceRecordSets`, `route53:GetChange`, `route53:ListResourceRecordSets`, `route53:ListHostedZonesByName` |
Provider-side propagation tunables (also env vars): `CLOUDFLARE_PROPAGATION_TIMEOUT` (default 120s), `CLOUDFLARE_TTL` (default 120s), `AWS_PROPAGATION_TIMEOUT` (default 120s), `AWS_TTL` (default 10s).
## 5. Dynamic config: attach the resolver to a router
Labels on your app service (dynamic config):
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
- "traefik.http.routers.myapp.entrypoints=websecure"
- "traefik.http.routers.myapp.tls=true"
- "traefik.http.routers.myapp.tls.certresolver=dns-resolver"
- "traefik.http.routers.myapp.tls.domains[0].main=example.com"
- "traefik.http.routers.myapp.tls.domains[0].sans=*.example.com"
```
Wildcard + apex note: a cert for `*.example.com` does **not** cover the bare `example.com`. Request both by setting `main: example.com` with `sans: *.example.com` — this triggers two DNS-01 challenges, and both write to the same `_acme-challenge` TXT name. The lego provider table notes which providers handle this; if your provider's TXT TTL exceeds the challenge timeout, the second challenge can fail — raise propagation timeouts or lower the TXT TTL.
## 6. Debugging `unable to obtain certificate`
Work this list in order. Do every step against the **staging** CA first (`caServer: https://acme-staging-v02.api.letsencrypt.org/directory`) so failed attempts don't count against production rate limits.
1. **Read the actual error**: `docker logs traefik` with `--log.level=DEBUG`. The log line names the failing piece (provider auth, propagation, ACME order). Don't guess — the provider name and HTTP status are usually in the line.
2. **Is the resolver even loaded?** Check the Traefik dashboard / API for the resolver name. If it's missing, the `certificatesResolvers` block is in the wrong config source (dynamic file instead of static) or has a YAML/TOML syntax error that prevented startup parsing.
3. **Provider auth**: 401/403 from the DNS API means wrong or insufficient credentials. Cloudflare: token needs Zone:Read + DNS:Edit on every zone involved; using the Global API Key when you meant a token (or vice versa) fails. Route53: IAM policy missing one of the four actions fails at TXT creation.
4. **TXT propagation**: if the log shows the TXT record was created but validation timed out, raise `propagation.delayBeforeChecks` (e.g. `60s`) and/or the provider's `*_PROPAGATION_TIMEOUT`. Set explicit `resolvers` (`1.1.1.1:53`) if the container's DNS is filtered.
5. **Wrong provider code**: `dnsChallenge.provider` must be the exact lego code (`cloudflare`, not `Cloudflare`). A wrong code fails at startup with an unknown-provider error.
6. **acme.json problems**: file must exist and be writable by the Traefik process; a corrupted file (e.g. from an interrupted write) can be renamed aside and re-issued — but only after the underlying error is fixed, or you'll hit rate limits re-issuing.
7. **Rate limits**: Let's Encrypt caps failed validations per hostname per hour and certificates per domain per week. If everything "looks fixed" but issuance still fails, you may be rate-limited — switch to staging and wait it out.
8. **Only one resolver challenge per router**: don't set `httpChallenge` and `dnsChallenge` on the same resolver and expect both to run; pick one. Wildcard routers must point at the DNS-01 resolver.
## Quick failure table
| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing requested; no log lines about ACME | Resolver defined in dynamic config, or router label misspells the resolver name | Move `certificatesResolvers` to static `traefik.yml`; match `tls.certresolver` name exactly |
| 401/403 from DNS API | Bad token or missing permissions | Cloudflare: token needs Zone:Read + DNS:Edit; Route53: full IAM action set |
| TXT created, validation times out | Propagation slower than checks | `delayBeforeChecks: 60s`, explicit `resolvers`, raise provider `*_PROPAGATION_TIMEOUT` |
| Wildcard issued but apex domain still insecure | Only `*.example.com` requested | Add `main: example.com` + `sans: *.example.com` via `tls.domains` |
| Worked once, fails on renew | Storage not persisted / permissions changed | Keep `acme.json` on a persistent volume with owner-only permissions |
| All attempts fail after many retries | Let's Encrypt rate limit | Debug on staging CA; wait out the limit window |