Skip to main content

Kubernetes Deployment

When to Use

  • Production or multi-user deployments
  • Managing platform services with Helm
  • Dynamic workspace scheduling via workspace-operator
  • Per-workspace network isolation (Cilium)
  • Public-facing access with Ingress / TLS

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│ Ingress Controller │
│ example.com → frontend │
│ workspace-manager.example.com → manager │
│ keycloak.example.com → keycloak │
│ workspace-runtime-{id}.example.com → runtime pod │
│ workspace-browser-{id}.example.com → browser pod │
└────────┬──────────┬──────────┬──────────────────────────────┘
│ │ │
┌─────▼────┐ ┌───▼────┐ ┌──▼───────┐ ┌──────────────┐
│ Frontend │ │Manager │ │Keycloak │ │ Workspace │
│ Deploy │ │Deploy │ │Deploy │ │ Operator │
└──────────┘ └───┬────┘ └──────────┘ │ Deploy │
│ └──────┬───────┘
┌─────▼──────┐ │ reconcile
│ Celery + │ ┌──────▼───────┐
│ Flower │ │ Workspace CR │
└────────────┘ │ (CRD) │
└──────┬───────┘
┌────────────┐ │ creates
│ PostgreSQL │ ┌──────▼───────┐
│ StatefulSet│ │ Runtime Pod │
└────────────┘ │ Browser Pod │
┌────────────┐ │ Canvas Pod │
│ Redis │ │ Service │
│ StatefulSet│ │ Ingress │
└────────────┘ │ CiliumPolicy │
┌────────────┐ └──────────────┘
│ CoTURN │
│ Deployment │
└────────────┘

Helm Management Scope

The Helm chart manages the following platform services:

Resource TypeItems
Deploymentfrontend, workspace-manager, workspace-operator, keycloak, coturn
StatefulSetpostgres, redis
ServiceAll service ClusterIPs, CoTURN NodePort
IngressUnified ingress for frontend, workspace-manager, keycloak
ConfigMapplatform-config, workspace-routing, firewall-defaults, keycloak-realm, frontend-nginx
SecretDatabase passwords, Keycloak password
RBACworkspace-operator ClusterRole, workspace-manager Role, ServiceAccounts
CRDworkspaces.platform.aileron.io
Jobpostgres-bootstrap (database initialization)
note

Dynamic resources per workspace (Pods, Services, Ingresses, CiliumNetworkPolicies) are not managed by Helm directly — they are reconciled by the workspace-operator based on the Workspace CR.

Requirements

  • Kubernetes cluster (1.26+ recommended)
  • kubectl
  • helm (3.12+ recommended)
  • Ingress Controller (nginx by default)
  • Manageable DNS (workspace hosts must resolve; use wildcard DNS or automate per-host records)
  • TLS certificate (for public deployment, optionally with cert-manager)
  • Cilium (for full per-workspace firewall)
  • Shared storage (ReadWriteMany PVC or equivalent)

Helm Chart Location

helm/aileron/
├── Chart.yaml
├── values.yaml
├── crds/
│ └── platform.aileron.io_workspaces.yaml
├── templates/
│ ├── _helpers.tpl
│ ├── frontend-deployment.yaml
│ ├── workspace-manager-deployment.yaml
│ ├── workspace-operator-deployment.yaml
│ ├── keycloak-deployment.yaml
│ ├── coturn-deployment.yaml
│ ├── postgres-statefulset.yaml
│ ├── redis-statefulset.yaml
│ ├── ingress.yaml
│ ├── platform-configmap.yaml
│ ├── workspace-routing-configmap.yaml
│ ├── firewall-defaults-configmap.yaml
│ ├── workspace-manager-rbac.yaml
│ ├── workspace-operator-rbac.yaml
│ └── ... (other services / secrets / jobs)
└── files/
└── realm.json

Installation

Validate the Chart

# Lint
helm lint helm/aileron

# Render templates
helm template test-release helm/aileron

Install

helm install aileron helm/aileron \
--namespace aileron \
--create-namespace

Install with Custom Values

# Copy defaults and edit
cp helm/aileron/values.yaml my-values.yaml
# Edit my-values.yaml ...

helm install aileron helm/aileron \
--namespace aileron \
--create-namespace \
-f my-values.yaml

