Custom domains & TLS
Put a knext app on your own hostname with a Knative DomainMapping and a cert-manager certificate, keep private apps private, and debug the usual failures.
A fresh deploy is reachable on the cluster's default domain (for example
acme.prod.203.0.113.10.sslip.io). Production traffic wants app.example.com and a certificate a
browser trusts. Both are Knative and cert-manager objects that sit next to your NextApp — knext
does not need to know about them, and they survive new deploys because they point at the app by name.
DNS app.example.com ──► Kourier load balancer ──► DomainMapping ──► Knative Service (your NextApp)
▲ │
TLS terminates here tls.secretName
(certificate from cert-manager) ◄──── SecretPrerequisites: Knative Serving with the Kourier ingress, cert-manager, a deployed NextApp
(this page uses acme in namespace prod), and permission to create a ClusterDomainClaim
(cluster-scoped, usually an admin).
A DomainMapping always publishes the hostname on the public load balancer. Labelling it
networking.knative.dev/visibility: cluster-local does not change that. Never use a
DomainMapping for an app that must stay private — see Private apps.
1. Point DNS at the cluster
Find the address Kourier is listening on:
kubectl -n kourier-system get svc kourier \
-o jsonpath='{.status.loadBalancer.ingress[0]}{"\n"}'- The result has an
ip(OCI, GKE): create an A recordapp.example.com → <ip>. - The result has a
hostname(AWS): create a CNAMEapp.example.com → <hostname>.
Check it from your machine:
dig +short app.example.comNo domain to hand? <anything>-<ip-with-dashes>.sslip.io is a public DNS name that resolves to the
IP in it, so app-203-0-113-10.sslip.io points at 203.0.113.10 with no DNS setup. Use the dashed
form so the name does not sit under your cluster's default-domain suffix.
2. Claim the hostname
Knative makes a hostname exclusive to one namespace with a ClusterDomainClaim. Unless your cluster
admin enabled automatic claims, create it before the mapping:
apiVersion: networking.internal.knative.dev/v1alpha1
kind: ClusterDomainClaim
metadata:
name: app.example.com
spec:
namespace: prod3. Map the hostname to the app
The Knative Service the operator creates has the same name as your NextApp, so ref.name is the
NextApp name:
apiVersion: serving.knative.dev/v1beta1
kind: DomainMapping
metadata:
name: app.example.com
namespace: prod
spec:
ref:
apiVersion: serving.knative.dev/v1
kind: Service
name: acmekubectl apply --validate=strict -f claim.yaml -f domainmapping.yaml
kubectl -n prod get domainmapping app.example.com
# NAME URL READY
# app.example.com http://app.example.com True
curl -i http://app.example.com/The app still scales to zero behind the mapping: the first request after idle wakes it, and the mapping keeps working across new deploys and revisions.
4. Add TLS
The approach here is a per-hostname certificate: cert-manager writes a Secret, and the
DomainMapping serves it. It changes nothing cluster-wide and needs no extra Knative component.
Choose how the certificate is issued
Why cert-manager's default HTTP-01 setup returns 404 on a Kourier-only cluster. The HTTP-01
solver publishes the challenge path with a standard Kubernetes Ingress
(networking.k8s.io/v1). Kourier does not read those: it only serves Knative's own networking
Ingress (networking.internal.knative.dev), and a Kourier-only cluster has no controller for the
standard kind. Setting ingressClassName: kourier.ingress.networking.knative.dev on the solver does not
change that. The challenge stays pending with wrong status code '404', expected '200', even though
the solver pod answers correctly when reached directly.
Three ways to issue a trusted certificate, by what they need:
| Path | Scope | Status on the reference cluster |
|---|---|---|
| HTTP-01 through a Knative routing rule | One namespace | Verified: Let's Encrypt staging, then production, served on the hostname |
| DNS-01 | One namespace | Not yet verified |
| Cluster-wide auto-TLS | Whole cluster, admin-owned | Not yet verified |
HTTP-01 on a Kourier-only cluster
This path makes the challenge reachable through Kourier itself. You write one Knative networking
Ingress for the hostname that sends /.well-known/acme-challenge/ to cert-manager's solver and
everything else to your app, and that also serves the certificate. It replaces the
DomainMapping for that hostname (steps 3 and 4's tls.secretName). Everything stays in your
namespace.
Read these before using it.
networking.internal.knative.devis Knative's internal API, not a user-facing contract. It can change between Knative releases; re-test this path when you upgrade Knative.- Never create it for a hostname that a
DomainMappingalready serves. Delete the mapping first. Two routing rules for one hostname were not tested. The risk is that the gateway rejects the conflicting route update, which could affect routing for other apps too. - Whether Knative or the knext operator ever reconciles or removes a hand-written rule like this was not observed. In testing it persisted unchanged, but nothing owns it: you keep it in sync yourself.
- Knative's hostname claims do not cover this object, so keep the
ClusterDomainClaimfrom step 2 in place: it stops aDomainMappingin another namespace from taking the name. - Creating these objects usually needs cluster-admin rights.
- Certificate renewal uses the same path, but a renewal has not been observed on the reference cluster yet.
An issuer whose solver pods carry a label you choose, and a Service that selects them. The
Ingress cert-manager still creates for the solver is ignored on this cluster, which is harmless:
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: letsencrypt-http01
namespace: prod
spec:
acme:
# Start on staging (untrusted certificates, generous rate limits), then switch to
# https://acme-v02.api.letsencrypt.org/directory and re-issue.
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: you@example.com
privateKeySecretRef:
name: letsencrypt-http01-account-key
solvers:
- http01:
ingress:
serviceType: ClusterIP
podTemplate:
metadata:
labels:
acme-solver-for: app.example.com
---
apiVersion: v1
kind: Service
metadata:
name: acme-solver
namespace: prod
spec:
selector:
acme-solver-for: app.example.com
ports:
- port: 80
targetPort: 8089 # the port cert-manager's solver listens onThe routing rule. The second path is the same rule a DomainMapping would generate for the app
acme:
apiVersion: networking.internal.knative.dev/v1alpha1
kind: Ingress
metadata:
name: app-example-com
namespace: prod
annotations:
networking.knative.dev/ingress.class: kourier.ingress.networking.knative.dev
spec:
httpOption: Enabled # keep port 80 open: HTTP-01 is validated over plain HTTP
rules:
- hosts:
- app.example.com
visibility: ExternalIP
http:
paths:
- path: /.well-known/acme-challenge/
splits:
- percent: 100
serviceName: acme-solver
serviceNamespace: prod
servicePort: 80
- rewriteHost: acme.prod.svc.cluster.local
splits:
- percent: 100
appendHeaders:
K-Original-Host: app.example.com
serviceName: acme
serviceNamespace: prod
servicePort: 80
tls:
- hosts:
- app.example.com
secretName: app-example-com-tls
secretNamespace: prodApply the claim from step 2, then the issuer and the routing rule, then the Certificate from
Request the certificate with
issuerRef: {name: letsencrypt-http01, kind: Issuer}:
kubectl apply --validate=strict -f claim.yaml -f issuer-http01.yaml -f route-app-example-com.yaml
kubectl apply --validate=strict -f certificate.yaml
kubectl -n prod get certificate app-example-com # READY True, about a minute on staging
curl -v https://app.example.com/ 2>&1 | grep -E 'issuer:|SSL certificate verify'Until the Secret exists the routing rule serves plain HTTP only, which is all the challenge needs.
On the reference cluster, staging issued in about a minute; after switching the issuer to production
and re-issuing, curl -v reported issuer: C=US; O=Let's Encrypt and SSL certificate verify ok.
DNS-01
DNS-01 (does not depend on the ingress). cert-manager proves control of the domain by writing a DNS record, so the request path through Kourier is not involved. The example uses Cloudflare; pick the solver for your DNS provider from the cert-manager documentation and keep the rest unchanged.
Not yet verified on the reference cluster. The Cloudflare issuer below follows the cert-manager
documentation but has not been run end to end here. What has been verified is everything after the
issuer: a Certificate writing a Secret that a DomainMapping serves (tested with a self-signed
issuer), and Let's Encrypt issuing through the HTTP-01 path above.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-dns01
spec:
acme:
# Start on the staging endpoint (untrusted certificates, generous rate limits),
# then switch to https://acme-v02.api.letsencrypt.org/directory.
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: you@example.com
privateKeySecretRef:
name: letsencrypt-dns01-account-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token # in the cert-manager namespace
key: api-tokenA ClusterIssuer reads its credentials from the cert-manager namespace. Use a namespaced
Issuer (same spec, kind: Issuer in the certificate's issuerRef) to keep everything in prod.
Self-signed (to test the wiring). No DNS provider needed; browsers will not trust the result:
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: selfsigned
namespace: prod
spec:
selfSigned: {}Request the certificate and reference it
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: app-example-com
namespace: prod # must be the DomainMapping's namespace
spec:
secretName: app-example-com-tls
dnsNames:
- app.example.com
issuerRef:
name: letsencrypt-dns01 # or: selfsigned
kind: ClusterIssuer # or: IssuerAdd tls.secretName to the mapping you created in step 3:
apiVersion: serving.knative.dev/v1beta1
kind: DomainMapping
metadata:
name: app.example.com
namespace: prod
spec:
ref:
apiVersion: serving.knative.dev/v1
kind: Service
name: acme
tls:
secretName: app-example-com-tlskubectl apply --validate=strict -f certificate.yaml -f domainmapping.yaml
kubectl -n prod get certificate app-example-com # READY True
kubectl -n prod get domainmapping app.example.com # URL now https://…, READY True
curl -i https://app.example.com/Check what the load balancer actually presents for your hostname:
openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -ext subjectAltName -datesTwo behaviours to plan for:
- Plain HTTP keeps working. Adding a certificate does not redirect
http://tohttps://. Redirect at your edge (CDN, WAF) if you need it. - Without the Secret the mapping does not go Ready. It reports
ReconcileIngressFaileduntil the Secret exists, so apply theCertificatefirst and wait for it to beREADY True.
Cluster-wide auto-TLS
Knative can also issue a certificate for every mapping on its own. That is a cluster-level switch (the
cert-manager integration for Knative plus external-domain-tls in config-network) that the
cluster admin owns, not something an app deploys. With it enabled, Knative adds the HTTP-01
challenge path to the mapping's own routing rule, so the mapping keeps working and renewals need no
extra objects. It is the supported long-term way to do HTTP-01 with Kourier, but it has not been
verified on the reference cluster yet. Until it is enabled, a mapping reports
CertificateProvisioned: TLSNotEnabled — that is expected when you supply the Secret yourself.
5. Make redirects use your domain
Next.js builds request.url from the address its server listens on, not from the hostname the
browser asked for. Inside a pod that address is 0.0.0.0, so a redirect built from request.url:
return NextResponse.redirect(new URL('/dashboard', request.url));answers Location: http://0.0.0.0:8080/dashboard. The browser cannot reach that address, and any
cookie the redirecting response set (a login session, the Draft Mode cookie) is never sent back,
because the browser is now on a different host.
Tell the app which hostnames it is served on with networking.publicHosts. Put your primary domain
first:
const config: KnativeNextConfig = {
name: 'acme',
networking: {
publicHosts: ['app.example.com', 'www.example.com'],
},
};or directly on the resource:
apiVersion: apps.kn-next.dev/v1alpha1
kind: NextApp
spec:
networking:
publicHosts:
- app.example.com
- www.example.comThe operator turns the list into the KNEXT_PUBLIC_ORIGINS environment variable on the app, one
https:// entry per host (https://app.example.com,https://www.example.com). The scheme comes from
that entry, so the same redirect answers Location: https://app.example.com/dashboard whatever the
request used to reach the cluster. The pod logs the active list once at startup as
PUBLIC_ORIGINS:https://app.example.com,https://www.example.com (env).
Each entry is a lowercase hostname with no scheme, port, path, user name or wildcard, at most 253
characters with labels of at most 63, and at most 32 are allowed. Per-pull-request preview
deployments do not inherit the list, so a preview's redirect never points at production. The cluster rejects anything else when you apply the resource, and knext deploy
checks the same rules before it builds. An empty or missing list changes nothing: the operator adds
no variable.
Upgrade the operator first, then the CLI. publicHosts is a new field on the NextApp
resource. An operator that predates it rejects a resource that carries it, and knext deploy stops
before touching the cluster and names the field. See Operator upgrade.
Setting the variable yourself
You can still set KNEXT_PUBLIC_ORIGINS directly, and it is the route to use on an operator that
does not have publicHosts yet:
const config: KnativeNextConfig = {
name: 'acme',
env: {
KNEXT_PUBLIC_ORIGINS: 'app.example.com,www.example.com',
},
};or on the resource, under spec.env. A value you set yourself, in env or from a Secret through
secrets.envMap, wins over publicHosts: the list is then ignored, so an app that already sets
the variable keeps behaving exactly as it did. Do not set both unless you mean it. A
KNEXT_PUBLIC_ORIGINS key inside a Secret you inject whole with secrets.envFrom is the exception:
it is shadowed by publicHosts, because Kubernetes applies a whole-Secret injection first and
the operator's variable on top of it. Set the variable under env or secrets.envMap instead. The variable also
accepts a port (localhost:3000) and an https:// or http:// prefix, which publicHosts does not.
How the origin is chosen:
- Only the listening address is replaced. A
Locationwhose origin is0.0.0.0or[::]is rewritten. A redirect to any other host, or a relative redirect such asredirect('/dashboard')fromnext/navigation, is left exactly as your code wrote it. The path, query string and fragment are always kept. - The hostname must be on your list. The request's
X-Forwarded-Host, then itsHost, is used if it matches an entry exactly. Case doesn't matter, but a port has to match too. If neither header matches, the first entry is used. That covers ingresses that don't pass the original hostname through, which is why the primary domain goes first. - The scheme comes from your list. An entry written as
https://app.example.comredirects tohttps://, andhttp://localhost:3000redirects tohttp://. An entry with no scheme meanshttps. No request header,X-Forwarded-Protoincluded, changes the scheme, so a client can't downgrade your redirects tohttp.
A request header is only ever compared against your list. Neither a hostname that isn't on it nor
a scheme from a header ever appears in a redirect, so a forged Host or X-Forwarded-Host cannot
send your users to another site.
A redirect whose address was rewritten also carries Vary: X-Forwarded-Host, Host, added to any Vary your app already sets,
so a CDN or browser cache keeps one cached redirect per hostname.
In the variable, each entry is a hostname, optionally with a port (localhost:3000) and optionally
prefixed with https:// or http://. Write the scheme when your app is served over plain HTTP, for
example a local test host. If you list the same hostname twice, the first entry wins. An entry with
a path, a user name or a wildcard is dropped, and the pod log says which one. publicHosts entries
are always served as https.
Without publicHosts or KNEXT_PUBLIC_ORIGINS nothing changes. Redirects built from
request.url keep the 0.0.0.0 origin, as they always have. Set one of them on every app that
redirects with an absolute URL.
request.url itself still carries the listening address; only the redirect is fixed. If your code
builds absolute URLs from request.url for anything else, such as an OAuth redirect_uri or a link
in an email, build them from your domain instead.
This applies to the standalone targets: Node, Bun and the compiled executable. The vinext target
builds request.url from the request's Host header, so its redirects already carry your domain.
Private apps
A private app is one only reachable inside the cluster, such as an internal tool or a backend. Do not
publish one with a DomainMapping.
Measured on a Knative + Kourier cluster, with a Knative Service labelled
networking.knative.dev/visibility: cluster-local:
| What was requested | Result |
|---|---|
| The app's default-domain URL from outside | 404 (private, as intended) |
DomainMapping with no label, public DNS name | 200 — published |
DomainMapping labelled cluster-local, public DNS name | 200 — published |
Same, raw request: curl -H 'Host: app.example.com' http://<load-balancer-ip>/ | 200 — published |
The mapping's own ingress rule is always external, so the label on the mapping is ignored. Labelling the Knative Service is what keeps the default route private, and nothing more.
To keep an app private:
-
Mark the Knative Service cluster-local. The
NextAppspec has no visibility field today, so set the label on the Knative Service the operator created:kubectl -n prod label ksvc acme networking.knative.dev/visibility=cluster-localThe label survived new revisions and
NextAppupdates in testing. Re-apply it if the Knative Service is ever recreated. -
Reach it in-cluster at
http://acme.prod.svc.cluster.local. -
Check from outside with a raw request, not a browser, to prove nothing publishes it:
curl -s -o /dev/null -w '%{http_code}\n' \ -H 'Host: acme.prod.svc.cluster.local' http://<load-balancer-ip>/ # 404 -
For people or other networks, front it with something you control, not a
DomainMapping:-
A tunnel (for example Cloudflare Tunnel) running in the cluster. Point its origin at the internal Kourier service and rewrite the
Hostheader to the app's cluster-local name. Without the header, Knative cannot tell which app the request is for:# cloudflared config (sketch) ingress: - hostname: tools.example.com service: http://kourier-internal.kourier-system.svc.cluster.local:80 originRequest: httpHostHeader: acme.prod.svc.cluster.local - service: http_status:404Only the in-cluster origin was tested: this request from inside the cluster returns
200. The tunnel itself and the public hostname in front of it are unverified, so treat the config above as a starting point:curl -H 'Host: acme.prod.svc.cluster.local' \ http://kourier-internal.kourier-system.svc.cluster.local/ -
A private (internal) load balancer in front of a second Kourier Service, using your cloud's internal-load-balancer annotation. Nothing is exposed to the internet, and your VPN or peering reaches it.
-
Whichever you pick, authenticate at that layer too: private is a network property, not an access control.
Troubleshooting
Start with the object that is not Ready, and read its conditions:
kubectl -n prod get domainmapping app.example.com \
-o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}: {.message}{"\n"}{end}'The DomainMapping is not Ready
| Condition | Cause | Fix |
|---|---|---|
DomainClaimed=False DomainAlreadyClaimed: the domain name is already in use by another DomainMapping | No ClusterDomainClaim for this hostname in this namespace (the message is misleading), or a different namespace holds it. | kubectl get clusterdomainclaim app.example.com -o yaml, then create or correct the claim. Once it exists the mapping recovers on its own, usually within a minute. |
ReferenceResolved=False ResolveFailed: services.serving.knative.dev "…" not found | ref.name is not a Knative Service in the mapping's namespace. | kubectl -n prod get ksvc; the name is the NextApp name. The mapping and the app must share a namespace. |
IngressReady=Unknown ReconcileIngressFailed: failed to fetch secret | tls.secretName names a Secret that does not exist in this namespace. | Wait for the Certificate to be Ready, or fix the name. |
CertificateProvisioned=True TLSNotEnabled | Informational: cluster-wide auto-TLS is off. | Nothing to fix when you supply tls.secretName. |
The certificate is not Ready
cert-manager works through four objects. Walk down them until one explains it:
kubectl -n prod describe certificate app-example-com
kubectl -n prod get certificaterequest,order,challenge
kubectl -n prod describe challenge| Where | Message | Cause and fix |
|---|---|---|
| CertificateRequest | Referenced "Issuer" not found | issuerRef points at an issuer that does not exist, or has the wrong kind (Issuer vs ClusterIssuer). |
| Issuer / ClusterIssuer | READY False | The ACME account did not register. kubectl describe it: usual causes are a bad email, no outbound access to the ACME endpoint, or a missing credentials Secret. |
| Challenge (HTTP-01) | wrong status code '404', expected '200' | Kourier does not serve the standard Ingress cert-manager creates for the solver. Use HTTP-01 on a Kourier-only cluster. If you already do: check the solver Service has an endpoint (kubectl -n prod get endpointslices) and its selector matches the issuer's podTemplate label. |
| Challenge (HTTP-01) | solver log says invalid host | Something rewrote the Host header before the solver, for example a DomainMapping pointing at the solver Service. The solver only answers for the challenge hostname; route the path without a host rewrite, as above. |
| Challenge (DNS-01) | propagation or authentication error | The API token lacks permission on the zone, or the zone is not the one that serves the domain. |
| Certificate | untrusted in the browser although READY True | The issuer is self-signed or the Let's Encrypt staging endpoint. Switch the issuer's server to production and re-issue. |
The hostname does not resolve, or resolves somewhere else
dig +short app.example.com # your record
kubectl -n kourier-system get svc kourier \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}{"\n"}' # the load balancerThe two must match. A cached or not-yet-propagated record is the usual cause: query the zone's own
name servers (dig +short NS example.com, then dig @<ns> app.example.com) to separate "not
published" from "not propagated". Proxying records through a CDN also changes the answer to the CDN's
addresses; that is expected, and it matters for challenges (below).
To separate DNS from routing, bypass DNS and send the hostname straight to the load balancer:
curl -i --resolve app.example.com:443:<load-balancer-ip> https://app.example.com/
curl -i -H 'Host: app.example.com' http://<load-balancer-ip>/A 200 here with a failing public name is DNS. A 404 from Kourier means the load balancer is
reached but no mapping serves that hostname: check the mapping is Ready and the name matches exactly.
The challenge fails behind a proxy or CDN
An ACME challenge must reach the cluster unmodified. These break HTTP-01:
- A CDN or proxy in front of the record that answers on its own, caches, or challenges the request.
- A forced redirect from HTTP to HTTPS, because the challenge URL is requested over plain HTTP on
port 80 and is expected to answer
200directly there. - A WAF or bot rule that blocks the ACME validation servers.
Use DNS-01, which does not depend on the request path, or turn the proxy off for the record while the certificate issues.
Clean up
kubectl -n prod delete domainmapping app.example.com
kubectl delete clusterdomainclaim app.example.com
kubectl -n prod delete certificate app-example-comRemoving the Certificate does not remove its Secret; delete app-example-com-tls as well when you
are done with it.
If you used the HTTP-01 routing rule instead of a mapping:
kubectl -n prod delete ingresses.networking.internal.knative.dev app-example-com
kubectl -n prod delete service acme-solver
kubectl -n prod delete issuer letsencrypt-http01Related
- Security — what is and is not exposed by default.
- Hardening — rate limiting and payload limits in front of the app.
- Scale to zero — why the first request after idle is slower.
- Oracle Cloud (OKE), GKE, EKS — cluster setup.
Private registries
Pulling your app image from OCIR, private GHCR, ECR, or any registry that requires credentials — the manual pull-secret path that works today, and what to expect from doctor.
Observability
Self-hosted metrics, RUM, and tracing for knext — Prometheus, Web Vitals, and OpenTelemetry, with no SaaS lock-in.