Eigen domein (custom domain via CNAME)
Dit document legt uit hoe een tenant bereikbaar wordt op een eigen adres, bijvoorbeeld tapster.organisatie.nl, en waarom de keten zo in elkaar zit. Voor de instructie aan de beheerder zie de userdocs bij apps/frontend/src/app/features/backoffice/settings/pages/eigen-domein/.
Probleem
Een tenant draait standaard op een adres binnen een zone die wij zelf beheren. Klanten willen hun omgeving graag onder hun eigen merk aanbieden. Dat betekent een hostnaam in een DNS-zone waar wij geen toegang toe hebben, en dus ook geen certificaat kunnen uitgeven via de route die we voor onze eigen domeinen gebruiken.
Concept: één vast anker
De klant zet bij zijn eigen domeinbeheerder een CNAME die naar proxy.tapster.nl wijst. Die hostnaam is het anker, vastgelegd als CUSTOM_DOMAIN_ANCHOR in apps/api/src/common/services/k8s/cluster.constant.ts.
Bewust geen tenant-specifieke URL als doel. Zou elke klant naar zijn eigen tenant-adres wijzen, dan bepaalt de levensduur van één tenantaccount de CNAME’s van alle andere klanten. Eén anker houdt die afhankelijkheid weg.
De statusmachine
De opgeslagen status leeft op de tenant (customDomain) en loopt in deze volgorde:
| Status | Betekenis |
|---|---|
pending |
hostnaam ingevoerd, DNS nog niet gecontroleerd |
dns-ok |
DNS wijst naar ons, route en certificaat zijn aangevraagd |
active |
certificaat uitgegeven, getByHost matcht op deze hostnaam |
failed |
DNS klopt niet, of de uitgifte is definitief mislukt |
De volgorde is niet cosmetisch. Pas na een geslaagde DNS-controle maken we een Certificate aan. Een Certificate dat te vroeg wordt aangevraagd laat de HTTP-01-challenge falen en telt mee voor de rate limits van Let’s Encrypt, ook voor klanten die hun DNS wel goed hadden staan. Daarom is verify() in customDomainService.ts de enige plek die ensureCustomDomainIngress() aanroept.
Let op: zodra een domein op failed staat, leest refresh() de clusterstatus niet meer. Het komt daar alleen uit door opnieuw op “Controleren” te klikken.
Wat er op het cluster gebeurt
Bij een geslaagde DNS-controle komen er twee resources bij in de tenant-namespace:
IngressRoute tenant-<tenantId>-customop entrypointwebsecure, met een Host-match op de klanthostnaam entls.secretNamenaar het nieuwe secret.Certificate tenant-<tenantId>-custom, met het labeltapster.nl/acme-solver: http01.
Dat label is het scharnierpunt. De ClusterIssuer heeft twee solvers: DNS-01 zonder selector, en HTTP-01 met een selector.matchLabels op dat label. Zonder label kiest cert-manager de DNS-01-solver en faalt de uitgifte, want de zone van de klant staat niet in ons TransIP-account. De solver-keuze zit dus niet in onze code maar in de ClusterIssuer.
De HTTP-01-challenge komt binnen op poort 80. De globale redirect naar HTTPS blokkeert dat niet: Let’s Encrypt volgt de redirect, en het solver-record van cert-manager hangt aan alle entrypoints, dus ook aan websecure.
Timing: de eerste poging mislukt geregeld
Uitgifte duurt normaal onder de minuut. Maar de eerste HTTP-01-validatie mislukt met enige regelmaat, bijvoorbeeld met een Timeout after connect. cert-manager gaat dan in backoff en vraagt ongeveer een uur later vanzelf opnieuw aan. Die tweede poging slaagt doorgaans gewoon.
Een invalid challenge is dus geen eindstand. Kijk altijd eerst of er een nieuwere Order is die valid staat voordat je gaat sleutelen.
Twee valkuilen bij het lezen van de status:
- Let’s Encrypt zet
notBeforeop het certificaat standaard ongeveer een uur terug in de tijd. Dat veld liegt dus over het werkelijke uitgiftemoment. De timestamp van deReady-conditie klopt wel. - De
Failed-reden staat op deIssuing-conditie, niet opReady.mapCertificateStatuskijkt bewust alleen naarReady, zodat een lopende retry niet als mislukking in de UI belandt.
Het scherm stopt na vijf minuten met pollen (POLL_TIMEOUT_MS). Een uur doorvragen kost honderden requests zonder dat de beheerder er iets aan heeft. In plaats daarvan meldt het scherm dat de aanvraag doorloopt.
Als het vastloopt
Controleer in deze volgorde:
# 1. Is er inmiddels een geldig certificaat?
kubectl -n tapster get certificate tenant-<tenantId>-custom
# 2. Zo niet, welke orders zijn er en wat is hun staat?
kubectl -n tapster get order,challenge | grep <tenantId>
# 3. Pas als een challenge echt blijft hangen: wat zegt Let's Encrypt?
kubectl -n tapster describe challenge <naam>
Staat er in stap 2 een Order op valid, dan is het klaar en is de UI alleen nog niet bijgewerkt. Openen van het scherm zet de status dan op active.
Blijft het na meerdere pogingen falen, kijk dan naar de foutmelding in stap 3. Vermeldt die een IP-adres, controleer dan of dat adres van ons is en of de site daar bereikbaar is. De sitetest op internet.nl geeft daar snel uitsluitsel over, ook voor IPv6.
Wat niet het probleem is
proxy.tapster.nl heeft naast een A-record ook een AAAA-record, gezet vanuit INGRESS_IPV6. Dat adres hoort echt bij onze load balancer en werkt: gemeten op 21 augustus 2026 is IPv6 identiek aan IPv4, met dezelfde poorten, headers en inhoud. Een timeout op dat adres is dus geen reden om aan de DNS of aan INGRESS_IPV6 te gaan sleutelen.
Terzijde voor wie daar toch gaat zoeken: GET /v6/kubernetes/clusters/{cluster}/load-balancers/{lb} in de TransIP-API kent geen ipSetup-veld. Dat hoort bij het klassieke HA-IP-product, niet bij de load balancer van een managed cluster.
Zie ook
- Userdocs voor de beheerder:
apps/frontend/src/app/features/backoffice/settings/pages/eigen-domein/userdocs/index.md - Werkplan en besluitvorming:
docs/superpowers/plans/2026-08-11-custom-domain-cname.md