Upgrade

helm upgrade aileron helm/aileron \
--namespace aileron

Uninstall

helm uninstall aileron --namespace aileron
CRDs Are Not Auto-Removed

helm uninstall does not remove CRDs. To fully clean up:

kubectl delete crd workspaces.platform.aileron.io

Public Routing Configuration

Kubernetes mode uses host-based routing (subdomain style) rather than path-based ingress.

Current behavior:

  • Helm creates one platform Ingress for Frontend, Workspace Manager, and Keycloak
  • workspace-operator creates one separate Ingress each for workspace-runtime, workspace-browser, and workspace-canvas whenever a workspace is created

So workspace traffic is not routed through a single wildcard Ingress rule. The operator expands the host patterns with workspaceId and creates explicit Ingress hosts.

Helm Values

ValueDefaultDescription
publicRouting.schemehttphttp or https
publicRouting.baseDomainaileron.localBase domain
publicRouting.frontendHost{baseDomain}Frontend host
publicRouting.workspaceManagerHostworkspace-manager.{baseDomain}Manager host
publicRouting.keycloakHostkeycloak.{baseDomain}Keycloak host
publicRouting.runtimeHostPatternworkspace-runtime-{workspaceId}.{baseDomain}Runtime host pattern
publicRouting.browserHostPatternworkspace-browser-{workspaceId}.{baseDomain}Browser host pattern
publicRouting.canvasHostPatternworkspace-canvas-{workspaceId}.{baseDomain}Canvas host pattern

{baseDomain} and {workspaceId} are placeholders that Helm templates resolve at deploy time.

Example

Using example.com as the example domain:

helm upgrade --install aileron helm/aileron \
--namespace aileron \
--create-namespace \
--set publicRouting.scheme=https \
--set publicRouting.baseDomain=example.com \
--set publicRouting.frontendHost='{baseDomain}' \
--set publicRouting.workspaceManagerHost='workspace-manager.{baseDomain}' \
--set publicRouting.keycloakHost='keycloak.{baseDomain}' \
--set publicRouting.runtimeHostPattern='workspace-runtime-{workspaceId}.{baseDomain}' \
--set publicRouting.browserHostPattern='workspace-browser-{workspaceId}.{baseDomain}' \
--set publicRouting.canvasHostPattern='workspace-canvas-{workspaceId}.{baseDomain}'

Public host mapping:

ServiceHost
Frontendhttps://example.com
Workspace Managerhttps://workspace-manager.example.com
Keycloakhttps://keycloak.example.com
Workspace Runtimehttps://workspace-runtime-<workspaceId>.example.com
Workspace Browserhttps://workspace-browser-<workspaceId>.example.com
Workspace Canvashttps://workspace-canvas-<workspaceId>.example.com

Each new workspace gets its own set of URLs from these patterns. For example, default-workspace becomes:

  • workspace-runtime-default-workspace.example.com
  • workspace-browser-default-workspace.example.com
  • workspace-canvas-default-workspace.example.com

DNS & TLS Requirements

DNS Records

Required DNS records:

TypeNameTargetPurpose
A / CNAMEexample.comIngress IPFrontend
A / CNAMEworkspace-manager.example.comIngress IPManager API
A / CNAMEkeycloak.example.comIngress IPAuthentication
A / CNAME*.example.com or automated workspace-*-<workspaceId>.example.com recordsIngress IPAll workspace subdomains
tip

Kubernetes currently creates explicit Ingress hosts for each workspace component, such as workspace-runtime-default-workspace.example.com. A wildcard DNS record is the simplest way to cover these dynamic hosts, but you can also create per-host A/CNAME records automatically with tools such as ExternalDNS.

TLS Setup

With cert-manager for automatic certificate management:

# values.yaml
ingress:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- secretName: aileron-tls
hosts:
- example.com
- "*.example.com"
Workspace TLS behavior

values.yaml ingress.tls only applies to the Helm-managed platform Ingress. The workspace Ingresses created by workspace-operator currently include host/path rules and nginx annotations, but do not set spec.tls. If workspace subdomains must use HTTPS, the ingress controller usually needs to serve a wildcard or default certificate, or the operator must be extended to configure TLS on those Ingresses.

Or manually create a TLS Secret:

