Preparing the host cluster for tenant exposure¶
The host cluster must be prepared before tenants can be reached from
outside. A bare cluster + helm install kubespaces gives you the Tier 1
demo (port-forward access, see prerequisites.md); public
tenant endpoints additionally need the pieces on this page, installed once
per host cluster. Every step here is cluster-admin, day-0 platform work —
after it, tenant exposure is fully automated by the operator.
The reference implementation below uses Envoy Gateway, but nothing in
KubeSpaces depends on it: any Gateway API implementation that supports
TLSRoute works (the pre-OSS KubeSpaces production setup ran the exact
same Gateway shape on Istio for years). Contour, Istio, Envoy Gateway are all
known-good TLSRoute implementations; check your implementation's conformance
docs before picking something else.
1. Gateway API implementation (with TLSRoute)¶
TLSRoute is GA (v1, Standard channel) since Gateway API v1.5
(February 2026). The operator creates TLSRoutes using the v1 API
(gateway.networking.k8s.io/v1), so Gateway API >= 1.5 CRDs are
required — a pre-1.5 install only served TLSRoute in the experimental
channel as v1alpha2, which the operator no longer uses. (v1alpha2 is
still served by 1.5.x for backward compatibility but is removed in
Gateway API 1.6.)
Kubernetes >= 1.31 is also required: the TLSRoute
v1CRD ships CEL validation rules (x-kubernetes-validations), and CEL validation for custom resources only became generally available in Kubernetes 1.31. On older clusters the CRD applies but its validation rules are ignored.
Envoy Gateway bundles the CRDs it needs (TLSRoute v1 included), which
makes this a one-liner:
helm install eg oci://docker.io/envoyproxy/gateway-helm \
-n envoy-gateway-system --create-namespace --wait
If you bring another implementation, make sure
kubectl get crd tlsroutes.gateway.networking.k8s.io succeeds afterwards
and that it serves v1
(kubectl get crd tlsroutes.gateway.networking.k8s.io -o jsonpath='{.spec.versions[*].name}'
should list v1); if it does not, install the standard channel CRDs from
gateway-api releases
(v1.5.0 or newer).
2. The tenant API Gateway (TLS passthrough)¶
One shared Gateway carries every tenant's API server traffic. The listener is TLS Passthrough: the gateway routes on SNI and never terminates TLS, so clients verify vCluster's own serving certificate end-to-end and client-cert kubeconfigs keep working. No cert-manager involvement on this path.
That manifest (adjust <domain>) is:
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: envoy
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: kubespaces-api
namespace: kubespaces-system
spec:
gatewayClassName: envoy
listeners:
- name: apipassthrough # referenced by operator.tenantApi.gateway.sectionName
hostname: "*.api.<domain>"
port: 443
protocol: TLS
tls:
mode: Passthrough
allowedRoutes:
namespaces:
from: Same # operator-created TLSRoutes live in this
# namespace; Same (not All) means tenant
# namespaces can never attach routes here
Wait for an address:
3. DNS¶
Point tenant API hostnames at the Gateway's address. Two options:
- Wildcard record (simplest):
*.api.<domain>→ the Gateway's LoadBalancer IP. One record, covers every tenant forever. - external-dns: the operator annotates every TLSRoute with
external-dns.alpha.kubernetes.io/hostname(and honors an optional target override for on-prem appliances viaoperator.tenantApi.externalDnsTarget), so external-dns watchingtlsroutesources creates per-tenant records automatically.
4. Tell KubeSpaces about it¶
# values.yaml
operator:
enabled: true
tenantApi:
domain: <domain> # tenants get <tenant>.api.<domain>
gateway:
name: kubespaces-api
namespace: kubespaces-system
sectionName: apipassthrough
All three of domain, gateway.name and gateway.namespace must be set;
leave them empty and the operator skips exposure entirely (Tier 1 behavior).
From here the operator automates everything per tenant: TLSRoute +
ReferenceGrant, vCluster cert SANs, kubeconfig server URL, and
status.apiServerUrl on the Tenant.
5. Verify¶
kubectl apply -f - <<'EOF'
apiVersion: kubespaces.io/v1alpha1
kind: Tenant
metadata: {name: smoke}
spec: {owner: you@example.com}
EOF
kubectl wait tenant smoke --for=jsonpath='{.status.phase}'=Ready --timeout=5m
kubectl get secret vc-smoke -n kubespaces-tenant-smoke \
-o jsonpath='{.data.config}' | base64 -d > /tmp/smoke.kubeconfig
kubectl --kubeconfig /tmp/smoke.kubeconfig get nodes # over the public endpoint
kubectl delete tenant smoke
Tenant app exposure (the apps Gateway)¶
The second half of the networking story: workloads inside tenants exposed
at https://<name>.<tenant>.apps.<domain>. Additional host prerequisites:
- cert-manager — the operator creates a per-tenant wildcard
Certificate(*.<tenant>.apps.<domain>); a wildcard cert can only be issued via DNS-01, so production needs a DNS-01ClusterIssuer(evaluation can use a self-signed one):
helm install cert-manager oci://quay.io/jetstack/charts/cert-manager \
-n cert-manager --create-namespace --set crds.enabled=true --wait
- The apps Gateway — TLS-terminating, one static base listener; the operator manages one listener per tenant on it:
-
DNS — one record covers everything:
*.apps.<domain>→ the apps Gateway address (DNS wildcards match multiple labels, so it also coversweb.tenant.apps.<domain>). -
Chart values:
operator:
tenantApps:
domain: <domain>
gateway: {name: kubespaces-apps, namespace: kubespaces-system}
clusterIssuer: selfsigned # or your DNS-01 issuer
Per tenant, the operator then: issues the wildcard Certificate, adds listener
t-<tenant> (hostname *.<tenant>.apps.<domain>, TLS terminate,
allowedRoutes restricted to that tenant's namespace), enables vCluster's
native sync.toHost.gatewayApi.httpRoutes, projects the apps Gateway into
the tenant's virtual default namespace, and reports status.appsDomain.
Tenant users expose an app by creating a plain HTTPRoute in their
default namespace, referencing the Gateway they can see right next to it:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: web}
spec:
parentRefs:
- name: kubespaces-apps # projected into default by vCluster
hostnames: ["web.<tenant>.apps.<domain>"]
rules:
- backendRefs: [{name: web, port: 80}]
Routes must live in the virtual default namespace — vCluster projects the
Gateway into exactly one namespace, and its route authorization requires the
route to sit next to a Gateway it can see. On sync, vCluster translates the
parentRef back to the real host Gateway.
Isolation is structural: the synced route lands in the tenant's host
namespace, and the only listener that admits routes from that namespace is
the tenant's own — whose hostname is *.<tenant>.apps.<domain>. A route
claiming another tenant's hostname matches no listener it is allowed to
attach to, so hostname theft is impossible without any admission policy.
TLSRoute sync stays disabled by design: tenant-authored passthrough routes
must never reach the API gateway (which additionally only admits routes from
its own namespace).
GitOps note: the operator writes spec.listeners on this one Gateway.
Configure your GitOps tool to ignore listener drift on it (Flux
spec.ignore, Argo CD ignoreDifferences) — everything else on this page
stays fully declarative. Gateway API caps a Gateway at 64 listeners; beyond
~60 tenants, shard across a second apps Gateway (tracked upstream in #16
follow-ups).
GitOps¶
All of the above is declarative and belongs in your platform GitOps
repository (Flux/Argo) rather than in shell history — the pre-OSS KubeSpaces
ran exactly this layout (gateway-api → implementation → gateways → DNS) as
Flux Kustomizations. A reference layout ships in
examples/host/; reconcile that directory and the host
is ready.
Related¶
- The apps gateway (wildcard TLS termination + cert-manager, for tenant workloads and the portal) is the second half of the networking story — tracked in #16 and #17.
- prerequisites.md — the tier model and why the API path needs no certificates.