# Fix Traefik wildcard certificate failures: ACME DNS-01 in Docker

Getting wildcard certs from Let's Encrypt with Traefik in Docker fails for repeatable reasons: the resolver is defined in the wrong config (static vs dynamic), propagation keys are wrong, provider env vars or permissions are off, or TXT propagation is slower than the checks. This skill gives the exact static/dynamic split, a complete v3 DNS-01 resolver config in YAML and TOML, provider credential tables, propagation tuning, and an ordered checklist for 'unable to obtain certificate' errors.

Exact reference: {"kind":"skill_version","skill_id":"skl_ql-n3V_jHuxSnZMUram-Kg","version_id":"skv_kJblEMZ46Gx1ZIxRSNcvcg"}

Applicability: [{"constraint":"^3","technology":"traefik","version_scheme":"semver"},{"constraint":">=4","technology":"lego","version_scheme":"semver"},{"constraint":"docker compose","technology":"docker","version_scheme":"unknown"}]

# 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 |


## Supporting basis and limitations

All config keys verified against Traefik's official ACME certificate resolver reference: certificatesResolvers as static config, acme.email/storage required, dnsChallenge.provider/resolvers, propagation.delayBeforeChecks as Go duration default 0s, propagation.disableChecks/disableANSChecks/requireAllRNS, tls.domains main/sans for wildcard+apex (two DNS-01 challenges on the same TXT name), HTTP-01 needing port 80 and TLS-ALPN-01 needing port 443, wildcards only via DNS-01, one DNS provider per resolver with CNAME support (LEGO_DISABLE_CNAME_SUPPORT). Env vars verified on lego provider pages: Cloudflare CF_DNS_API_TOKEN (Zone:Read + DNS:Edit) vs legacy CF_API_EMAIL/CF_API_KEY, CLOUDFLARE_PROPAGATION_TIMEOUT default 120, CLOUDFLARE_TTL default 120; Route53 AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_REGION/AWS_HOSTED_ZONE_ID with the four required IAM actions and AWS_PROPAGATION_TIMEOUT default 120. Deprecated v2 flat keys delayBeforeCheck/disablePropagationCheck confirmed via Traefik CLI reference mirrors.

## Change and rationale

New skill covering the concrete failure modes of Let's Encrypt ACME DNS-01 wildcard certificates with Traefik v3 in Docker: static vs dynamic placement of certificatesResolvers, HTTP-01 vs TLS-ALPN-01 vs DNS-01 selection, v3 dnsChallenge.propagation keys (deprecated flat keys flagged), Docker Compose credential setup, wildcard+apex SAN configuration, and an ordered debugging checklist for 'unable to obtain certificate' errors.

Wildcard certificates can only be issued via DNS-01, and Traefik's config model (static certificatesResolvers referenced by name from dynamic router config) plus the v2-to-v3 key rename (delayBeforeCheck to propagation.delayBeforeChecks) are the two most common sources of silent failure. Existing documentation is spread across Traefik's ACME reference and lego's per-provider pages; a single task-shaped skill that names the exact keys, env vars, and debugging order reduces repeat troubleshooting for this exact setup.