kubectl create secret tls aileron-tls \
--cert=fullchain.pem \
--key=privkey.pem \
-n aileron

Internal vs External URLs

important

internalUrl and externalUrl have distinct roles. Do not mix them up.

TypePurposeUse Case
internalUrlCluster-internal Service DNSpod-to-pod, service-to-service calls
externalUrlPublic Ingress URLBrowser, OIDC redirect, WebSocket, preview

Internal URL examples:

http://workspace-manager.<namespace>.svc.cluster.local:3001
http://workspace-runtime-<workspaceId>.<namespace>.svc.cluster.local:3002
http://workspace-browser-<workspaceId>.<namespace>.svc.cluster.local:6080
http://workspace-canvas-<workspaceId>.<namespace>.svc.cluster.local:3003

The workspace-routing ConfigMap records the full routing contract, including service name templates and port mappings:

SettingValue
RUNTIME_SERVICE_NAME_TEMPLATEworkspace-runtime-{workspaceId}
BROWSER_SERVICE_NAME_TEMPLATEworkspace-browser-{workspaceId}
CANVAS_SERVICE_NAME_TEMPLATEworkspace-canvas-{workspaceId}
RUNTIME_SERVICE_PORT3002
BROWSER_SERVICE_PORT6080
CANVAS_SERVICE_PORT3003

Workspace CRD

The Workspace Operator uses a custom CRD workspaces.platform.aileron.io to manage workspaces:

apiVersion: platform.aileron.io/v1alpha1
kind: Workspace
metadata:
name: ws-example
namespace: workspace-system
spec:
workspaceId: "my-workspace"
ownerId: "user-123"
provisioner: kubernetes
runtime:
imageKey: default
image: ailerondocker/workspace-runtime:latest
resources: {}
browser:
enabled: true
image: ailerondocker/workspace-browser:latest
canvas:
enabled: true
image: ailerondocker/workspace-canvas:latest
workspacePath: /workspace
targetNamespace: workspace-system
git:
url: "https://github.com/example/repo.git"
branch: main
envVars:
- key: NODE_ENV
value: production
firewall:
workspace:
networkAccessEnabled: true
domainAccessMode: specific
allowedDomains:
- github.com
- api.anthropic.com
browser:
networkAccessEnabled: true
domainAccessMode: specific
allowedDomains:
- google.com

CRD Status

The Operator writes workspace status to .status:

FieldDescription
status.phaseOverall workspace phase
status.targetNamespaceNamespace where pods are actually deployed
status.components.runtime.phaseRuntime pod phase
status.components.runtime.internalUrlRuntime internal URL
status.components.runtime.externalUrlRuntime external URL
status.components.browser.*Browser pod phase and URLs
status.components.canvas.*Canvas pod phase and URLs
status.firewall.*.effectiveAllowedDomainsEffective domain allowlist

Operations Triggers

Use spec.operations to trigger component restarts:

spec:
operations:
restartWorkspaceAt: "2026-04-09T10:00:00Z" # Restart entire workspace
restartRuntimeAt: "2026-04-09T10:00:00Z" # Restart runtime only
restartBrowserAt: "2026-04-09T10:00:00Z" # Restart browser only
restartCanvasAt: "2026-04-09T10:00:00Z" # Restart canvas only

Kubernetes Settings

Helm ValueEnv VariableDefaultDescription
kubernetes.provisionerRUNTIME_PROVISIONERkubernetesDefault provisioner
kubernetes.defaultNamespaceRUNTIME_K8S_NAMESPACEworkspace-systemDefault namespace
kubernetes.allowedNamespacesRUNTIME_K8S_ALLOWED_NAMESPACES[workspace-system, default]Allowed namespaces
kubernetes.serviceTypeRUNTIME_K8S_SERVICE_TYPEClusterIPService type
kubernetes.nodePortRUNTIME_K8S_NODE_PORT(empty)NodePort
kubernetes.nodeAddressRUNTIME_K8S_NODE_ADDRESS127.0.0.1Node address
kubernetes.pvcNameRUNTIME_K8S_PVC_NAMEworkspace-runtime-pvcPVC name
kubernetes.runtimeImageRUNTIME_K8S_IMAGEailerondocker/workspace-runtime:latestRuntime image
kubernetes.browserImageRUNTIME_K8S_BROWSER_IMAGEailerondocker/workspace-browser:latestBrowser image
kubernetes.canvasImageRUNTIME_K8S_CANVAS_IMAGEailerondocker/workspace-canvas:latestCanvas image
kubernetes.watchNamespaceWATCH_NAMESPACE(empty, all namespaces)Operator watch namespace

