Back to Engineering Blog

The Wildcard That Wouldn't Fly: DNS-01, Traefik, and the .ovh Public Suffix List Trap

6 min read
traefik
docker
dns
homelab
The Wildcard That Wouldn't Fly: DNS-01, Traefik, and the .ovh Public Suffix List Trap

I’ve been running Traefik as my reverse proxy for months. Wildcard certificates via DNS-01 challenge? I thought I had it figured out. Cloudflare worked flawlessly: set the API token, point the resolver, done. Thirty-six services, one wildcard cert, zero drama.

Then I had to set up the same infrastructure for a .ovh domain.

It took me three days. Not because the challenge failed, but because the zone detection was fundamentally broken before I even started.

If you own a .ovh domain and you’re trying to get a wildcard certificate through Traefik’s native DNS provider, you’re probably hitting the same wall I did. Here’s what’s happening under the hood and how to actually fix it.

The 404 that made no sense

The setup looked standard. Traefik v3.0, native ovh DNS provider, OVH API credentials configured. I ran the container and checked the logs, expecting a clean certificate generation.

Instead, I got hit with this raw error:

Unable to obtain ACME certificate for domains error="unable to generate a certificate for the domains [mydomain.ovh *.mydomain.ovh]: error: one or more domains had a problem:\n[*.mydomain.ovh] acme: error presenting token: ovh: error when call api to add record (/domain/zone/ovh/record): OVHcloud API error (status code 404): Client::NotFound: \"This service does not exist\"

Look closely at the endpoint it’s trying to hit: /domain/zone/ovh/record.

That’s not my domain. My domain is mydomain.ovh. The zone should be mydomain.ovh, not ovh. Something upstream was stripping my actual domain and passing only the top-level domain to the OVH API.

It wasn’t a typo. It wasn’t a permission issue. It was the Public Suffix List.

What the PSL actually does (and why it broke everything)

The Public Suffix List (PSL) is a catalog of domain suffixes under which users can register their own domains. Think .com and .org, but also .co.uk, .gov.au, and yes—.ovh.

Lego, the ACME client Traefik uses under the hood, relies on the PSL to determine the DNS zone when performing a DNS-01 challenge. It looks at your domain, consults the PSL, and strips the registrable suffix to find the authoritative zone.

For mydomain.com, it correctly detects mydomain.com as both the domain and the zone. But .ovh is on the PSL as a top-level entry. So when lego processes mydomain.ovh, it sees ovh as a public suffix, strips it, and concludes the authoritative zone is just ovh.

That makes sense for browsers checking cookies. But for DNS-01 zone detection, it’s catastrophic. Lego tries to create a TXT record in the ovh zone (which I obviously don’t own), and OVH’s API rightfully returns a 404.

The exec provider escape hatch

After ruling out the native provider, I found the path forward: lego’s exec provider. Instead of lego managing the DNS record itself, it shells out to a custom script. You handle the API call. You dictate the zone.

Note: The exec DNS provider was introduced in lego v4.21.0. Traefik v3.0 ships with an older version. You must upgrade to Traefik v3.4+ to use this workaround.

The Docker Compose Trap

Getting a custom script to handle OVH’s strict SHA1 API authentication signature from inside a Traefik container is a battle of its own. I opted to embed the script directly in the entrypoint of the docker-compose.yml.

This is where Docker variable escaping becomes a nightmare. Because we are inside a compose file, every single $ required by bash or awk needs to be escaped as $$.

Here is the exact, battle-tested docker-compose.yml snippet that bypasses the PSL bug and successfully generates the wildcard certificate:

