Routing Modes — Subpath vs Subdomain
Choose how Scrydon apps are exposed externally. Subpath (default) puts every app under one hostname with a path prefix; subdomain puts every app on its own hostname. The same compiled binary serves either mode — the choice is made in Helm values.
Scrydon ships every customer-facing app (Cortex, Agentic, Analytics, Platform) plus the supporting APIs (Better-Auth, api-ontology, api-table) and the Marimo notebook editor as separate processes. How they are exposed to browsers is a deployment-time choice controlled by a single Helm value: routing.mode.
Subpath (subpath, default) | Subdomain (subdomain) | |
|---|---|---|
| Example URLs | app.customer.com/cortex app.customer.com/agentic app.customer.com/api/auth | cortex.customer.com agentic.customer.com auth.customer.com |
| DNS records | 1 A record | 1 wildcard or 5+ A records |
| TLS certs | 1 host cert | 1 wildcard or 5+ host certs |
| Ingress objects | 2 — ingress-frontdoor + ingress-marimo-sidecar (separate so marimo's forward-auth middleware does not bleed onto the SPAs) | One per app (5+, marimo included) |
| Cookie scoping | Host-scoped (stronger) | Domain=.customer.com (cross-subdomain) |
| CORS surface | One origin | One origin per subdomain |
| Best fit | Customer self-hosted | Multi-product SaaS where each app needs independent DNS / TLS / SSO |
The recommendation for every customer-deployed cluster is to keep the default and stay on subpath mode unless there is a specific reason to do otherwise (custom domain-per-app strategy, separate SSO realms, etc.).
How it works
A single compiled binary per app serves both modes. The path prefix flows in at runtime through three places:
- Helm
values.yaml→ ConfigMap. The chart resolves the path prefix per app (""in subdomain mode,/cortexetc. in subpath mode) and exposes it as theBASE_PATHenv var in each app's ConfigMap. Cross-app links are composed by the chart soPUBLIC_*_APP_URLenv vars are already correct in either mode. - App boot. The app reads
BASE_PATH, mounts its static asset table under the prefix, and rewrites SSR HTML responses so absolute asset URLs include the prefix. A small<script>window.__SCRYDON_BASE_PATH__="…"</script>is injected so client-side code stays in sync. - TanStack Router. Each app passes the resolved prefix as
basepathtocreateTanStackRouter. All<Link>, navigation, and route matching respect the prefix automatically.
The same mechanism applies to the API services (Better-Auth, api-ontology, api-table) — they read BASE_PATH and mount their roots under it.
Why not just rewrite paths in the ingress?
Strip-prefix at the ingress (/cortex/foo → /foo before the pod) is the obvious-looking shortcut and does not work for the kind of SPA we emit. The SSR HTML contains absolute asset URLs (<script src="/_build/assets/abc123.js">). When the browser is on /cortex/dashboard it then fetches /_build/assets/abc123.js directly — no prefix, no way for the ingress to route it back to the right pod. Hashed asset filenames also change every build, so a static regex rewrite cannot disambiguate which app should serve a given bundle.
Scrydon takes the runtime approach so the same binary works in both modes without rebuilding — the same approach used by Grafana (GF_SERVER_ROOT_URL), GitLab (relative URL root), and most cloud-provider consoles.
Subpath quick-start (recommended)
# values.customer.yaml
routing:
mode: subpath
host: app.example.com
# Default paths — override only if you need a different layout.
# paths:
# cortex: /cortex
# agentic: /agentic
# analytics: /analytics
# platform: /platform
# apiAuth: /api/auth
# apiOntology: /api/ontology
# apiTable: /api/table
# agenticRealtime: /agentic/realtime
# marimo: /marimoDNS: one A record for app.example.com. TLS: one cert (cert-manager + Let's Encrypt by default). Ingress: ingress-frontdoor (one path rule per SPA + API) plus ingress-marimo-sidecar (path /marimo, reuses the same TLS secret — kept as a separate Ingress so its forward-auth middleware doesn't apply to the SPAs).
Subdomain quick-start
# values.customer.yaml
routing:
mode: subdomain
auth:
publicUrl: https://auth.example.com
ingress: { hostname: auth.example.com }
corsOrigins:
- https://app.example.com
- https://cortex.example.com
- https://agentic.example.com
- https://ws-agentic.example.com
- https://analytics.example.com
platform:
publicUrl: https://app.example.com
ingress: { hostname: app.example.com }
cortex:
publicUrl: https://cortex.example.com
ingress: { hostname: cortex.example.com }
agentic:
publicUrl: https://agentic.example.com
publicSocketUrl: https://ws-agentic.example.com
app: { ingress: { hostname: agentic.example.com } }
realtime:
ingress:
hostname: ws-agentic.example.com
annotations:
# Required so the realtime WebSocket sticks to one pod for the
# duration of a session. Swap the Traefik-specific keys for the
# equivalent on your ingress controller if you are not using Traefik.
traefik.ingress.kubernetes.io/service.sticky.cookie: "true"
traefik.ingress.kubernetes.io/service.sticky.cookie.name: scrydon_realtime_affinity
traefik.ingress.kubernetes.io/service.sticky.cookie.secure: "true"
traefik.ingress.kubernetes.io/service.sticky.cookie.httponly: "true"
analytics:
publicUrl: https://analytics.example.com
ingress: { hostname: analytics.example.com }
marimoSidecar:
ingress:
hostname: marimo.example.com # own host, own TLS certDNS: one A record per subdomain (or a wildcard). TLS: one cert per host (or one wildcard cert). Ingress: one per app — ingress-cortex, ingress-auth, ingress-marimo-sidecar, etc.
DNS + TLS implications
Subpath mode (single host)
app.example.com A <ingress LB IP>cert-manager request: one Certificate for app.example.com, issued by the letsencrypt-prod ClusterIssuer. No wildcard needed. The chart configures the front-door Ingress automatically when ingress.tls.enabled: true.
Subdomain mode (per-app hosts)
app.example.com A <ingress LB IP>
cortex.example.com A <ingress LB IP>
agentic.example.com A <ingress LB IP>
ws-agentic.example.com A <ingress LB IP>
analytics.example.com A <ingress LB IP>
auth.example.com A <ingress LB IP>
marimo.example.com A <ingress LB IP> # only if marimoSidecar.enabledEach per-app Ingress requests its own Certificate. If your DNS provider supports it, a wildcard *.example.com cert reduces this to one rotation target. Cross-subdomain cookies depend on the registrable domain matching — do not mix subdomain mode with multiple unrelated registrable domains.
Cookie and CORS behavior
The chart configures cookie scoping based on routing.mode:
- Subpath: cookies are host-scoped (no
Domain=attribute). One origin for the whole product, so CORS collapses to a single allowlist entry plus the internal cluster Service URLs. - Subdomain: cookies use
Domain=<registrable-domain>so sessions survive cross-subdomain navigation. CORS allowlist enumerates every per-app subdomain.
In subpath mode you do not need to maintain auth.corsOrigins arrays — the chart composes the right list automatically.
Marimo notebook editor
The Marimo notebook editor is a Python process that ships behind its own Ingress (ingress-marimo-sidecar). The shape changes per routing mode:
| Subpath | Subdomain | |
|---|---|---|
| Host | routing.host (shared with front-door) | analytics.marimoSidecar.ingress.hostname |
| Path | /marimo (default — see routing.paths.marimo) | / |
| TLS secret | tls-frontdoor (shared with the front-door — avoids two certs racing for the same host) | tls-marimo-sidecar (dedicated) |
To disable marimo entirely set analytics.marimoSidecar.enabled: false.
Customer DNS / TLS prep:
- Subpath: nothing extra — the
app.example.comA record andtls-frontdoorcert already cover/marimo. - Subdomain: add an A record for
marimo.example.com(or include it in your wildcard) and let cert-manager requesttls-marimo-sidecarautomatically.
Switching modes on a live deployment
Both modes are first-class — you can switch by changing one value and running helm upgrade. The steps:
-
Provision the new DNS records and TLS certs for the target mode. Subpath → 1 record. Subdomain → records per app.
-
Update
values.customer.yaml: changerouting.modeand (for subpath) setrouting.host, or (for subdomain) set each app'spublicUrl+ingress.hostname. -
Apply:
helm upgrade scrydon oci://scrydonops.azurecr.io/scrydon/charts/scrydon \ --version "${CURRENT_VERSION}" \ --namespace scrydon-platform \ -f values.customer.yaml \ --wait -
Verify:
# Subpath: ingress-frontdoor + ingress-marimo-sidecar kubectl -n scrydon-platform get ingress # Subdomain: one Ingress per app kubectl get ingress -A | grep ingress- # Pods restart automatically because BASE_PATH changed in the ConfigMap kubectl -n scrydon-platform get pods -
Cut over DNS if you changed it. Old sessions cookied under the previous host will be ignored — users will be prompted to log in once.
A rollback is just changing routing.mode back and re-running helm upgrade; the per-app Ingress templates are kept in the chart for both modes.
Multi-namespace deployments
The front-door Ingress assumes every app's Service has been deployed into the same namespace as the front-door (the chart default). Customers running multi-namespace topologies — e.g. analytics in its own namespace, agentic in another — should either:
- Flatten the deployment back into one namespace (recommended for smaller installs), or
- Switch to subdomain mode, which emits one Ingress per app in the app's own namespace.
Kubernetes Ingress objects cannot reference Services across namespaces, so a true multi-namespace subpath mode would require ExternalName Services or per-namespace ingress controllers — out of scope for the default chart.