knext

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) ◄──── Secret

Prerequisites: 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 record app.example.com → <ip>.
  • The result has a hostname (AWS): create a CNAME app.example.com → <hostname>.

Check it from your machine:

dig +short app.example.com

No 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:

claim.yaml
apiVersion: networking.internal.knative.dev/v1alpha1
kind: ClusterDomainClaim
metadata:
  name: app.example.com
spec:
  namespace: prod

3. 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:

domainmapping.yaml
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
kubectl 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:

PathScopeStatus on the reference cluster
HTTP-01 through a Knative routing ruleOne namespaceVerified: Let's Encrypt staging, then production, served on the hostname
DNS-01One namespaceNot yet verified
Cluster-wide auto-TLSWhole cluster, admin-ownedNot 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.dev is 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 DomainMapping already 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 ClusterDomainClaim from step 2 in place: it stops a DomainMapping in 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:

issuer-http01.yaml
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 on

The routing rule. The second path is the same rule a DomainMapping would generate for the app acme:

route-app-example-com.yaml
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: prod

Apply 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.

clusterissuer.yaml
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-token

A 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

certificate.yaml
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: Issuer

Add tls.secretName to the mapping you created in step 3:

domainmapping.yaml
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-tls
kubectl 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 -dates

Two behaviours to plan for:

  • Plain HTTP keeps working. Adding a certificate does not redirect http:// to https://. Redirect at your edge (CDN, WAF) if you need it.
  • Without the Secret the mapping does not go Ready. It reports ReconcileIngressFailed until the Secret exists, so apply the Certificate first and wait for it to be READY 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:

app/login/route.ts
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:

knext.config.ts
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.com

The 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:

knext.config.ts
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 Location whose origin is 0.0.0.0 or [::] is rewritten. A redirect to any other host, or a relative redirect such as redirect('/dashboard') from next/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 its Host, 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.com redirects to https://, and http://localhost:3000 redirects to http://. An entry with no scheme means https. No request header, X-Forwarded-Proto included, changes the scheme, so a client can't downgrade your redirects to http.

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 requestedResult
The app's default-domain URL from outside404 (private, as intended)
DomainMapping with no label, public DNS name200 — published
DomainMapping labelled cluster-local, public DNS name200 — 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:

  1. Mark the Knative Service cluster-local. The NextApp spec 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-local

    The label survived new revisions and NextApp updates in testing. Re-apply it if the Knative Service is ever recreated.

  2. Reach it in-cluster at http://acme.prod.svc.cluster.local.

  3. 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
  4. 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 Host header 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:404

      Only 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

ConditionCauseFix
DomainClaimed=False DomainAlreadyClaimed: the domain name is already in use by another DomainMappingNo 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 foundref.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 secrettls.secretName names a Secret that does not exist in this namespace.Wait for the Certificate to be Ready, or fix the name.
CertificateProvisioned=True TLSNotEnabledInformational: 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
WhereMessageCause and fix
CertificateRequestReferenced "Issuer" not foundissuerRef points at an issuer that does not exist, or has the wrong kind (Issuer vs ClusterIssuer).
Issuer / ClusterIssuerREADY FalseThe 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 hostSomething 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 errorThe API token lacks permission on the zone, or the zone is not the one that serves the domain.
Certificateuntrusted in the browser although READY TrueThe 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 balancer

The 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 200 directly 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-com

Removing 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-http01

On this page