Scrydon
DeploymentReference

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 URLsapp.customer.com/cortex
app.customer.com/agentic
app.customer.com/api/auth
cortex.customer.com
agentic.customer.com
auth.customer.com
DNS records1 A record1 wildcard or 5+ A records
TLS certs1 host cert1 wildcard or 5+ host certs
Ingress objects2ingress-frontdoor + ingress-marimo-sidecar (separate so marimo's forward-auth middleware does not bleed onto the SPAs)One per app (5+, marimo included)
Cookie scopingHost-scoped (stronger)Domain=.customer.com (cross-subdomain)
CORS surfaceOne originOne origin per subdomain
Best fitCustomer self-hostedMulti-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:

  1. Helm values.yaml → ConfigMap. The chart resolves the path prefix per app ("" in subdomain mode, /cortex etc. in subpath mode) and exposes it as the BASE_PATH env var in each app's ConfigMap. Cross-app links are composed by the chart so PUBLIC_*_APP_URL env vars are already correct in either mode.
  2. 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.
  3. TanStack Router. Each app passes the resolved prefix as basepath to createTanStackRouter. 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.

# 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: /marimo

DNS: 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 cert

DNS: 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.enabled

Each 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.

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:

SubpathSubdomain
Hostrouting.host (shared with front-door)analytics.marimoSidecar.ingress.hostname
Path/marimo (default — see routing.paths.marimo)/
TLS secrettls-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.com A record and tls-frontdoor cert already cover /marimo.
  • Subdomain: add an A record for marimo.example.com (or include it in your wildcard) and let cert-manager request tls-marimo-sidecar automatically.

Switching modes on a live deployment

Both modes are first-class — you can switch by changing one value and running helm upgrade. The steps:

  1. Provision the new DNS records and TLS certs for the target mode. Subpath → 1 record. Subdomain → records per app.

  2. Update values.customer.yaml: change routing.mode and (for subpath) set routing.host, or (for subdomain) set each app's publicUrl + ingress.hostname.

  3. Apply:

    helm upgrade scrydon oci://scrydonops.azurecr.io/scrydon/charts/scrydon \
      --version "${CURRENT_VERSION}" \
      --namespace scrydon-platform \
      -f values.customer.yaml \
      --wait
  4. 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
  5. 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.

On this page

On this page