Multi-tenant dashboard hosting under a shared domain
Host one Ark dashboard per tenant namespace behind a single domain, with each tenant served from its own URL prefix (e.g. mydomain.com/namespace1/, mydomain.com/namespace2/).
When to use this
Use this when you only have one external domain available but need a separate dashboard and API per tenant. Each tenant’s namespace, secrets, models, agents, and runtime resources stay isolated — only the URL space is shared.
basePath is the runtime knob that makes this work. It tells the dashboard which URL prefix it lives under, so every static asset, internal navigation link, and OIDC callback resolves under that prefix instead of the domain root. The value is applied at container startup — no image rebuild is required to change it.
Topology
For each tenant namespace, install:
- One
ark-apiHelm release (in the tenant’s namespace). - One
ark-dashboardHelm release (in the tenant’s namespace) withapp.config.basePathset to the tenant’s URL prefix.
A single cluster-wide Ingress (or Gateway API HTTPRoute) routes one rule per tenant:
mydomain.com/<ns>/*→ tenantark-dashboard.
The dashboard’s built-in proxy (app/api/v1/[...proxy]/route.ts) forwards /api/v1/* requests to the configured ark-api Service, minting a Bearer token from the user’s NextAuth session. The Ingress does not need a separate rule for /<ns>/api/v1/* — the dashboard pod is the only public surface per tenant.
You can still expose ark-api with a dedicated Ingress rule (/<ns>/api/v1/* with prefix-strip) if you want direct API access for CI jobs or other clients. That topology is shown later in this guide. Either way, ark-api itself is the auth boundary — see Access modes below.
Install per tenant
helm install ark-api-ns1 ./services/ark-api/chart \
--namespace namespace1 --create-namespace
helm install ark-dashboard-ns1 ./services/ark-dashboard/chart \
--namespace namespace1 \
--set app.config.basePath=/namespace1 \
--set app.config.arkApiService.host=ark-api.namespace1.svc.cluster.local \
--set 'app.env[0].name=BASE_URL,app.env[0].value=https://mydomain.com/namespace1' \
--set 'app.env[1].name=AUTH_URL,app.env[1].value=https://mydomain.com/namespace1/api/auth'Repeat per tenant, substituting namespace and prefix. A ready-to-use values file lives at services/ark-dashboard/chart/values-multi-tenant.example.yaml.
Access modes
Choose one of the two modes below before exposing the dashboard externally. Each tenant’s dashboard and ark-api must agree — running the dashboard in SSO while ark-api stays open leaves the API publicly callable behind the same domain.
Unauthenticated access (anyone can access any namespace)
Both the dashboard and ark-api run with AUTH_MODE=open. There is no sign-in, no token validation, and no per-tenant boundary at the application layer. Anyone who can reach mydomain.com/<ns>/... can read and write every Ark resource in that namespace.
Use this only when the cluster sits behind a separate auth boundary (corporate VPN, mTLS at the edge, allow-listed source IPs) that you trust to gate access. It is appropriate for local dev clusters and shared internal sandboxes; it is not appropriate for any environment reachable from the public internet.
Configuration:
# Dashboard (per tenant)
--set 'app.env[0].name=AUTH_MODE,app.env[0].value=open'
# ark-api (per tenant)
--set 'env[0].name=AUTH_MODE,env[0].value=open' # or simply leave unsetBASE_URL and AUTH_URL are still required on the dashboard so internal links and the (dummy) sign-in redirect resolve under the tenant prefix.
Isolated access (per-tenant OIDC)
Both the dashboard and ark-api run with AUTH_MODE=sso. The dashboard runs NextAuth and stores the OIDC access token in a session cookie scoped to the tenant’s basepath. The proxy route mints Authorization: Bearer <token> from that cookie on every forwarded request. ark-api validates the JWT against the OIDC issuer and returns 401 for any request without a valid token.
What this guarantees:
- Unauthenticated callers (no session, no Bearer) get 401 from ark-api. The same is true whether they hit the dashboard proxy or call ark-api directly through a dedicated Ingress rule.
- A user signed into
/namespace-ahas their NextAuth cookie scoped to/namespace-a/by Next.js’s basepath handling. The browser does not send the cookie on requests to/namespace-b/..., so casual cross-tenant navigation lands at a sign-in screen.
What this does not guarantee on its own:
- If every tenant trusts the same OIDC client, the JWT issued to a user authenticated for
/namespace-ais structurally valid against/namespace-b’s ark-api too. A deliberate attacker who copies their/namespace-acookie into a request against/namespace-b/api/v1/...will be accepted by ark-api, which then queries its own namespace with its own service account. RBAC on the user is not applied unless impersonation is enabled (see Tenant isolation). - For full cross-tenant isolation, register a distinct OIDC client per tenant and set
OIDC_APPLICATION_IDto that tenant’s client ID on its ark-api. The JWT’saudclaim is then checked per tenant and tokens from one tenant are rejected at another.
Configuration:
# Dashboard (per tenant)
--set 'app.env[0].name=AUTH_MODE,app.env[0].value=sso'
--set 'app.env[1].name=BASE_URL,app.env[1].value=https://mydomain.com/namespace1'
--set 'app.env[2].name=AUTH_URL,app.env[2].value=https://mydomain.com/namespace1/api/auth'
# plus your provider-specific AUTH_<PROVIDER>_ID / SECRET / ISSUER variables
# ark-api (per tenant)
--set 'env[0].name=AUTH_MODE,env[0].value=sso'
--set 'env[1].name=OIDC_ISSUER_URL,env[1].value=https://idp.example.com/realms/ark'
--set 'env[2].name=OIDC_APPLICATION_ID,env[2].value=ark-namespace1' # distinct per tenant for isolationThe OIDC provider’s allow-list must contain https://mydomain.com/<ns>/api/auth/callback/<provider> for each tenant — without that entry, the IdP rejects the redirect and sign-in fails.
Example Ingress (NGINX)
The recommended topology routes only /<ns>/* to the dashboard. The dashboard’s proxy handles API forwarding internally — no API-specific Ingress rule is needed.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ark-multi-tenant
namespace: ingress-shared
spec:
ingressClassName: nginx
rules:
- host: mydomain.com
http:
paths:
# /namespace1/<rest> -> dashboard in namespace1, no rewrite
- path: /namespace1
pathType: Prefix
backend:
service:
name: ark-dashboard
port: { number: 3000 }
# /namespace2/<rest> -> dashboard in namespace2, no rewrite
- path: /namespace2
pathType: Prefix
backend:
service:
name: ark-dashboard
port: { number: 3000 }The dashboard paths do not strip the prefix — Next.js needs to see /<ns>/... to route under its runtime basePath.
For Gateway API, pass each dashboard prefix through with a single HTTPRoute rule per tenant — no URLRewrite filter is required.
Alternative: direct API Ingress per tenant
If you also want to expose ark-api directly (for CI, scripts, or a non-browser client that holds its own bearer), add a second rule per tenant that strips the prefix:
metadata:
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /v1/$2
nginx.ingress.kubernetes.io/use-regex: "true"
spec:
rules:
- host: mydomain.com
http:
paths:
# /namespace1/api/v1/<rest> -> ark-api in namespace1 as /v1/<rest>
- path: /namespace1/api/v1(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: ark-api
port: { number: 80 }
# ... then the dashboard rule from the example aboveThis Ingress receives requests that bypass the dashboard pod — the request never sees the dashboard’s NextAuth cookie or its bearer minting. Ark-api alone enforces auth on this path, so you must run it in AUTH_MODE=sso (or basic/hybrid) for this rule to be safe.
Tenant isolation
The per-tenant ark-api Role + RoleBinding isolates secrets, configmaps, pods, services, and all Ark CRDs to the tenant’s namespace. Tenants cannot read or modify each other’s resources through their ark-api instance regardless of access mode.
Caveats (current state of the ark-api chart):
- The chart’s
ClusterRolegrants every tenant’s ark-api full CRUD on the cluster-scopedArkConfigresource. Tenants can read and modify the cluster’sArkConfig. Do not store cross-tenant defaults inArkConfigif your trust model requires strict isolation. - If
impersonation.enabled=truein the ark-api chart, every tenant’s service account can impersonate arbitrary users and groups cluster-wide. Keep impersonation disabled for cross-organisation tenancy. - Without per-tenant audience separation (distinct
OIDC_APPLICATION_IDper tenant), a JWT issued for one tenant’s user is structurally valid against every tenant’s ark-api. The Role + RoleBinding still scopes ark-api’s service account to its own namespace, so the attacker only sees data from the ark-api they reached — but they can reach it.
The full audit lives in the dashboard-runtime-basepath OpenSpec change (openspec/changes/dashboard-runtime-basepath/rbac-audit.md).
Verifying a deployment
After installing two tenants, the following should hold regardless of access mode:
curl https://mydomain.com/namespace1/returns the Ark Dashboard HTML, with every asset reference under/namespace1/.curl https://mydomain.com/api/v1/context(no prefix) is not routed to any tenant.- A user signing into
mydomain.com/namespace1/stays under/namespace1/for every subsequent navigation, including OIDC callbacks — providedBASE_URLandAUTH_URLinclude the prefix.
In isolated (AUTH_MODE=sso) mode, additionally check:
curl https://mydomain.com/namespace1/api/v1/context?namespace=namespace1(no Authorization header) returns 401 from the underlying ark-api. If it returns data, ark-api is running inAUTH_MODE=openand the API is publicly readable — fix this before exposing the domain.- A browser session signed into
/namespace1/keeps its NextAuth cookie scoped to/namespace1/: requests it makes to/namespace2/api/v1/...arrive at namespace2’s dashboard pod without a session cookie and end up returning 401 from namespace2’s ark-api.
In unauthenticated (AUTH_MODE=open) mode, the equivalent curl calls return data without any Authorization header. This is expected — confirm you trust the network boundary that fronts the cluster.
Troubleshooting
- Assets 404 with paths like
/_next/static/...instead of/<ns>/_next/static/...— the dashboard pod started beforeARK_DASHBOARD_BASE_PATHwas set, or the sentinel substitution was skipped. Check the entrypoint log for the lineentrypoint: ARK_DASHBOARD_BASE_PATH=...and re-roll the pod. - Sign-in loops back to
/api/auth/signin—BASE_URLorAUTH_URLis missing the prefix, or the OIDC provider’s registered redirect URI does not include the prefix. Both must match the tenant prefix exactly. - API calls 404 at the dashboard —
app.config.arkApiService.hostdoes not resolve, or it points at a Service in a different namespace. The dashboard proxy fetches<protocol>://<host>:<port>/v1/...and the failure surfaces as 404 (or 502) at the dashboard. Verifykubectl get svc -n <tenant>shows the expectedark-apiService. /<ns>/api/v1/*requests bypass the dashboard — only relevant if you added the optional direct-API Ingress rule. Check the rewrite target strips/<ns>so ark-api sees/v1/..., not/<ns>/api/v1/....- Dashboard renders but namespace switcher is empty — ark-api is reachable but the user has no RBAC for any namespace. Check the per-tenant
Role/RoleBindinginstalled by the ark-api chart.
Rolling back
Set app.config.basePath="" (default) and re-install the dashboard release. The container substitutes the sentinel with empty at startup; the dashboard serves at the root again. No image change is required.