Overriding Namespace and Allowlist

helm upgrade --install aileron helm/aileron \
--namespace aileron \
--create-namespace \
--set kubernetes.defaultNamespace=workspace-system \
--set kubernetes.allowedNamespaces[0]=workspace-system \
--set kubernetes.allowedNamespaces[1]=team-a \
--set kubernetes.allowedNamespaces[2]=team-b

RBAC & Service Accounts

Workspace Operator

The Operator needs ClusterRole-level permissions to manage workspace resources across namespaces:

API GroupResourcesVerbs
"" (core)pods, services, PVC, events, configmaps, secretsAll
appsdeployments, statefulsetsAll
networking.k8s.ioingressesAll
cilium.iociliumnetworkpoliciesAll (only when cilium is enabled)
platform.aileron.ioworkspaces, workspaces/status, workspaces/finalizersAll

Workspace Manager

The Manager only needs Role-level permissions (limited to the workspace namespace):

API GroupResourcesVerbs
platform.aileron.ioworkspacesAll

Storage & Persistence

Platform Service Persistence

ServiceDefault SizeAccess ModePurpose
PostgreSQL10GiReadWriteOnceDatabase
Redis5GiReadWriteOnceCache and task queue
# values.yaml example
postgres:
persistence:
enabled: true
size: 20Gi
storageClass: "fast-ssd"

redis:
persistence:
enabled: true
size: 5Gi

Workspace Storage

Workspaces use PVC mounts for their working directories. The Operator configures mounts automatically based on kubernetes.pvcName:

kubernetes:
pvcName: workspace-runtime-pvc
Shared Storage

If multiple workspaces need to share base images or tools, use a ReadWriteMany StorageClass (NFS, CephFS, EFS, etc.).

Knowledge Base Storage

Knowledge Bases use a dedicated shared PVC managed by the Helm chart:

kubernetes:
knowledgeBases:
pvcName: knowledge-bases-pvc
size: 20Gi
accessModes:
- ReadWriteMany
storageClassName: hostpath

Mount flow:

  • Helm creates knowledge-bases-pvc
  • workspace-manager mounts it at /host/knowledge-bases
  • workspace-operator mounts each attached KB into runtime Pods at /knowledge/<alias> using subPath=<kbId>

Local development guidance:

  • the default hostpath value is a single-node fallback for local clusters
  • it is acceptable for Docker Desktop or local Kubernetes smoke testing
  • it is not a substitute for real multi-node RWX shared storage

Production guidance:

  • switch kubernetes.knowledgeBases.storageClassName to a real RWX shared class such as nfs
  • helm/values-rke.yaml already contains this override
  • verify knowledge-bases-pvc is Bound before validating KB attach / mount flows

Recommended checks:

kubectl get pvc -n aileron knowledge-bases-pvc
kubectl describe pvc -n aileron knowledge-bases-pvc
kubectl describe deployment -n aileron aileron-workspace-manager

CoTURN (WebRTC TURN Server)

The workspace-browser uses neko to stream the desktop via WebRTC. In Kubernetes, multiple NAT layers exist between the neko pod and the user's browser — a TURN server is required to relay WebRTC media.

Why TURN Is Required

WebRTC uses ICE to find a working path between peers:

Browser (client) ←── WebSocket signaling ──→ neko pod (K8s)
│ │
└──── direct (host candidate) ───────────────┘
pod IP (10.x.x.x) unreachable → fails
│ │
└──── TURN relay ─── coturn ────────────────┘
both sides get relay address → succeeds

Without TURN, ICE only has host candidates (pod-internal IPs), and the browser cannot reach them. The browser component stays stuck at Connecting....

Architecture

[Browser] [K8s Node]
│ turn:nodeIP:nodePort │
│──────────────────────────→│ NodePort 30479
│ │ ↓ (kube-proxy)
│ [coturn pod]
│ hostNetwork: true
│ port 3478 bound to nodeIP
│ relay: nodeIP:49152-65535
│←─── relay at nodeIP:49xxx ─┘

