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 oftraefik, 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
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.${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 throughservices/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 oftraefik.hill90.com on 2026-07-27 shows lego writing the challenge
record through Cloudflare and cleaning it up afterwards:
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.
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
docs/architecture/certificates.md— full challenge flow, troubleshooting for failed issuance and DNS record verificationdocs/runbooks/dns-cutover.md— the Hostinger to Cloudflare movedocs/reference/dns.md— DNS record inventory and the management scripts