services:
  traefik:
    image: traefik:v3.4
    # ... your ports and volumes ...
    environment:
      - OVH_ENDPOINT=your ovh zone
      - OVH_APPLICATION_KEY=your key
      - OVH_APPLICATION_SECRET=your secret
      - OVH_CONSUMER_KEY=your consumer key
    entrypoint:
      - sh
      - -c
      - |
        mkdir -p /scripts
        apk add --no-cache curl 2>&1 | tail -3

        cat > /scripts/ovh-dns.sh << 'YEOFSCRIPT'
        #!/bin/sh
        ACTION=$$1; FQDN=$$2; VALUE=$$3
        DOMAIN=$$(echo "$$FQDN" | sed 's/^_acme-challenge\.//; s/\.$$//')
        ZONE="mydomain.ovh"; RECORD_NAME="_acme-challenge"
        
        case "$$DOMAIN" in *.*.*)
            SUB=$$(echo "$$DOMAIN" | sed "s/\.$${ZONE}$$//")
            RECORD_NAME="_acme-challenge.$${SUB}" ;;
        esac
        
        TIMESTAMP=$$(curl -s "$${OVH_ENDPOINT}/1.0/auth/time")
        
        gen_sig() { 
          local m="$$1" u="$$2" b="$$3"
          local str="$${OVH_APPLICATION_SECRET}+$${OVH_CONSUMER_KEY}+$$m+$$u+$$b+$${TIMESTAMP}"
          printf '%s' "$$str" | sha1sum | cut -d' ' -f1 
        }
        
        call_api() { 
          local m="$$1" u="$$2" b="$$3"
          local s=$$(gen_sig "$$m" "$${OVH_ENDPOINT}$${u}" "$$b")
          curl -s -X "$$m" \
            -H "X-Ovh-Application: $${OVH_APPLICATION_KEY}" \
            -H "X-Ovh-Consumer: $${OVH_CONSUMER_KEY}" \
            -H "X-Ovh-Timestamp: $${TIMESTAMP}" \
            -H "X-Ovh-Signature: $$1$$$${s}" \
            -H "Content-Type: application/json" \
            $${b:+-d "$$b"} \
            "$${OVH_ENDPOINT}$${u}"
        }
        
        case "$$ACTION" in
            "present")
                BODY="{\"fieldType\":\"TXT\",\"subDomain\":\"$${RECORD_NAME}\",\"ttl\":60,\"target\":\"$$VALUE\"}"
                RESULT=$$(call_api "POST" "/1.0/domain/zone/$${ZONE}/record" "$$BODY")
                echo "$$RESULT"
                call_api "POST" "/1.0/domain/zone/$${ZONE}/refresh" > /dev/null ;;
            "cleanup")
                RESULT=$$(call_api "GET" "/1.0/domain/zone/$${ZONE}/record?fieldType=TXT&subDomain=$${RECORD_NAME}")
                for ID in $$(echo "$$RESULT" | grep -oE '[0-9]+'); do 
                  call_api "DELETE" "/1.0/domain/zone/$${ZONE}/record/$$ID" > /dev/null
                done
                call_api "POST" "/1.0/domain/zone/$${ZONE}/refresh" > /dev/null
                echo "Cleaned up" ;;
        esac
        YEOFSCRIPT
        
        chmod +x /scripts/ovh-dns.sh
        exec traefik "$$@"
    command:
      - "--certificatesresolvers.myresolver.acme.dnschallenge.provider=exec"
      - "--certificatesresolvers.myresolver.acme.dnschallenge.delaybeforecheck=60"
      - "--certificatesresolvers.myresolver.acme.email=your@email.com"
      - "--certificatesresolvers.myresolver.acme.storage=/acme.json"
      # ... rest of your traefik commands ...

The moment it worked

After deploying this, lego stopped trying to guess the zone. It executed /scripts/ovh-dns.sh, passed the tokens, and my script hardcoded the zone injection exactly where it needed to be.

The final confirmation in the logs was exactly what I wanted to see:

No ACME certificate generation required for domains ["mydomain.ovh","*.mydomain.ovh"]

The Public Suffix List is an excellent tool for web browsers, but a terrible DNS zone detector. Any domain on the PSL—like .ovh, .uk, or .au—has the potential to trip up automated Let’s Encrypt zone detection.

If your DNS provider has an API, the exec provider is your escape hatch. Solve the edge case once, hardcode the reality of your infrastructure, and forget it exists.

Need this applied to your platform?

At Ionastec we help CTOs and Tech Leads ship scalable, high-performance systems. Let's talk.

Talk to Ionastec