[neko pod]
│ turn:nodeIP:nodePort
│──────────────────────────→ [coturn] (reachable inside K8s)
│←─── relay at nodeIP:49yyy ─┘

Both relay addresses are on nodeIP — coturn bridges them → WebRTC connected

Why hostNetwork: true

TURN relay uses ephemeral UDP ports (default 49152–65535). NodePort cannot map this entire range — one NodePort per port is impractical. With hostNetwork: true:

  • The coturn pod shares the node's network namespace
  • Relay ports bind directly to the node IP and are reachable externally
  • The signaling port (3478) is also directly on the node IP

This is the standard pattern for deploying TURN servers in NodePort-only Kubernetes clusters.

host vs frontendHost

neko v3 separates ICE server configuration into backend (neko pod → TURN) and frontend (browser → TURN). This allows different addresses in environments where the same IP is not reachable from both sides:

SettingUsed byDescription
coturn.hostneko pod (backend)IP pods use to reach TURN — the node IP
coturn.frontendHostUser's browser (frontend)IP browsers use to reach TURN. Defaults to host if not set.
Production

In production, the browser connects directly to the node IP (host). frontendHost does not need to be set — it falls back to host automatically.

Local development (Docker Desktop)

Docker Desktop only exposes NodePort services to the Mac browser via localhost, not via the VM IP (192.168.65.3) directly. Therefore:

  • host = 192.168.65.3 (the node IP, reachable from pods)
  • frontendHost = 127.0.0.1 (reachable from the Mac browser via Docker Desktop's localhost proxy)

Helm Values

Helm ValueDefaultDescription
coturn.enabledtrueEnable TURN server
coturn.port3478coturn listening port (inside container)
coturn.nodePort30478K8s NodePort
coturn.host192.168.65.3Externally reachable node IP (backend)
coturn.frontendHost"" (same as host)IP browsers use to reach TURN (frontend)
coturn.usernameaileronTURN username
coturn.credentialaileron-turn-secretTURN credential (use a strong secret in production)
coturn.realmaileron.localhostTURN realm

Configuration Examples

Production (NodePort, node has a public IP):

coturn:
host: "203.0.113.10" # node's public IP
nodePort: 30479
username: "aileron"
credential: "your-strong-secret-here"
# frontendHost not needed — defaults to host

Firewall must allow:

  • nodePort (default 30479): UDP + TCP for TURN signaling
  • 49152–65535: UDP for TURN relay media

Local development (Docker Desktop):

coturn:
host: "192.168.65.3" # Docker Desktop node IP (reachable from pods)
frontendHost: "127.0.0.1" # Mac browser reaches TURN via localhost proxy
nodePort: 30479

Multi-node clusters:

hostNetwork: true means only one coturn instance per node (port 3478 conflict). Pin coturn to a dedicated node using nodeSelector:

# Label the designated TURN node
kubectl label node <turn-node> role=turn

# In values.yaml, add:
# coturn.nodeSelector.role: turn

Set coturn.host to that specific node's IP.

Verifying TURN Connectivity

A successful connection shows in the browser pod log:

ICE connection state changed: connected
peer connection state changed: connected
set webrtc connected: connected=true

If the browser is stuck at Connecting..., check in order:

  1. Is coturn advertising the correct relay address?

    kubectl logs -n aileron deployment/aileron-coturn | grep "Relay address"
    # Must show node IP (e.g. 203.0.113.10), NOT pod IP (10.x.x.x)

    Pod IP in the relay address means hostNetwork: true is missing or --external-ip is not set.

  2. Is UDP 49152–65535 open on the firewall?

  3. Local dev only: Is frontendHost set to 127.0.0.1 (not the Docker Desktop VM IP)?

Ingress Configuration

The default configuration uses the nginx ingress controller with long timeouts required for WebSockets:

ingress:
enabled: true
className: "nginx"
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"

The Helm chart auto-generates Ingress rules for:

  • Frontend (frontendHost)
  • Workspace Manager (workspaceManagerHost)
  • Keycloak (keycloakHost)
note

Dynamic workspace Ingresses (runtime, browser, canvas) are created by the Operator during reconciliation. Each workspace gets its own hosts and Ingress objects, separate from the Helm-managed platform Ingress.

Firewall Defaults

In Kubernetes mode, the platform installs a firewall-defaults ConfigMap separated into workspace and browser groups:

Default Allowed Domains

Workspace Runtime:

  • github.com, api.github.com, objects.githubusercontent.com, raw.githubusercontent.com
  • registry.npmjs.org, npmjs.com
  • pypi.org, files.pythonhosted.org
  • api.anthropic.com

Workspace Browser:

  • github.com
  • google.com, gstatic.com, googleapis.com

Overriding Firewall Defaults

helm upgrade --install aileron helm/aileron \
--namespace aileron \
--set firewall.defaults.workspace.allowedDomains[0]=github.com \
--set firewall.defaults.workspace.allowedDomains[1]=registry.npmjs.org \
--set firewall.defaults.browser.allowedDomains[0]=google.com \
--set firewall.defaults.browser.allowedDomains[1]=gstatic.com

The Operator and Manager read this ConfigMap via the FIREWALL_DEFAULTS_CONFIGMAP_NAME env variable. Once Cilium is enabled, the Operator creates a CiliumNetworkPolicy per workspace.

# Enable Cilium
cilium:
enabled: true

Helm Values Reference

Global

ValueDefaultDescription
global.imagePullSecrets[]Image pull secrets
global.storageClass""Default StorageClass

Service Toggles

ValueDefaultDescription
frontend.enabledtrueEnable Frontend
workspaceManager.enabledtrueEnable Manager
workspaceOperator.enabledtrueEnable Operator
postgres.enabledtrueEnable PostgreSQL
redis.enabledtrueEnable Redis
keycloak.enabledtrueEnable Keycloak
coturn.enabledtrueEnable CoTURN

Service Images

ValueDefault
frontend.image.repositoryailerondocker/workspace-ui
frontend.image.taglatest
workspaceManager.image.repositoryailerondocker/workspace-manager
workspaceManager.image.taglatest
workspaceOperator.image.repositoryailerondocker/workspace-operator
workspaceOperator.image.taglatest

Credentials

ValueDefaultDescription
postgres.auth.usernamepostgresDB user
postgres.auth.passwordpostgresDB password
postgres.auth.appDatabaseaileronApplication DB
postgres.auth.keycloakDatabasekeycloakKeycloak DB
keycloak.auth.adminUseradminKeycloak admin
keycloak.auth.adminPasswordadminKeycloak password
workspaceManager.env.SECRET_KEY(dev default)JWT signing key

Kubernetes Storage

ValueDefaultDescription
kubernetes.pvcNameworkspace-runtime-pvcWorkspace working directory PVC
kubernetes.knowledgeBases.pvcNameknowledge-bases-pvcDedicated shared PVC name for Knowledge Bases
kubernetes.knowledgeBases.size20GiKnowledge Base PVC capacity
kubernetes.knowledgeBases.accessModes[ReadWriteMany]Knowledge Base PVC access modes
kubernetes.knowledgeBases.storageClassNamehostpathLocal dev fallback; switch to a shared RWX class such as nfs for production

Verifying the Deployment

After installation, run the following checks:

# Check all pod statuses
kubectl get pods -n aileron

# Check services
kubectl get svc -n aileron

# Check ingress
kubectl get ingress -n aileron

# Verify CRD is installed
kubectl get crd workspaces.platform.aileron.io

# View Workspace CRs (if any)
kubectl get workspaces -A

# Check ConfigMaps
kubectl get configmap -n aileron

# View logs for specific services
kubectl logs -n aileron deployment/aileron-workspace-manager
kubectl logs -n aileron deployment/aileron-workspace-operator

Current Limitations

  • How dynamic workspace hosts are served (single Ingress, Gateway API, or a custom controller) depends on your cluster's ingress capabilities
  • Fully public domain routing requires DNS and TLS to be set up; otherwise Keycloak/OIDC, preview, and WebSockets will not work externally
  • Enabling per-workspace domain allowlists requires Cilium
  • CoTURN host must be set to the node's actual routable IP. Production deployments do not need frontendHost (it defaults to host). Local Docker Desktop development requires frontendHost: "127.0.0.1" because Docker Desktop only proxies NodePort services through localhost, not via the VM IP directly.