Skip to main content

Certificates

Traefik obtains all certificates from Let’s Encrypt, using two different challenge methods for two different kinds of hostname.

Why two

HTTP-01 requires Let’s Encrypt’s validation servers to reach the host on port 80. The Tailscale-only surfaces are not reachable from the internet, so HTTP-01 can never validate them. Correction, 2026-07-31. This page previously said those surfaces “have no public A record — that is the point of them”. That is not what public DNS returns: each of traefik, portainer, grafana, vault, storage and litellm resolves on public resolvers to the host’s private tailnet address. The records exist so that machines on the tailnet can resolve the names without extra configuration; the addresses they return are unroutable from the internet, which is why HTTP-01 still cannot work. The operational conclusion is unchanged — DNS-01 is required — but the reason is unreachable, not unpublished, and the difference matters to anyone reasoning about exposure. Keycloak is the exception among administrative surfaces: it is publicly routed, so it has a public A record and uses HTTP-01 like any other public host. See Surfaces. DNS-01 validates by publishing a TXT record instead, which works for any hostname regardless of whether it is publicly reachable. It needs an API-driven way to write that record — which, since the Cloudflare cutover, is a Traefik setting rather than a service.

The DNS-01 path

A resolver block and a scoped token:
provider: cloudflare selects lego’s first-class Cloudflare implementation, which Traefik already ships. Certificate issuance is now configuration rather than a service. delayBeforeCheck: 30s is still doing work: lego waits that long before asking Let’s Encrypt to validate, so the TXT record has time to propagate. The resolvers list is which nameservers lego polls to confirm the record is visible before proceeding.
Keys under dnsChallenge are flat here because Traefik is pinned to v2.11. The propagation.* block is v3-only and would be silently ignored rather than rejected, which is the kind of failure that looks like working configuration.
Traefik does not interpolate ${VAR} in its YAML config, so the ACME email and CA server are literal in traefik.yml; the token arrives as an environment variable, which is read by lego rather than by Traefik’s config parser.

What replaced what

Until 2026-07-27 this ran through services/dns-manager, a small Flask webhook implementing lego’s httpreq provider against the Hostinger DNS API. DNS management moved to Cloudflare, the shim was deleted from the repository, and its container is gone from the host — the edge stack is now Traefik and Portainer. The capability did not change; the component did. The same four hostnames use DNS-01 through the same resolver name, letsencrypt-dns — what changed is that the challenge is now solved in-process rather than by a container.

Verified, on one hostname

A forced reissue of traefik.hill90.com on 2026-07-27 shows lego writing the challenge record through Cloudflare and cleaning it up afterwards:
The certificate now served there carries notBefore=Jul 27 02:09:53 2026 GMT. That looks an hour earlier than the log lines because Let’s Encrypt backdates notBefore by an hour; the certificate’s own SCT timestamps are 03:08:23Z, which matches the issuance above. That is one hostname, not four. grafana, portainer and vault are still serving certificates issued before the cutover — dated 14 June, 14 June and 26 July respectively — so the new path is proven for the host that was deliberately reissued and inferred for the rest. They will exercise it on renewal. Worth recording that the first attempt failed: the DNS-01 resolver was skipped at 03:05:03Z because acme-dns.json had mode 644 rather than 600. The cutover fixed that and retried. Nameserver delegation has moved: the registry and whois both return adi.ns.cloudflare.com and art.ns.cloudflare.com. Note that not every public resolver agrees yet — at the time of writing 8.8.8.8 returns the Cloudflare pair while 1.1.1.1 and 9.9.9.9 still answer with the old parking nameservers from cache.

Hostinger has not gone away

Only the DNS-management half moved. Hostinger is still the VPS host and still the mail provider, and the records that serve mail still point there: Those are correct as they stand and should not be rewritten as part of any DNS work. DKIM is configured and signing works. The zone delegates all three selectors to Hostinger by CNAME, and the -a selector resolves through to a live RSA key (v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEF…, 409 characters). The -b and -c selectors resolve to v=DKIM1;p= with an empty key. That is normal: Hostinger provisions three selector slots and rotates through them, publishing the unused ones empty rather than omitting them. An empty p= is a spare rotation slot, not a gap.
Do not hand-add a DKIM TXT record here. Signing works through CNAME delegation, so the key lives in Hostinger’s zone and rotates there. Adding a TXT record at a selector that already has a CNAME creates a conflict and is a common way to break mail signing while appearing to fix it.
DMARC is published at p=none, so signing is in place but no enforcement policy is requested. No service in the current stack sends mail.

Rate limits

Let’s Encrypt production allows 50 certificates per registered domain per week and 5 validation failures per hostname per hour. Because of that, make deploy-infra uses staging certificates by default; production certificates require make deploy-infra-production or the deploy-infra.yml workflow, which sets them explicitly. The edge stack is deliberately excluded from push-triggered deploys for the same reason — recreating Traefik re-requests certificates, and that should be a decision rather than a side effect of a merge.

Source documents