## TL;DR

For "unable to obtain certificate": 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. Check the Traefik dashboard / API for the resolver name. Set explicit resolvers (1.1.1.1:53) if the container's DNS is filtered.

```text
unable to obtain certificate
```

## When to use

You are seeing this: There is no HTTP-01 or TLS-ALPN-01 path to a wildcard cert. Use this skill when you run into "Fix Traefik wildcard certificate failures: ACME DNS-01 in Docker".

## When not to use

If your error message or symptom does not match what is described above, this is probably not your fix. Search for your exact error text instead of forcing this one to fit.

## Versions

Versions mentioned in the source: 1.1.1.1, 8.8.8.8. If you are on something much newer or older, the details may have shifted.

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