Skip to content
GitHub

Custom domains and HTTPS

This guide assumes the platform is configured with a Gateway and a clusterIssuer; if not, start with Exposing apps and HTTPS.

  1. Add the domain to the app. Open the app, Settings tab. Under Domains, add api.shop.example.com to Custom domains and press Save. Or send the full list:

    Terminal window
    curl -fsS -X PATCH "$KUBEN_URL/api/v1/projects/shop/environments/production/apps/api" \
    -H "Authorization: Bearer $KUBEN_TOKEN" -H 'Content-Type: application/json' \
    -d '{"domains": ["api.shop.example.com"]}'
  2. Point DNS at the gateway. Create an A/AAAA record (or a CNAME) for the domain pointing at the Gateway’s address. kubectl -n kuben-system get gateway kuben shows it.

  3. Check it. Press Check DNS in the same Domains section (GET …/apps/api/domains). For every hostname it reports:

    Status Meaning
    ok resolves to the gateway
    mismatch resolves somewhere else
    unresolved no record yet, or not propagated
    unknown the gateway has no address to compare with

    In a production environment, the domain change is a deployment like any other and waits for its approval first.

  4. Wait for the certificate. Kuben adds an HTTPS listener for the hostname to its Gateway, and cert-manager issues the certificate through the HTTP-01 challenge. Plain HTTP requests are redirected to HTTPS. Within a minute or two https://api.shop.example.com answers.

Kuben refuses the same domain on a second app with 409 Conflict. At the gateway, each hostname only accepts routes from the namespace that claimed it first, so one tenant cannot take over another tenant’s domain by adding it to their own app.

An organization claims a domain before its apps use it. A claim on example.com covers the domain itself and every name below it, such as shop.example.com and api.example.com.

  • Verified is exclusive. Once a claim is verified, no other organization on the installation can verify an overlapping name or serve a host below it. Kuben checks this in one transaction, so two organizations can never both win.
  • Optional by default. Unclaimed domains still work. With domains.require_claim = true, an app can only use domains its organization has verified. Either way, a domain another organization verified is refused.

In the console, open Domains in the sidebar and press Claim under Claims. Claims and DNS provider accounts belong to the organization, so only owners manage them. With the API:

Terminal window
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"domain":"example.com"}' https://kuben.example.com/api/v1/domains

The answer names a TXT record, _kuben-challenge.example.com, and the value it must hold. There are two ways to prove the claim:

  • With a TXT record. Create that record, then verify: in the console, Verify with The TXT record I added and press Verify, or
    Terminal window
    curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{}' \
    https://kuben.example.com/api/v1/domains/<id>/verify
    Kuben reads the record through DNS-over-HTTPS (domains.doh_url), so what counts is the public DNS. If the record is missing or wrong, the claim stays pending and lastError says why.
  • With a DNS provider account. If one of your organization’s provider accounts holds the domain’s zone, {"provider":"cf"} verifies the claim without a TXT record.

Revoking a claim (DELETE /api/v1/domains/<id>) removes the protection. Apps keep their domains.

Add a Cloudflare account once, with an API token that has Zone:Read and DNS:Edit on your zones. In the console: Domains, DNS providers, enter a Name and the Cloudflare API token, and press Add provider. Adding one needs a signed-in session; API tokens are refused. The API call, from such a session:

Terminal window
curl -X POST -H 'X-Kuben-Client: curl' -b cookies.txt -H 'content-type: application/json' \
-d '{"name":"cf","kind":"cloudflare","token":"<api token>"}' \
https://kuben.example.com/api/v1/dns-providers

Kuben checks the token before saving it, stores it encrypted, and never shows it again.

Then write an app’s records: on the app’s Settings tab, under DNS records, pick the Provider and press Write records. Or:

Terminal window
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"provider":"cf"}' \
https://kuben.example.com/api/v1/projects/shop/environments/prod/apps/web/dns

Every custom domain your organization has verified gets records pointing at the Gateway:

  • By default: A and AAAA records for the Gateway’s public addresses.
  • With domains.cname_target: a CNAME to that name.

Kuben is careful with records it did not write:

  • Tagging. Its records carry the comment kuben:<organization>.
  • Conflicts. A record of the same name and type without that comment is reported as a conflict and never overwritten.
  • Deletion. A record is deleted only when Cloudflare still reports the id Kuben stored for it.
  • Retries. Running the call again is safe: records that are already right are reported unchanged.

HTTP-01 challenges need port 80 to reach the Gateway directly. For wildcard certificates, for apex names, or behind a proxy, use DNS-01 through Cloudflare:

Terminal window
kuben dns01-issuer --email ops@example.com --token-file /root/cloudflare-token
kubectl patch kubenconfig kuben --type merge -p '{"spec":{"clusterIssuer":"letsencrypt-dns"}}'

The command stores the token in a Secret next to cert-manager and creates the ClusterIssuer letsencrypt-dns. Add --staging to test against Let’s Encrypt’s staging server, or --dry-run to print the objects without applying them.

For each custom domain, an app’s Doctor (Open Doctor on the app page) also reports:

  • claim: whether your organization verified the domain.
  • delegation: the zone’s public name servers.
  • proxy: whether a provider proxies the record. A proxy means visitors reach the provider’s edge rather than your Gateway, so use DNS-01 certificates there.

Every HTTP app also answers on <app>-<project>-<environment>.<baseDomain>, for example api-shop-production.apps.example.com. With many apps, provide a wildcard certificate for *.<baseDomain> (a DNS-01 Certificate) and name its Secret in the KubenConfig; all generated hostnames then share one listener instead of each taking one:

Terminal window
kubectl patch kubenconfig kuben --type merge -p '{"spec":{"wildcardTlsSecret":"<secret>"}}'
  • A Gateway holds at most 64 listeners, and Kuben uses at most 60. Beyond that, use the wildcard listener for generated hosts. Custom domains still take a listener each; ListenerSet support (experimental in Gateway API) is on the roadmap.
  • cert-manager must run with Gateway API support (config.enableGatewayAPI=true in its Helm values).
  • TLS terminates at your Gateway controller (Traefik, Envoy Gateway, Cilium). Kuben configures it but never sees the traffic.