This document describes how workspace application proxying works in kube-workspaces. The proxy is a standalone service (proxy/) deployed independently from the API, with session-based authentication and namespace access control.
All proxy traffic flows through the main host via nginx ingress:
https://workspaces.example.com/proxy/{namespace}/{name}/...
The kw-session cookie (same domain) provides authentication automatically.
When a user opens a workspace application (e.g. code-server, filebrowser, a VNC desktop), the browser loads the app through the reverse proxy. The proxy validates the user's session and checks they have access to the workspace's namespace before forwarding the request.
https://workspaces.example.com/proxy/{namespace}/{name}/{path}
Example:
https://workspaces.example.com/proxy/user1/my-codeserver/
The request flows through nginx ingress (/proxy path) to the proxy service. The proxy validates the kw-session cookie, checks namespace access, then forwards to the workspace pod.
┌─────────────────────────────────────────────────────┐
│ Browser │
│ (kw-session cookie sent automatically) │
└───────────────────────────┬─────────────────────────┘
│
│ https://workspaces.example.com/proxy/{ns}/{name}/...
▼
┌─────────────────────────────────────────────────────┐
│ Nginx Ingress │
│ (/proxy → kube-workspaces-proxy service) │
│ WebSocket timeout: 3600s │
└───────────────────────────┬─────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ kube-workspaces-proxy (ClusterIP Service :80) │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ proxy binary (Go, port 8080) │ │
│ │ │ │
│ │ 1. Auth middleware: │ │
│ │ - Validates kw-session cookie (HMAC-256) │ │
│ │ - Checks user namespace access │ │
│ │ - Admins bypass checks │ │
│ │ - Auth disabled → pass through │ │
│ │ │ │
│ │ 2. Proxy handler: │ │
│ │ - /proxy/{ns}/{name}/... → pod │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────┘
│
│ http://{name}.{ns}.svc.cluster.local:80/{rest}
│ (or :audioPort for /audio/ requests)
▼
┌─────────────────────────────────────────────────────┐
│ Workspace Pod (ClusterIP Service) │
│ (code-server, filebrowser, VNC desktop, etc.) │
└─────────────────────────────────────────────────────┘
The proxy enforces the same auth model as the API:
- User navigates to
/proxy/{namespace}/{name}/... - Nginx forwards to proxy service
- Proxy auth middleware:
- Reads
kw-sessioncookie (orAuthorization: Bearerheader) - If no token → 401 Unauthorized
- Validates HMAC-SHA256 signature using signing key from K8s Secret
- If invalid/expired → 401 Unauthorized
- Checks if email is in
adminEmails→ grants admin role override - If admin → pass through (skip namespace check)
- Looks up User CR by email (not cached — instant revocation)
- If user disabled → 403 Forbidden
- Checks
UserHasNamespaceAccess(user, namespace)→ personal NS +spec.namespaceAccess[] - If no access → 403 Forbidden
- Otherwise → forward to proxy handler
- Reads
| Condition | Behavior |
|---|---|
AuthConfig.spec.enabled = false |
All requests pass through (no auth) |
| AuthConfig CR not found | Auth disabled (fail-open, backward compatible) |
Admin role (via CR or adminEmails) |
Skip namespace access check |
Health endpoints (/healthz, /readyz) |
Always pass through |
Root endpoint (/) |
Always pass through |
| Resource | Caching | Purpose |
|---|---|---|
| AuthConfig + signing key | 30-second TTL | Matches API behavior; avoids per-request Secret reads |
| User CRs | Not cached | Looked up on every request for instant revocation |
| Image CRs | 30-second TTL | Reduces API calls when resolving proxyConfig |
| Workspace image | 10-second TTL | Short TTL since workspace image rarely changes |
The proxy's K8s client is configured with QPS: 50 and Burst: 100 to prevent client-side throttling under load. Without this, the default client rate limits (5 QPS) caused 3-4 second delays on proxied requests.
This ensures user disabling and namespace access revocation take effect immediately, while reducing unnecessary API server load for static configuration.
The proxy ServiceAccount (kube-workspaces-proxy) has:
ClusterRole:
rules:
- apiGroups: ["kubeworkspaces.io"]
resources: ["images"]
verbs: ["list"]
- apiGroups: ["kubeworkspaces.io"]
resources: ["workspaces"]
verbs: ["get"]
- apiGroups: ["kubeworkspaces.io"]
resources: ["authconfigs"]
verbs: ["get"]
- apiGroups: ["kubeworkspaces.io"]
resources: ["users"]
verbs: ["get", "list"]Role (namespaced to kube-workspaces-system):
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]The namespaced Role limits Secret access to only the deployment namespace (where the signing key Secret lives).
Source: proxy/cmd/proxy/main.go, proxy/internal/proxy/proxy.go, proxy/internal/auth/
The proxy is a standalone Go binary with minimal dependencies:
- stdlib
net/http+net/http/httputilfor reverse proxying k8s.io/client-godynamic client for reading CRs and Secrets- Custom HMAC-SHA256 token validation (same format as the API)
- No Goa framework, no database
Client request arrives
↓
CORS middleware (origin check)
↓
Auth middleware:
/healthz, /readyz, /, /sw.js → skip auth
Auth disabled → skip auth
Validate token → 401 if invalid
Check namespace access → 403 if denied
↓
Root handler routing:
/healthz, /readyz → health check response
/sw.js → no-op ServiceWorker
/proxy/{ns}/{name}/... → proxy handler
Referer-based recovery → rewrite + proxy handler
/ → service info JSON
Other: 404
↓
Proxy handler:
Parse namespace, name, rest from URL
Look up Workspace CR → extract container image
Look up Image CRs → find proxyConfig
Build target: http://{name}.{namespace}.svc.cluster.local:80/{rest}
Forward via httputil.ReverseProxy
↓
Response modifications:
Location header rewriting
HTML rewriting (code-server, base tag)
↓
Response returned to client
The proxy constructs the backend URL using Kubernetes DNS:
http://{workspace-name}.{namespace}.svc.cluster.local:80/{rest}
- Port 80 is used by default (workspace Services map port 80 to the container's
defaultPort) - If
audioPortis configured and the request path matches/audio/, the proxy routes to that port instead (e.g., port 6902) - If
tlsInsecureis set, scheme becomeshttpswith certificate verification disabled
WebSocket connections are handled transparently by httputil.ReverseProxy. The proxy preserves Connection and Upgrade headers when an upgrade is requested. The FlushInterval: -1 setting enables streaming for long-lived connections.
The nginx ingress is configured with extended timeouts for WebSocket:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"The Director function applies these transformations:
| Header | Behavior |
|---|---|
Host |
Set to original request Host |
X-Forwarded-Host |
Set to original request Host |
X-Forwarded-Proto |
Set to https/http based on upstream header |
Connection |
Removed unless WebSocket upgrade |
Accept-Encoding |
Removed (forces decompression for response rewriting) |
| Custom headers | Injected from proxyConfig.customRequestHeaders |
All Location headers in responses are rewritten to stay under the proxy prefix:
- Absolute URL → proxy prefix + path (e.g.
http://pod.ns.svc/login→/proxy/{ns}/{name}/login) - Absolute path → proxy prefix + path (e.g.
/login→/proxy/{ns}/{name}/login) - Relative path → resolved relative to current proxy path
For text/html responses (when a proxyConfig exists), the proxy performs:
-
<base>tag injection (wheninjectBaseTag: true): Inserts<base href="/proxy/{ns}/{name}/">after<head>, causing the browser to resolve all relative and absolute URLs through the proxy path. -
code-server
remoteAuthorityrewrite: Replaces"remoteAuthority":"remote:443"with the actual browser hostname so VS Code's WebSocket connects through the proxy. -
code-server
serverBasePathrewrite: Replaces"serverBasePath":"."with the proxy prefix path. -
code-server
rootEndpointrewrite: Replaces"rootEndpoint":"."with the proxy prefix path.
The proxy handles gzip-encoded responses by decompressing before rewriting and re-compressing after.
The proxy includes multiple layers of recovery for requests that escape the path prefix:
If a request arrives at a path that doesn't match /proxy/{ns}/{name}/..., the proxy checks the Referer header:
referer := r.Header.Get("Referer")
if referer != "" {
proxyPrefix := proxy.ExtractProxyPrefix(referer, pathPrefix)
if proxyPrefix != "" {
r.URL.Path = proxyPrefix + r.URL.Path
// forward to proxy handler
}
}This catches requests from workspace apps that use absolute paths like /js/app.js.
For requests without a Referer header (e.g. WebSocket upgrades from KasmVNC), the proxy sets a kw-proxy-prefix cookie on HTML responses:
Set-Cookie: kw-proxy-prefix=/proxy/{ns}/{name}; Path=/; SameSite=Lax
When the frontend production server receives an escaped request (e.g. /websockify) without a Referer, it reads this cookie to reconstruct the correct proxy path and forwards the request to the proxy service.
Flow:
- Browser loads workspace at
/proxy/ns/name/→ proxy setskw-proxy-prefix=/proxy/ns/name - KasmVNC client connects to
/websockify(root-relative, no Referer) - Request hits frontend server (catch-all ingress)
- Frontend reads
kw-proxy-prefixcookie → rewrites to/proxy/ns/name/websockify - Frontend forwards to proxy service → proxy strips prefix → forwards to pod
Path blocklist: The frontend does NOT apply cookie-based recovery to known frontend routes:
/workspaces, /admin, /auth, /api, /login, /volumes, /profile, /_next, /favicon, /manifest, /sw.js, /images, /icons
The proxy serves a no-op ServiceWorker at /sw.js:
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));This is also served from frontend/public/sw.js for the case where the request hits the frontend instead.
The proxy validates namespace and name segments against Kubernetes naming rules before attempting lookups:
- Must start/end with lowercase alphanumeric
- Middle characters: lowercase alphanumeric or hyphens
- Max 253 characters
This rejects scanner probes like .git/HEAD, .ssh/id_rsa, wp-admin, etc. without hitting the Kubernetes API.
If the proxy cannot connect to the workspace pod, it returns:
{"error": "Failed to connect to workspace: <reason>"}with HTTP status 502 (Bad Gateway).
| Endpoint | Purpose |
|---|---|
/healthz |
Liveness probe |
/readyz |
Readiness probe |
Both return {"status":"ok"} with HTTP 200. Health checks bypass auth.
The proxy includes logging middleware that records every non-health request:
2024/01/15 10:30:42 GET /proxy/user1/my-code/ → 200 (45ms)
2024/01/15 10:30:43 GET /proxy/user1/my-code/static/app.js → 200 (12ms)
Logged fields: method, path, status code, duration. Health check endpoints (/healthz, /readyz) are excluded to reduce noise.
The proxy handles SIGINT and SIGTERM with a 30-second graceful shutdown window, allowing in-flight requests (including WebSocket connections) to complete.
Two Ingress resources handle routing (deploy/kustomize/base/ingress.yaml):
Direct path-based routing without rewriting:
| Path | Backend | Notes |
|---|---|---|
/v1 |
API | REST API endpoints |
/auth |
API | OIDC login/callback |
/openapi |
API | OpenAPI spec |
/proxy |
Proxy | Workspace reverse proxy (auth-enabled) |
/ (catch-all) |
Frontend | Next.js app |
Annotations:
proxy-read-timeout: 3600— WebSocket supportproxy-send-timeout: 3600— WebSocket supportcert-manager.io/cluster-issuer: letsencrypt-production
Regex-based path rewriting:
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/use-regex: "true"
path: /api(/|$)(.*)This means:
/api/v1/workspaces→ API receives/v1/workspaces/api/admin/users→ API receives/admin/users
| Browser URL | Routed to | Service receives |
|---|---|---|
/api/v1/workspaces |
API (rewrite) | /v1/workspaces |
/proxy/ns/name/path |
Proxy (direct) | /proxy/ns/name/path |
/v1/workspaces |
API (direct) | /v1/workspaces |
/workspaces |
Frontend | N/A |
/admin |
Frontend | N/A |
A second host rule routes api.workspaces.example.com directly to the API with no path rewriting.
Purpose: Catch requests that "escape" the proxy prefix.
Workspace apps served under /proxy/ns/name/... may use absolute paths like /static/app.js. The browser sends these to the frontend (catch-all ingress). The proxy file detects this via the Referer header and redirects back to the proxy.
How it works:
- A user views a workspace app at
/proxy/ns/name/ - The app's JavaScript requests
/static/app.js(absolute path) - The browser sends this to the frontend (catch-all ingress rule)
- Proxy file inspects the
Refererheader - Extracts the proxy prefix:
/api/proxy/ns/name - Responds with 308 Permanent Redirect to
/api/proxy/ns/name/static/app.js - Browser follows redirect → nginx routes to proxy service
Exclusions (not redirected):
/_next/*— Next.js internals- Known kube-workspaces paths:
/api/v1/,/api/admin/,/api/auth/,/workspaces,/volumes,/admin, etc.
frontend/src/lib/api.ts exports:
// All proxy traffic goes through the main host
export function getProxyUrl(namespace: string, name: string, path: string): string {
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
return `/proxy/${namespace}/${name}/${cleanPath}`;
}Example: getProxyUrl("user1", "my-code", "/") → /proxy/user1/my-code/
The kw-session cookie is sent automatically because the proxy is on the same domain.
The custom dev server replicates production routing for local development:
Proxied paths: /api/*, /auth/*, /proxy/* → API (default http://localhost:8090)
Path rewriting: /api/... is stripped to /... before forwarding (same as nginx rewrite-target: /$2)
In production, the frontend uses a subprocess architecture to handle escaped proxy requests:
Architecture:
server-prod.mjslistens on port 3000 (the container's exposed port)- It spawns
node server.js(Next.js standalone output) on an internal port (3001) - All requests pass through
server-prod.mjswhich acts as a transparent proxy
Escaped request recovery:
- Checks
Refererheader for proxy prefix extraction - Falls back to
kw-proxy-prefixcookie when no Referer is present - Applies path blocklist (
FRONTEND_PATH_PREFIXES) to avoid hijacking app routes - Forwards recovered requests to the proxy service (
PROXY_URLenv var)
Why subprocess? The Next.js standalone server.js relies on webpack internals that aren't available when importing it directly. The subprocess approach avoids this dependency issue while still allowing the proxy logic wrapper.
Handles /sw.js: Serves a no-op ServiceWorker directly (prevents noVNC registration errors).
The proxyConfig field on Image CRs controls per-image proxy behavior. These settings are defined in the ImageProxyConfig struct (controller/api/v1alpha1/image_types.go) and read by the proxy service at request time via the K8s API.
proxyConfig:
needsNoopSW: trueType: bool (default: false)
Effect: Enables serving a no-op ServiceWorker script at /sw.js. Many web applications (noVNC, code-server, KasmVNC) attempt to register a ServiceWorker at the root scope. The no-op SW satisfies the browser's registration requirement without interfering with the application.
When to use: Enable for any app that registers a ServiceWorker.
proxyConfig:
rewriteHostAbsolutePaths: trueType: bool (default: false)
Effect: Enables Referer-based request rewriting at the proxy level. When the proxy receives a request that doesn't match a valid workspace path, it checks the Referer header and internally rewrites the request.
When to use: Enable for apps that use absolute paths for assets or API calls. Safe to enable by default.
proxyConfig:
injectBaseTag: trueType: bool (default: false)
Effect: Injects a <base href="/proxy/{namespace}/{name}/"> tag into all HTML responses. This tells the browser to resolve all relative URLs relative to the workspace's proxy prefix.
When to use: Use for apps that load resources with relative paths but don't support a configurable base URL. Prefer the app's native base URL configuration (like FB_BASEURL for filebrowser) when available.
Caution: The <base> tag can break apps that rely on window.location.pathname for routing.
proxyConfig:
tlsInsecure: trueType: bool (default: false)
Effect: Changes the proxy's connection to the workspace pod from HTTP to HTTPS with TLS certificate verification disabled (InsecureSkipVerify: true).
When to use: Required for workspace images that only serve HTTPS with self-signed certificates (e.g. Kasm Workspaces, KasmVNC desktops).
proxyConfig:
websocketPaths:
- /websockifyType: []string (default: nil)
Effect: Currently informational only. Documents which paths use WebSocket connections. The Go reverse proxy supports WebSocket upgrade transparently on ALL paths.
proxyConfig:
customRequestHeaders:
X-Custom-Header: "value"Type: map[string]string (default: nil)
Effect: Injects additional HTTP headers into every proxied request sent to the workspace pod.
proxyConfig:
preservePathPrefix: trueType: bool (default: false)
Effect: When enabled, the proxy forwards the full request path including the proxy prefix (/proxy/{namespace}/{name}/...) to the workspace pod, instead of stripping it. This is required for applications that are configured with a base URL matching the proxy prefix.
How it works:
Without preservePathPrefix (default):
Browser → /proxy/ws/my-fb/api/login
Proxy strips prefix → forwards /api/login to pod
App handles /api/login directly
With preservePathPrefix: true:
Browser → /proxy/ws/my-fb/api/login
Proxy preserves prefix → forwards /proxy/ws/my-fb/api/login to pod
App strips its configured baseURL (/proxy/ws/my-fb) → handles /api/login
When to use: Required for apps that:
- Support a base URL/path prefix configuration (CLI flag, env var, config file)
- Use that configuration to both generate URLs AND route incoming requests via path prefix stripping
- Would otherwise have root-relative paths that escape the proxy
Location header handling: When preservePathPrefix is true, the proxy assumes the app generates Location headers with the full prefix already included, so it doesn't double-prefix them.
Example with filebrowser:
spec:
defaultEnv:
- name: FB_BASE_URL
value: /proxy/{{namespace}}/{{name}}
proxyConfig:
preservePathPrefix: true
needsNoopSW: trueproxyConfig:
audioPort: 6902Type: int32 (default: 0 — disabled)
Effect: Routes requests matching /audio/ to a different backend Service port instead of the default port 80. This enables audio WebSocket streams to be served from a separate process within the workspace pod (e.g., PulseAudio via websockify on port 6902).
How it works:
Without audioPort (default):
Browser → /proxy/ns/name/audio/ → proxy → workspace-svc:80/audio/
With audioPort: 6902:
Browser → /proxy/ns/name/audio/ → proxy → workspace-svc:6902/audio/
Prerequisites:
- The workspace container must expose the audio port (use
additionalPortson the Image CR) - The controller's
generateServiceexposes all container ports — the first port maps to Service port 80, additional ports map to their own port number - The audio process in the container must listen on the specified port
When to use: For desktop workspace images that have a separate audio streaming service (PulseAudio → GStreamer → websockify) running on a different port from the main VNC/web interface.
Example with debian-desktop:
spec:
defaultPort: 6901
additionalPorts:
- name: audio
port: 6902
proxyConfig:
needsNoopSW: true
rewriteHostAbsolutePaths: true
audioPort: 6902The audio plugin in the browser requests /audio/ which, through escaped request recovery (cookie-based), becomes /proxy/{ns}/{name}/audio/. The proxy detects the /audio/ path and routes it to Service port 6902 instead of port 80.
Some applications support a base URL configuration that's more robust than proxy-side rewriting. This is configured via the Image CR's defaultEnv field with template placeholders:
spec:
defaultEnv:
- name: FB_BASE_URL
value: /proxy/{{namespace}}/{{name}}Supported placeholders:
{{namespace}}— workspace's Kubernetes namespace{{name}}— workspace name
These are substituted at workspace creation time. Existing workspaces must be recreated to pick up changes.
Why this is preferred over injectBaseTag: When an app natively understands its base URL, it generates all internal URLs correctly (API calls, asset paths, redirects). This is more reliable than proxy-side HTML rewriting.
IDE workspace images like code-server and OpenVSCode Server have a built-in port forwarding feature. When a user runs a process that listens on a port (e.g. a Python web server on port 8000), the IDE detects it and offers to "forward" it via the Ports panel.
How it works:
code-server includes a built-in reverse proxy at /proxy/<port>/ that forwards requests to localhost:<port> within the pod. No changes to the external Go proxy are required — the request chains through naturally:
Browser → /proxy/{ns}/{name}/proxy/8000/index.html
↓ Go proxy strips /proxy/{ns}/{name}
→ Pod Service:80 receives /proxy/8000/index.html
↓ code-server's built-in /proxy/<port>/ route
→ localhost:8000/index.html (within the pod)
Configuration:
The VSCODE_PROXY_URI environment variable controls the URLs generated in the Ports panel. It supports a {{port}} placeholder that code-server substitutes at runtime:
spec:
defaultEnv:
- name: VSCODE_PROXY_URI
value: /proxy/{{namespace}}/{{name}}/proxy/{{port}}/Placeholder resolution:
{{namespace}}and{{name}}— resolved by the kube-workspaces API at workspace creation time{{port}}— resolved by code-server at runtime when generating the link in the Ports panel
For example, a workspace my-code in namespace chris with a process on port 8000 produces:
/proxy/user1/my-code/proxy/8000/
code-server also supports /absproxy/<port>/ which passes the full path (including the prefix) to the target process. Use proxy (not absproxy) for most applications.
Important: Since VSCODE_PROXY_URI is baked into the workspace env at creation time, existing workspaces must be deleted and recreated to pick up changes to this value.
Filebrowser (FB_BASE_URL):
spec:
defaultEnv:
- name: FB_BASE_URL
value: /proxy/{{namespace}}/{{name}}
proxyConfig:
preservePathPrefix: true
needsNoopSW: truelinuxserver/webtop images (SUBFOLDER):
spec:
defaultEnv:
- name: SUBFOLDER
value: /proxy/{{namespace}}/{{name}}/
proxyConfig:
preservePathPrefix: trueThe trailing / on SUBFOLDER is required by linuxserver images. The SUBFOLDER env tells KasmVNC/Selkies to serve all assets and WebSocket connections from the subfolder path, eliminating escaped requests entirely.
When workspace images run as a non-root user (e.g. filebrowser runs as UID 1000), mounted PVCs may be inaccessible because they're owned by root. The Image CR provides two fields to fix this:
spec:
defaultUID: 1000Type: *int64 (optional)
Effect: Sets the pod's securityContext at workspace creation time:
securityContext:
runAsUser: 1000
fsGroup: 1000The fsGroup ensures Kubernetes changes the group ownership of all mounted volumes to the specified GID.
spec:
defaultInitContainers:
- name: volume-permissions
image: busybox:1.37
command: ["sh", "-c", "chown -R {{uid}}:{{uid}} /srv"]Type: []corev1.Container (optional)
Effect: Injects init containers into the workspace pod at creation time. They:
- Run as root (
securityContext.runAsUser: 0) — allowschownoperations - Inherit the main container's volume mounts automatically if no explicit
volumeMountsare specified - Support
{{uid}},{{namespace}},{{name}}placeholders incommand,args, andenvvalues
When to use: For images that need explicit ownership changes on mounted volumes before the main container starts.
Note: This field is intentionally not exposed via the REST API due to the complexity of the corev1.Container schema. Manage via kubectl apply or the admin YAML editor.
apiVersion: kubeworkspaces.io/v1alpha1
kind: Image
metadata:
name: filebrowser
spec:
image: filebrowser/filebrowser:latest
defaultPort: 80
defaultUID: 1000
defaultEnv:
- name: FB_BASE_URL
value: /proxy/{{namespace}}/{{name}}
defaultInitContainers:
- name: volume-permissions
image: busybox:1.37
command: ["sh", "-c", "chown -R {{uid}}:{{uid}} /srv"]
proxyConfig:
preservePathPrefix: true
needsNoopSW: trueWhen a workspace is created with this image:
- Pod
securityContextis set torunAsUser: 1000, fsGroup: 1000 - Init container runs
chown -R 1000:1000 /srv(with all workspace volumes mounted) - Main container starts as UID 1000 with write access to the PVC
The Image CR additionalPorts field specifies extra container ports beyond the primary defaultPort. These are injected into the workspace pod's container spec at creation time, and the controller's generateService exposes them on the Kubernetes Service.
apiVersion: kubeworkspaces.io/v1alpha1
kind: Image
metadata:
name: debian-desktop
spec:
defaultPort: 6901
additionalPorts:
- name: audio
port: 6902Service port mapping:
- First container port (from
defaultPort) → Service port 80 (named "http") — backward compatible - Additional ports → Service port matching their own port number (e.g., 6902 → Service port 6902, named "audio")
This means workspace Services can expose multiple backend processes. The proxy routes to port 80 by default, but specific path patterns (like /audio/) can be routed to other Service ports via audioPort in proxyConfig.
additionalPorts:
- name: <string> # Required. Used as the Service port name.
port: <int32> # Required. Container port number.
protocol: <string> # Optional. Defaults to "TCP".apiVersion: kubeworkspaces.io/v1alpha1
kind: Image
metadata:
name: debian-desktop
spec:
image: flaccid/debian-desktop:latest
defaultPort: 6901
defaultPath: /vnc.html?resize=remote
additionalPorts:
- name: audio
port: 6902
proxyConfig:
needsNoopSW: true
rewriteHostAbsolutePaths: true
audioPort: 6902Result when a workspace is created:
Container ports:
ports:
- containerPort: 6901
name: workspace-port
protocol: TCP
- containerPort: 6902
name: audio
protocol: TCPService:
spec:
ports:
- name: http
port: 80
targetPort: 6901 # First port → Service port 80
- name: audio
port: 6902
targetPort: 6902 # Additional port → own port numberProxy routing:
/proxy/ns/name/vnc.html→ Service port 80 → container port 6901 (VNC)/proxy/ns/name/audio/→ Service port 6902 → container port 6902 (PulseAudio WebSocket)
additionalPortsare baked into the workspace at creation time only. Existing workspaces must be deleted and recreated to pick up changes.- The controller's
generateServicedetects all container ports and generates matching Service port entries. If a workspace was created before this feature existed, its Service will only have port 80. - Port names default to
port-{number}if the container port has no name set.
1. User clicks "Open" on workspace "my-code" in namespace "user1"
2. Frontend calls getProxyUrl("user1", "my-code", "/")
3. Generates: /proxy/user1/my-code/
4. Browser navigates to https://workspaces.example.com/proxy/user1/my-code/
(kw-session cookie sent automatically — same domain)
5. Nginx ingress: /proxy prefix → kube-workspaces-proxy service
(proxy-read-timeout: 3600 for WebSocket support)
6. Proxy auth middleware:
- Reads kw-session cookie
- Validates HMAC-SHA256 signature
- Token valid, user email: user@example.com
- Checks adminEmails → not admin, check namespace access
- Looks up User CR → personalNamespace: "user1"
- UserHasNamespaceAccess(user, "user1") → true (personal NS)
- Pass through ✓
7. Proxy handler: path is /proxy/user1/my-code/ → strips prefix
8. Parses: namespace=user1, name=my-code, rest=/
9. GetWorkspaceImage("user1", "my-code") → "codercom/code-server:latest"
10. GetImageProxyConfig("codercom/code-server:latest") → {needsNoopSW, rewriteHostAbsolutePaths}
11. Target: http://my-code.chris.svc.cluster.local:80/
12. Director: sets Host, X-Forwarded-Host, X-Forwarded-Proto
13. Request forwarded to pod
14. Pod returns HTML
15. ModifyResponse: rewrites remoteAuthority, serverBasePath, rootEndpoint
16. Location headers rewritten to stay under /proxy/user1/my-code/
17. Response returned to browser
18. code-server loads, subsequent requests go through same auth flow
19. WebSocket connects via nginx (timeout 3600s)
1. Attacker navigates to https://workspaces.example.com/proxy/user1/my-code/
2. No kw-session cookie present
3. Proxy auth middleware → 401 {"error":"Unauthorized","message":"authentication required"}
1. User "bob" navigates to /proxy/user1/my-code/ (not bob's namespace)
2. kw-session cookie valid for bob@example.com
3. Proxy looks up User CR for bob → personalNamespace: "bob", namespaceAccess: []
4. UserHasNamespaceAccess(bob, "user1") → false
5. 403 {"error":"Forbidden","message":"no access to namespace user1"}
| Variable | Default | Purpose |
|---|---|---|
HTTP_PORT |
8080 |
Listen port |
ALLOWED_ORIGINS |
(empty) | Comma-separated origins for CORS |
PATH_PREFIX |
/proxy |
Path prefix for proxy routes |
| Variable | Default | Purpose |
|---|---|---|
NEXT_PUBLIC_API_URL |
(empty) → /api |
Base URL for API calls (client-side) |
API_URL |
http://localhost:8090 |
Backend API target for server-side calls and dev proxy |
PROXY_URL |
(same as API_URL) | Backend proxy target for escaped request forwarding |
The proxy is deployed via deploy/kustomize/base/:
proxy-rbac.yaml— ServiceAccount, ClusterRole, ClusterRoleBinding, Role, RoleBindingproxy-deployment.yaml— Deployment + ClusterIP Service
kubectl apply --server-side -k deploy/kustomize/base/helm install kube-workspaces deploy/helm/kube-workspaces/ \
--set proxy.pathPrefix=/proxyThe proxy Service is type ClusterIP (accessed via nginx ingress only):
spec:
type: ClusterIP
ports:
- port: 80
targetPort: http (8080)# Run proxy (port 8091, uses kubeconfig)
make run-proxy
# Or directly:
cd proxy && go run ./cmd/proxy/ --port=8091The proxy reads ~/.kube/config when not running in-cluster. Set PATH_PREFIX=/proxy (default).
For local dev with auth, you need:
- An
AuthConfigCR withspec.enabled: truedeployed to your cluster - A signing key Secret in
kube-workspaces-systemnamespace - A valid
kw-sessioncookie (login via the API first)
When auth is disabled (default), the proxy passes all requests through without checks.
| Aspect | Local Dev | Production |
|---|---|---|
| Access URL | http://localhost:8091/proxy/{ns}/{name}/... |
https://workspaces.example.com/proxy/{ns}/{name}/... |
| TLS | None | cert-manager + nginx |
| Auth | Usually disabled (no AuthConfig) | Enabled (kw-session cookie) |
| WebSocket | Direct (no proxy) | Via nginx (3600s timeout) |
| Run command | make run-proxy |
Deployment |
- Ensure you're logged in (check for
kw-sessioncookie in browser DevTools) - The cookie may have expired — re-login via
/auth/login - If using
Authorization: Bearer, verify the token is valid
- Check the User CR:
kubectl get user <slugified-email> -o yaml - Verify
spec.namespaceAccessincludes the target namespace, orstatus.personalNamespacematches - Admin users bypass this check — add the email to
AuthConfig.spec.adminEmails
- Check if the app supports a base URL config (env var, flag). Use
defaultEnvwith placeholders. - If not, enable
injectBaseTag: truein the Image CR's proxyConfig. - Verify
rewriteHostAbsolutePaths: trueis set for Referer-based recovery. - The frontend proxy file also catches escaped requests via Referer header.
- Verify nginx ingress has timeout annotations (should be set in
ingress.yaml):nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
- Verify
X-Forwarded-Hostis being passed correctly.
Enable needsNoopSW: true in the Image CR's proxyConfig. The proxy serves a no-op SW at /sw.js. The frontend also serves one from public/sw.js.
This happens when the app uses absolute paths like /api/login that conflict with kube-workspaces routes.
Solutions (in order of preference):
- Configure the app's base URL (e.g.
FB_BASEURL=/proxy/{{namespace}}/{{name}}) - Enable
rewriteHostAbsolutePaths— Referer-based rewriting catches most cases - Enable
injectBaseTag— as a last resort
Important: If an application hardcodes root-relative paths (e.g.
/api/login,/static/app.js) and provides no configuration option to change them (base URL setting, CLI flag, or environment variable), that application is incompatible with the proxy. See the Application Compatibility section below.
Enable tlsInsecure: true in proxyConfig for images that serve HTTPS with self-signed certs.
defaultEnv, defaultArgs, defaultUID, defaultInitContainers, and additionalPorts values are applied at workspace creation time only. Existing workspaces must be deleted and recreated to pick up changes to Image CR defaults.
- Verify the workspace pod's Service exists:
kubectl get svc -n {namespace} {name} - Verify DNS resolution:
kubectl exec -n kube-workspaces-system deploy/kube-workspaces-proxy -- nslookup {name}.{namespace}.svc.cluster.local - Check RBAC:
kubectl auth can-i get workspaces.kubeworkspaces.io --as=system:serviceaccount:kube-workspaces-system:kube-workspaces-proxy
- Verify AuthConfig CR exists:
kubectl get authconfig default - Verify
spec.enabled: true - Check proxy logs for auth-related errors
- Verify RBAC: proxy needs
getonauthconfigsandgetonsecrets
Rule: Proxied workspace applications must not make root-relative requests that escape the proxy path.
The proxy serves applications under a sub-path (/proxy/{namespace}/{name}/...). For an application to work correctly, it must generate URLs relative to its base path, not absolute paths from the domain root.
An application is compatible if it meets at least one of these criteria:
- Uses relative URLs — all asset/API references are relative (e.g.
./api/login,assets/app.js) - Supports base URL configuration — has a CLI flag, environment variable, or config option to set a path prefix (e.g.
--baseurl,BASE_PATHenv var) - Supports
<base>tag — the app works correctly when a<base href="...">tag is injected (enableinjectBaseTag: truein proxyConfig)
An application is incompatible if it:
- Hardcodes root-relative paths (e.g.
/api/login,/static/js/app.js) in JavaScript - Provides no configuration option to change the base path
- Does not respect the
<base>tag for JavaScript-initiated requests (XHR/fetch)
Root-relative requests from incompatible apps escape the proxy path and hit other ingress rules on the same domain (e.g. /api/login hits the API's /api rewrite ingress, returning unexpected responses).
| Strategy | Effectiveness | Notes |
|---|---|---|
| App's native base URL config | Best | Use defaultEnv with {{namespace}}/{{name}} placeholders |
rewriteHostAbsolutePaths: true |
Good | Catches asset requests via Referer; doesn't work for initial page-driven API calls without Referer |
injectBaseTag: true |
Partial | Fixes relative URLs but not root-relative URLs hardcoded in JS |
| Frontend proxy file redirect | Partial | Only catches requests that hit the frontend catch-all (not /api/* rewrite) |
| Application | Compatible | Configuration Required |
|---|---|---|
| code-server | Yes | rewriteHostAbsolutePaths: true, VSCODE_PROXY_URI=/proxy/{{namespace}}/{{name}}/proxy/{{port}}/ — port forwarding works via code-server's built-in /proxy/<port>/ route |
| OpenVSCode Server | Yes | Same as code-server: VSCODE_PROXY_URI=/proxy/{{namespace}}/{{name}}/proxy/{{port}}/ |
| noVNC / KasmVNC | Yes | tlsInsecure: true, needsNoopSW: true |
| linuxserver/webtop (KasmVNC) | Yes | SUBFOLDER=/proxy/{{namespace}}/{{name}}/ env + preservePathPrefix: true — app natively supports subfolder |
| linuxserver/firefox | Yes | Same as webtop: SUBFOLDER env + preservePathPrefix: true |
| Filebrowser | Yes | FB_BASE_URL=/proxy/{{namespace}}/{{name}} env + preservePathPrefix: true + needsNoopSW: true |
| JupyterLab | Yes | --NotebookApp.base_url=/proxy/{{namespace}}/{{name}} |
| kasmweb/* (port 6901) | Yes | tlsInsecure: true, needsNoopSW: true — uses cookie-based escape recovery via frontend server |
| flaccid/debian-desktop | Yes | audioPort: 6902 + additionalPorts: [{name: audio, port: 6902}] — audio WebSocket on separate port; VNC via cookie-based escape recovery |
- Check if a newer version of the app has added base URL support
- File an issue upstream requesting path prefix configuration
- Consider forking or patching the app
- As a last resort, do not support that app as a workspace image