Customizing your domain (hostname)
By default the manifests use the placeholder domain workspaces.example.com.
This is not issued by real ACME providers (it is reserved by
RFC 2606), so you must
override it with your own hostname before going to production.
There are two supported ways to set your hostname, depending on how you deploy.
Keep the shared
kustomize/basedomain-agnostic. Consumers are expected to set their own hostname via Helm values or a kustomize patch — the base should never hardcode a specific operator’s domain.
Choosing an ingress controller
Kube Workspaces serves the frontend, API, and proxy from a single host using
path-based routing. The only controller-specific requirement is stripping the
/api prefix before a request reaches the API service, and passing WebSocket
traffic through to the proxy for browser workspaces. Beyond that, most ingress
controllers work as-is.
| Controller | Strip /api via |
Where you configure it | Notes |
|---|---|---|---|
| Traefik (recommended) |
StripPrefix middleware |
ingress.middlewares — the chart renders the Middleware CR |
No hand-written resources; WebSockets proxied natively |
| ingress-nginx |
rewrite-target annotation with regex capture groups |
ingress.annotations + regex paths
|
Common on managed and self-hosted clusters |
| Any other controller | Controller-specific rewrite or annotation | ingress.annotations |
Route the paths below to the right services |
Whichever controller you choose, the routing table is the same:
| Path prefix | Service |
|---|---|
/api, /v1, /auth
|
kube-workspaces-api |
/proxy |
kube-workspaces-proxy |
/ |
kube-workspaces-frontend |
Picking one
-
Prefer Traefik. The chart renders the
StripPrefixmiddleware fromingress.middlewares, so there is nothing extra to deploy, and WebSocket traffic (needed by noVNC and code-server) is proxied without extra flags. -
ingress-nginx needs only annotations — no
MiddlewareCRs — at the cost of regex paths and a carefulrewrite-target. See the example below. -
Anything else: set the same five paths to the right services and enable
WebSocket support if the controller requires it. Where TLS terminates at an
upstream load balancer, leave
ingress.tlsunset.
The
kustomize/baseingress ships withingressClassName: traefikhardcoded. If you use a different controller via Kustomize, override the class in your overlay — see Kustomize.
Helm (recommended)
The Helm chart exposes every hostname-dependent value. The values that matter:
| Value | Default | Purpose |
|---|---|---|
api.externalHost |
"" |
Host header forwarded to proxied workspace backends |
api.allowedOrigins |
https://workspaces.example.com,... |
CORS allow-list for the API |
proxy.allowedOrigins |
https://workspaces.example.com,... |
CORS allow-list for the proxy |
auth.callbackURL |
"" |
Optional override of the OIDC callback URL |
ingress.hosts |
workspaces.local |
Ingress rules (host → paths) |
ingress.tls |
[] |
TLS hosts + secret name |
ingress.className |
"" |
e.g. traefik, nginx
|
ingress.annotations |
{} |
e.g. cert-manager.io/cluster-issuer or traefik middleware |
ingress.middlewares |
[] |
Optional Traefik Middleware CRs (traefik.io/v1alpha1), each {name, spec}, rendered into the release namespace |
The recommended deployment model is single-host: frontend, API, and proxy
are all served from one domain. The frontend defaults to calling the API at
/api on the same origin (the API_BASE is baked into the frontend image at
build time and defaults to /api). A reverse-proxy strips the /api prefix
before forwarding to the API service.
The API sets a host-only kw-session cookie — this means single-host keeps the
cookie on one origin and avoids cross-origin cookie issues.
Traefik ingress controllers need a StripPrefix middleware to strip /api
before the API service handles the request. The Helm chart renders it from
ingress.middlewares — the entry below produces a Middleware named
kube-workspaces-strip-api in the release namespace, matched by the annotation:
api:
externalHost: "workspaces.example.com"
allowedOrigins: "https://workspaces.example.com,http://localhost:3000"
proxy:
allowedOrigins: "https://workspaces.example.com,http://localhost:3000"
ingress:
enabled: true
className: traefik
annotations:
traefik.ingress.kubernetes.io/router.middlewares: kube-workspaces-system-kube-workspaces-strip-api@kubernetescrd
middlewares:
- name: kube-workspaces-strip-api
spec:
stripPrefix:
prefixes:
- /api
hosts:
- host: workspaces.example.com
paths:
- path: /api
pathType: Prefix
backend:
serviceName: kube-workspaces-api
servicePort: 80
- path: /v1
pathType: Prefix
backend:
serviceName: kube-workspaces-api
servicePort: 80
- path: /auth
pathType: Prefix
backend:
serviceName: kube-workspaces-api
servicePort: 80
- path: /proxy
pathType: Prefix
backend:
serviceName: kube-workspaces-proxy
servicePort: 80
- path: /
pathType: Prefix
backend:
serviceName: kube-workspaces-frontend
servicePort: 80
Install with:
helm install kube-workspaces ./helm/kube-workspaces \
--namespace kube-workspaces-system --create-namespace \
-f my-values.yaml
ingress-nginx controllers have no middleware concept — use a
rewrite-target annotation with regex capture groups to strip /api instead:
api:
externalHost: "workspaces.example.com"
allowedOrigins: "https://workspaces.example.com"
proxy:
allowedOrigins: "https://workspaces.example.com"
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: /$2
hosts:
- host: workspaces.example.com
paths:
# $2 is the second capture group, so /api/v1/x -> /v1/x on the API
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend:
serviceName: kube-workspaces-api
servicePort: 80
- path: /v1(/|$)(.*)
pathType: ImplementationSpecific
backend:
serviceName: kube-workspaces-api
servicePort: 80
- path: /auth(/|$)(.*)
pathType: ImplementationSpecific
backend:
serviceName: kube-workspaces-api
servicePort: 80
- path: /proxy(/|$)(.*)
pathType: ImplementationSpecific
backend:
serviceName: kube-workspaces-proxy
servicePort: 80
# /()(.*) keeps the capture-group count consistent so $2 resolves
- path: /()(.*)
pathType: ImplementationSpecific
backend:
serviceName: kube-workspaces-frontend
servicePort: 80
Every path needs a
backendblock — the chart has no default. Omitting it fails withnil pointer evaluating interface {}.serviceName.Where TLS terminates at an upstream load balancer, leave
ingress.tlsunset.
ArgoCD
Argo CD syncs from the git remote (or a chart repository), so it deploys the
last pushed revision rather than your local working tree. Set
destination.namespace to the release namespace — Argo CD installs the chart
there (the examples below use kube-workspaces-system).
Chart from the OCI registry (recommended). The published chart ships the CRDs, so a single source is enough:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kube-workspaces
namespace: argocd
spec:
destination:
name: in-cluster
namespace: kube-workspaces-system
project: default
source:
chart: kube-workspaces
repoURL: ghcr.io/kube-workspaces/charts
targetRevision: 0.5.0
helm:
values: |
api:
externalHost: "workspaces.example.com"
ingress:
enabled: true
className: traefik
# ... hosts as above (single-host with /api route)
images: []
installExampleImages: false
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
CreateNamespace=true creates the release namespace on first sync. Argo CD
creates it but does not track it as a rendered resource, so prune never
removes it. To have the Namespace rendered and tracked by Argo CD instead, set
namespaces.createReleaseNamespace: true and drop the CreateNamespace=true
sync option — see
Namespaces and --create-namespace.
Chart from the git repo (multi-source). Use this when you want to track the chart source from the repository rather than a released chart, and manage the CRDs as a separate source:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kube-workspaces
namespace: argocd
spec:
destination:
name: in-cluster
namespace: kube-workspaces-system
project: default
sources:
- repoURL: https://github.com/kube-workspaces/deploy.git
targetRevision: main
path: helm/kube-workspaces
helm:
values: |
api:
externalHost: "workspaces.example.com"
ingress:
enabled: true
className: traefik
# ... hosts as above (single-host with /api route)
images: []
installExampleImages: false
- repoURL: https://github.com/kube-workspaces/deploy.git
targetRevision: main
path: kustomize/crds
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
Notes:
- Set
installExampleImages: false(and leaveimages: []) if you already manageImageCRs out-of-band (e.g. via a curated image catalog), to stop the chart’s default images from rendering. SetinstallCatalogImages: trueinstead if you want the full vendored image-catalog rather than the curated examples. - ArgoCD renders the chart with
helm templateand applies the output as plain manifests — it does not create a Helm release, sohelm listwon’t show it. - The chart renders the strip-api
Middlewareitself fromingress.middlewares, so it is applied together with the Ingress in one sync. Only if you manage the middleware out-of-band (e.g. a separate ArgoCD Application) should you care about ordering: apply theMiddlewarebefore the Ingress references it — the Ingress still routes correctly even if the middleware is missing briefly.
Two-host setup (alternative)
If you prefer a separate API host (api.workspaces.example.com), you need a
frontend image built with NEXT_PUBLIC_API_URL=https://api.workspaces.example.com
baked in at build time. The default image on ghcr.io is not built with this
value. When using a custom-built image, set frontend.externalApiUrl in the
Helm values and use these ingress rules:
api:
externalHost: "api.workspaces.example.com"
ingress:
hosts:
- host: workspaces.example.com
paths:
- path: /v1
pathType: Prefix
backend:
serviceName: kube-workspaces-api
servicePort: 80
# ... /auth, /proxy, /
- host: api.workspaces.example.com
paths:
- path: /
pathType: Prefix
backend:
serviceName: kube-workspaces-api
servicePort: 80
Kustomize
The kustomize/base is intentionally generic. Override the hostnames with a
json6902 patch in your own overlay:
# kustomization.yaml
resources:
- ../base
patches:
- target:
kind: Ingress
name: kube-workspaces
patch: |-
- op: replace
path: /spec/rules/0/host
value: workspaces.example.com
- op: replace
path: /spec/rules/1/host
value: api.workspaces.example.com
- op: replace
path: /spec/tls/0/hosts
value: [workspaces.example.com, api.workspaces.example.com]
- target:
kind: Deployment
name: kube-workspaces-api
patch: |-
- op: replace
path: /spec/template/spec/containers/0/env/0/value
value: api.workspaces.example.com
- op: replace
path: /spec/template/spec/containers/0/env/1/value
value: https://workspaces.example.com/auth/callback
- op: replace
path: /spec/template/spec/containers/0/env/2/value
value: https://workspaces.example.com,http://localhost:3000
- target:
kind: Deployment
name: kube-workspaces-proxy
patch: |-
- op: replace
path: /spec/template/spec/containers/0/env/0/value
value: https://workspaces.example.com,http://localhost:3000
The same patches can be expressed inline in an ArgoCD Application via
spec.sources[].kustomize.patches.
DNS and TLS
Before applying, make sure your DNS points at your ingress controller and that
cert-manager (or your ACME client) is configured to issue for your domains.
The ingress annotation cert-manager.io/cluster-issuer triggers automatic
certificate issuance through cert-manager’s ingress-shim.
Namespaces and --create-namespace
By default the chart renders a Namespace object for the workspace namespace only. The release namespace is deliberately left to Helm:
helm install kube-workspaces oci://ghcr.io/kube-workspaces/charts/kube-workspaces \
--namespace kube-workspaces-system --create-namespace
Helm creates the release namespace itself in order to store the release secret,
then refuses to adopt it as a chart resource — so rendering it from the chart as
well fails with invalid ownership metadata, or intermittently
namespaces "kube-workspaces-system" already exists.
If a config-management tool needs the Namespace to be part of the release (Argo
CD, for instance, so that it is not pruned), enable it explicitly and do not
pass --create-namespace:
namespaces:
createReleaseNamespace: true
Other switches:
| Value | Default | Effect |
|---|---|---|
namespaces.create |
true |
Master switch; false renders no Namespace objects at all |
namespaces.createReleaseNamespace |
false |
Render .Release.Namespace
|
namespaces.createWorkspaceNamespace |
true |
Render workspaceNamespace
|