Status: Draft for review
Author: (you) + Claude
Date: 2026-06-15
Related: nebari-operator, nebari-llm-serving-pack, jhub-apps
The Nebari Apps Pack is a Nebari Software Pack that lets users launch, manage, and
observe web apps on a Nebari Kubernetes cluster: static sites (HTML/CSS/JS
bundles served by nginx) and Python apps launched by a pixi task
(runtime.pixiTask). Apps run as pods behind Nebari's gateway with Keycloak SSO.
Scope note: Python app support (frameworks/
imagesources) was removed 2026-07-16, then reintroduced the same day in a simpler form: an app source (zip upload, git, or PVC) containing a pixi manifest, launched by a named pixi task that serves on0.0.0.0:8080. No per-app images, no framework table, no build pipeline.
Apps can be created and launched through four interfaces that all converge on a single
declarative resource (the App custom resource):
- A coding agent (Claude Code, Codex) — generates an app, then the user launches it with natural language via an in-cluster MCP server.
- The MCP server directly (tool calls: launch / list / access / remove / logs).
- A REST API (programmatic CRUD + observability).
- A form-based UI — like
jhub-apps, but with no JupyterHub dependency.
A companion Claude Code skill teaches agents how to scaffold apps in the exact layout this pack expects, so "generate then launch" is a smooth flow.
- Launch static web apps and Python/pixi apps as pods on Nebari, behind Keycloak SSO.
- One declarative
Appresource that the UI, API, MCP, and optional GitOps all produce. - Natural-language launching of agent-generated apps via an in-cluster MCP server.
- A form-based launch UI modeled on
jhub-appsbut free of JupyterHub. - CRUD + observability API and UI (status, logs, events, resource usage, URLs).
- MCP authenticates to Keycloak (device flow for CLI/agents).
- A skill to scaffold compatible static apps.
- Reuse Nebari conventions: Helm chart,
pack-metadata.yaml,NebariAppfor routing/auth.
- Not a general PaaS / arbitrary container scheduler — apps are static content or pixi projects run in the platform's shared Python image (no per-app images or build pipeline).
- Not building a new auth system — Keycloak (via nebari-operator) is the IdP.
- No multi-cluster federation in v1.
- No autoscaling beyond fixed replicas + optional scale-to-zero (deferred; see §15).
The Nebari ecosystem already provides most of the primitives. The Apps Pack composes them rather than reinventing.
| Component | What it gives us | How the Apps Pack uses it |
|---|---|---|
nebari-operator (reconcilers.nebari.dev/v1, NebariApp CRD, Go/kubebuilder) |
Given an existing Service, it provisions an HTTPRoute, a cert-manager Certificate, an Envoy SecurityPolicy, an auto-provisioned Keycloak OIDC client, and a landing-page tile. It does not create workloads. |
The Apps operator creates the Deployment+Service, then emits a NebariApp per app to get routing + TLS + SSO + landing-page registration "for free." |
| nebari-llm-serving-pack (CRD + Go operator + UI, Helm) | The reference template for "a pack with a CRD, an operator, and a UI" wired through NebariApp. |
Direct structural template for the Apps operator + Helm chart + pack-metadata.yaml. |
| jhub-apps (FastAPI + React; app data model; form UX; sharing) | Battle-tested app data model and a proven form UX. | We port the model, drop the JupyterHub spawner/proxy/registry (replaced by k8s + nebari-operator), and reuse the form-UI patterns. |
NebariApp routes to a Service that must already exist. Nothing in the ecosystem
creates the workload for an arbitrary user app. The Apps Pack owns workload creation —
that is its reason to exist.
┌─────────────────────────────────────────────────────────┐
Coding agent │ Nebari cluster │
(Claude Code / │ │
Codex) generates app │ ┌────────────┐ reads/writes ┌──────────────────┐ │
│ │ │ apps-mcp │ ───────────────► │ apps-api │ │
│ "launch it" │ │ (FastMCP) │ │ (FastAPI) │ │
▼ │ └────────────┘ └───────┬──────────┘ │
┌──────────┐ HTTP/MCP │ ▲ │ creates/ │
│ user / │ ──────────┼─────────┘ │ patches │
│ agent │ │ ┌────────────┐ REST/CRUD ▼ App CR │
└──────────┘ ─────────┼─► │ apps-ui │ ──────────────► ┌──────────────────┐ │
│ browser │ │ (React) │ │ App CR (etcd) │ │
│ │ └────────────┘ │ apps.nebari.dev │ │
│ │ └───────┬──────────┘ │
│ (optional) │ ┌────────────┐ git sync │ watch │
│ GitOps ────────┼─► │ ArgoCD │ ─────────────────────────┤ │
│ │ └────────────┘ ▼ │
│ │ ┌──────────────────────┐ │
│ │ │ apps-operator │ │
│ │ │ (Go / kubebuilder) │ │
│ │ └───────────┬──────────┘ │
│ │ reconciles into: │ │
│ │ ┌──────────────┐ ┌──────────┐ ┌──────▼────────┐ │
│ │ │ Deployment │ │ Service │ │ NebariApp │ │
│ │ │ (nginx pod) │ │ │ │ (routing+auth)│ │
│ │ └──────────────┘ └────┬─────┘ └──────┬────────┘ │
│ │ │ │ reconciled │
│ │ │ │ by │
│ │ │ ▼ nebari-op │
│ │ │ HTTPRoute + Cert + OIDC │
│ │ │ + landing-page tile │
│ │ │ │
└─────────────────┼──────── app URL ─────────┴──── Envoy Gateway ───────────┘
https://<app>.<cluster> (Keycloak SSO via SecurityPolicy)
The pattern in one sentence: every producer (agent/MCP, API, UI, GitOps) ends up writing
an App CR; the apps-operator turns that into a Deployment + Service + NebariApp; the
nebari-operator turns the NebariApp into routing + TLS + SSO + a landing-page tile.
| Component | Language / stack | Responsibility |
|---|---|---|
| App CRD | YAML (apps.nebari.dev/v1alpha1) |
The declarative contract for an app. |
| apps-operator | Go + kubebuilder/controller-runtime | Reconcile App → Deployment, Service, NebariApp, status. |
| apps-api | Python FastAPI (async SQLAlchemy, pydantic v2) | CRUD + observability; writes App CRs; the authority all clients use. |
| apps-ui | React + TS + Vite + shadcn/ui + Tailwind v4 | Form-based launch + management + observability dashboards. |
| apps-mcp | Python FastMCP | Agent-facing tools; Keycloak device-flow auth; calls apps-api. |
| apps skill | Claude Code skill (markdown + templates) | Scaffold static apps in the expected layout. |
Why the API is the authority (not the CRD directly): the API centralizes validation, RBAC, observability aggregation, and audit. MCP and UI never touch the Kubernetes API directly; they go through apps-api. GitOps is the one path that writes CRs without the API — that's an explicit, advanced opt-in.
App is the heart of the design. Group apps.nebari.dev, version v1alpha1, namespaced
(an app lives in a project/team namespace labeled nebari.dev/managed).
apiVersion: apps.nebari.dev/v1alpha1
kind: App
metadata:
name: docs-site
namespace: team-analytics
labels:
apps.nebari.dev/owner: jbouder # Keycloak sub / preferred_username
spec:
displayName: "Docs Site"
description: "Team documentation"
thumbnail: "data:image/png;base64,..." # optional
owner: jbouder
source: # where the app's content comes from
type: git # git | inline | pvc
# --- type: git ---
git: { url: "https://github.com/...", ref: "main", subdir: "site" }
# --- type: inline (small static content carried in the CR) ---
# inline: { files: { "index.html": "<!doctype html>..." } }
# --- type: pvc (content already on a volume) ---
# pvc: { claimName: "docs-content", subPath: "site" }
runtime:
env:
- name: LOG_LEVEL
value: info
resources:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "2", memory: "4Gi" }
keepAlive: false # if false + scaleToZero enabled, idle apps scale down
replicas: 1
access:
public: false # true => no auth (anonymous)
groups: ["analytics"] # Keycloak/OIDC groups allowed
users: ["alice", "bob"] # additional individual users
subdomain: docs-site # => https://docs-site.<cluster-domain>
status:
phase: Running # Pending|Deploying|Running|Failed|Stopped
url: "https://docs-site.cluster.example.com"
replicas: { desired: 1, ready: 1 }
conditions:
- type: WorkloadReady ; status: "True"
- type: RoutingReady ; status: "True" # mirrors NebariApp readiness
- type: Validated ; status: "True"
observedGeneration: 4
lastTransitionTime: "2026-06-15T12:00:00Z"
message: "All replicas ready"Every app is served by an unprivileged nginx on port 8080; auth is enforced at the gateway
by the NebariApp SecurityPolicy (never inside the app pod).
source.type |
Best for | How the content gets into the pod |
|---|---|---|
inline |
small sites (text assets, ≲900KB) | files carried in the CR, materialized as a ConfigMap-backed volume |
git |
version-controlled sites | a non-root init container clones the repo at pod start |
pvc |
larger sites / content already on a volume | mounts an existing PersistentVolumeClaim |
kubectl get apps printer columns show the Source (.spec.source.type) alongside phase
and URL.
This section answers the central question: how do pods fit the pack framework?
For each App, the operator runs an ordered, idempotent pipeline (mirroring nebari-operator's
core→routing→tls→auth structure):
- Validate — namespace is
nebari.dev/managed; source is coherent; owner set. Sets theValidatedcondition. - Workload — create/update a Deployment: an nginx image serving content into the web
root, sourced by
source.type:inline(files carried in the CR, materialized as a ConfigMap-backed volume — best for small sites),pvc(mount an existing volume — best for larger sites), orgit(init-container clone). Local-file launches (a directory with anindex.html) are an authoring convenience: the API/MCP/UI bundles the uploaded files and renders them into the CR asinline(small) or a provisionedpvc(large) — see §11. Apply resources, env, replicas, probes (readiness on the listen port), security context (non-root, read-only FS), and standard labels. - Service — a
ClusterIPService on the listen port. - Routing/Auth/Landing — emit a
NebariAppowned by thisApp:The nebari-operator then provisions HTTPRoute + Certificate + SecurityPolicy + Keycloak client + landing-page tile.apiVersion: reconcilers.nebari.dev/v1 kind: NebariApp metadata: { name: app-docs-site, namespace: team-analytics, ownerReferences: [<App>] } spec: hostname: docs-site.cluster.example.com # from access.subdomain + cluster domain gateway: public # or internal service: { name: app-docs-site, port: 8080 } routing: { routes: [ { pathPrefix: "/" } ] } auth: enabled: true # false if access.public provider: keycloak provisionClient: true # nebari-operator creates the OIDC client scopes: [openid, profile, email, groups] allowedGroups: ["analytics"] # maps from access.groups landingPage: enabled: true displayName: "Docs Site" category: "Apps" icon: "<thumbnail or default>"
- Status — aggregate workload + NebariApp conditions; publish
status.url, phase, replica counts, and a human-readable message.
The App owns the Deployment, Service, and NebariApp via ownerReferences. Deleting the
App (via API → CR delete, or kubectl delete) cascades automatically; the nebari-operator
tears down routing/cert/OIDC client.
- One contract, many producers. UI/API/MCP/GitOps all just produce an
App. No producer needs to know how to assemble Deployments, Services, routing, and OIDC clients. - Self-healing. The reconcile loop converges drift; restarts/upgrades are safe.
- GitHub is optional, not required. The apps-api writes CRs directly via a ServiceAccount (dynamic, no git). GitOps/ArgoCD is an opt-in path for teams who want their apps in version control. Both write the same CR; the operator doesn't care who wrote it.
- Matches the most mature pack (
nebari-llm-serving-pack= CRD + Go operator + UI).
- Browser users (UI + the apps themselves): standard Nebari Keycloak SSO. The UI is a
NebariAppwithauth.enabled: true; each launched app is likewise gated by its ownNebariAppSecurityPolicy(unlessaccess.public). - The MCP server / CLI / agents: Keycloak device authorization flow (RFC 8628). The nebari-operator already supports provisioning a public device-flow client. The agent runs the MCP tool, the MCP returns a verification URL + code, the user approves in a browser, and the MCP receives tokens. Tokens are cached locally (keyring) and refreshed.
- apps-api ↔ Kubernetes: the API runs with a ServiceAccount + RBAC scoped to create/patch
AppCRs (and read derived resources for observability) in managed namespaces.
- Who can launch / manage an app: enforced by apps-api using the caller's Keycloak groups.
An app's
access.groups/access.usersplus anownerfield define management rights. - Who can view/use a running app: enforced at the gateway by the
NebariAppSecurityPolicy(allowedGroups) — same mechanism every Nebari app uses.public: truedisables it for anonymous apps. - Namespaces as tenancy boundary: apps live in project/team namespaces. The API maps a user's groups → permitted namespaces.
- Agent scaffolds the app (skill), commits the files or pushes them to git.
- User: "Launch the docs site in ./docs-site as a public app."
- MCP
launch_apptool runs → if no valid token, returns a device-flow prompt → user approves → MCP callsPOST /appson apps-api with the bearer token. - apps-api validates the token + groups, writes the
AppCR, returns the (pending) app with its future URL. MCP reports status;get_app_statuspolls untilRunning.
A FastMCP (Python) server running in-cluster as part of the pack, exposed as a NebariApp
(streamable HTTP). It is a thin, well-described tool layer over apps-api — it holds no
business logic of its own, so behavior stays consistent across UI/API/MCP.
| Tool | Purpose | Maps to |
|---|---|---|
authenticate |
Start/refresh Keycloak device flow; return verification URL+code or confirm cached token. | Keycloak device endpoint |
describe_cluster |
Capabilities (apps domain, source types, launchable namespaces) so the agent picks valid options. | GET /capabilities |
launch_app |
Create + launch an app from NL-resolved params (name, source, access). | POST /apps |
list_apps |
List apps the caller can see (filter by namespace/owner/status). | GET /apps |
get_app |
Full spec + status + URL for one app. | GET /apps/{id} |
get_app_status |
Lightweight phase/replicas/url poll. | GET /apps/{id}/status |
get_app_logs |
Recent pod logs (optionally follow N lines). | GET /apps/{id}/logs |
update_app |
Patch an app (replicas, env, source ref, access). | PATCH /apps/{id} |
stop_app / start_app |
Scale to zero / back up. | `POST /apps/{id}:stop |
remove_app |
Delete the app (cascades). | DELETE /apps/{id} |
- Tool descriptions are written for an LLM caller: each documents required vs. optional args
and enumerates valid
source_typevalues (inline | git | pvc). launch_appis idempotent on(namespace, name): re-launching updates rather than duplicating, so an agent retrying is safe.- All tools return structured JSON the agent can reason over (status, url, conditions, next actions like "approve device login at ").
FastAPI, async SQLAlchemy (for app metadata/audit/observability cache), pydantic v2, structlog, OIDC bearer auth (validates Keycloak tokens; in-cluster + external issuer URLs).
# Capabilities
GET /capabilities # { appsDomain, sourceTypes, namespaces }
# App CRUD (writes App CRs to the cluster)
GET /apps # list (RBAC-filtered: namespace/owner/groups)
POST /apps # create + launch -> writes App CR
GET /apps/{id} # full spec + status
PATCH /apps/{id} # update spec (replicas, env, source, access)
DELETE /apps/{id} # delete (cascade)
POST /apps/{id}:stop # scale to zero
POST /apps/{id}:start # scale back up
# Observability
GET /apps/{id}/status # phase, replicas, url, conditions
GET /apps/{id}/logs # pod logs (query: lines, follow, container)
GET /apps/{id}/events # k8s events for the app's resources
GET /apps/{id}/metrics # cpu/mem (if metrics-server present)
# Auth
GET /auth/device # initiate device flow (for MCP/CLI)
GET /auth/me # current user + groups
The API validates against /capabilities, applies RBAC, then renders and applies the App
CR. The DB stores a denormalized copy + audit log; live status is read back from the
CR/cluster (the CR remains source of truth, the DB is a cache + history).
React + TS + Vite + shadcn/ui + Tailwind v4 (your frontend-dev conventions), TanStack Query +
Jotai, Keycloak via the standard Nebari SSO. Exposed as a NebariApp.
- App catalog / dashboard — grid of app cards (thumbnail, name, source type, status badge, owner, URL). Filter by status/owner/namespace. This is the landing experience.
- Launch form (the
jhub-apps-style flow, minus JupyterHub):- Name, description, thumbnail.
- Source: tabs for Upload (a zip or a single
.htmlfile — rendered into the CR asinlineorpvc) and Git. - Resources (cpu/mem/replicas) and env vars (key-value editor).
- Access: public toggle, groups/users selector, subdomain.
- Submit →
POST /apps→ redirect to the app detail page (spawn-pending → running).
- App detail / observability — status + conditions timeline, live URL, logs viewer (streamed), events, metrics (cpu/mem), and edit/stop/start/delete actions.
- Edit — same form pre-populated (maps to
PATCH /apps/{id}).
The UI is deliberately a thin client over apps-api (same authority as MCP), so the launch semantics are identical whether a human uses the form or an agent uses NL.
Operators can rebrand the UI without rebuilding the image, using the contract shared with
nebari-landing, llm-serving-pack, and provenance-collector-pack: the chart renders
ui.title / ui.branding.* into a /config.json ConfigMap mounted over the placeholder in
the image, and the SPA fetches it at startup (src/lib/branding.ts). Title, favicon, and
theme CSS variables are applied before React mounts; the logo is read by the header, and
optional classification banners wrap the page. Outside Kubernetes the same fields resolve
from a local config.json or BRANDING_* env vars overlaid by the image entrypoint.
Two deliberate deviations from the sibling packs: Keycloak settings stay out of
/config.json (this UI reads them from GET /api/v1/config, since the SPA client id depends
on the namespace), and the overridable theme tokens include this pack's top-bar tokens
(headerBackground, headerForeground, headerBorder, bodyBackground) because its chrome
is a full-width header. Every branding value is validated in the browser before it is applied
— CSS-injection characters are dropped, and logo/favicon URLs are restricted to http(s),
root-relative paths, and allow-listed base64 data: images.
A Claude Code skill (/new-nebari-app or similar) that an agent invokes to generate an app
in the exact layout the pack expects, so "generate → launch" is frictionless. (Lives alongside
your existing new-frontend / new-backend skills.)
- Scaffolds a real
index.html+ assets and anebari-app.yamlmanifest (a thin, human-authored spec the API/MCP can consume directly — maps 1:1 toApp.spec) whose source points at the local content directory:Manifest source types are# nebari-app.yaml — sits next to index.html displayName: "Docs Site" source: type: files # authoring convenience: a local directory of real files files: { path: "." } # dir containing index.html, relative to this manifest access: { public: true, subdomain: "docs-site" }
files | git | pvc. On launch, the API/MCP/UI bundles the referenced files and renders them into theAppCR assource.type: inline(small sites) or a provisionedpvc(large sites). So the author works with actual files, never hand-edited inline HTML;inline/pvc/gitremain the on-cluster CR forms. - Emits the exact natural-language launch instruction the user can hand to the MCP, e.g.:
"Launch the app in ./docs-site using nebari-app.yaml." — the MCP reads
nebari-app.yamland callslaunch_app.
It bridges the agent and the launcher: the agent writes code + nebari-app.yaml; the MCP/API
read that manifest so there's no ambiguity translating NL → App.spec. It's also the GitOps
artifact if the team commits it.
- Status: every
Apppublishes phase + conditions + URL; surfaced in UI, API, MCP. - Logs: apps-api streams pod logs (k8s API) → UI logs viewer + MCP
get_app_logs. - Events: k8s events for the app's Deployment/Pods/NebariApp aggregated per app.
- Metrics: cpu/mem from metrics-server (if installed); optional ServiceMonitor for Prometheus to scrape app + operator metrics.
- Operator metrics: reconcile counts, errors, durations (controller-runtime defaults).
- Audit: apps-api records who launched/changed/removed each app (DB), exposed in detail view.
- Pod hardening: non-root, drop capabilities, read-only root FS,
seccomp
RuntimeDefault, resource limits required (defaults applied if omitted). - Network: default-deny
NetworkPolicyper app namespace; app pods reach only what they need (DNS, declared egress). - Untrusted code: apps run user/agent-authored content. Treat every app as untrusted: per-namespace tenancy, no cluster-admin tokens in app pods, no host mounts. Consider gVisor/Kata for stronger isolation (deferred).
- Auth bypass paths: only
access.public: trueapps skip SSO — flagged prominently in UI and require an explicit confirmation + (optionally) an admin-allowed group. - Secrets: app env secrets via referenced k8s Secrets, never inlined in the CR; OIDC client secrets are operator-managed (nebari-operator) and mounted, not exposed via API.
- MCP: device-flow tokens scoped to the user's groups; the MCP cannot exceed the caller's RBAC because it always acts as the authenticated user against apps-api.
Follows the established pack convention (template: nebari-llm-serving-pack).
nebari-apps-pack/
pack-metadata.yaml # dashboard registration + nebariapp_integration: full
charts/nebari-apps/
Chart.yaml
values.yaml # clusterDomain, gateways, keycloak, images
crds/
app-crd.yaml # apps.nebari.dev/v1alpha1 App
templates/
operator-*.yaml # operator Deployment + RBAC + (optional) webhook
api-deployment.yaml
api-service.yaml
api-nebariapp.yaml # exposes apps-api (auth on)
ui-deployment.yaml
ui-service.yaml
ui-nebariapp.yaml # exposes apps-ui (landing-page tile = "Apps")
mcp-deployment.yaml
mcp-service.yaml
mcp-nebariapp.yaml # exposes apps-mcp (device-flow client)
namespace.yaml
_helpers.tpl
operator/ # Go / kubebuilder
api/v1alpha1/app_types.go
internal/controller/app_controller.go
internal/controller/reconcilers/{validate,workload,service,routing,status}/
cmd/ Dockerfile go.mod
api/ # FastAPI
src/nebari_apps_api/ pyproject.toml Dockerfile
ui/ # React + Vite
src/ components.json vite.config.ts package.json Dockerfile nginx.conf
mcp/ # FastMCP
src/nebari_apps_mcp/ pyproject.toml Dockerfile
skill/ # the scaffolding skill
SKILL.md references/ assets/
examples/ # sample App CRs, ArgoCD Application
docs/ README.md LICENSE
- Install: Helm chart (or ArgoCD Application), parameterized with
clusterDomain, gateway names/namespaces, andkeycloak.*. - Prereqs: nebari-operator (for
NebariApp), Envoy Gateway + AI Gateway, cert-manager issuer, Keycloak realm, and a StorageClass (forpvcsources). - CRD lifecycle: ship the
AppCRD incharts/crds/(or a separate ArgoCD-managed source).
- Scale-to-zero / idle reaping. v1 = fixed
replicas+ manual stop/start. Future: KEDA or Knative for true scale-to-zero on idle (keepAlive: false). Decide whether the operator owns this or delegates. - Per-app custom domains vs. subdomain-only — v1 is subdomain under cluster domain.
- Sharing UX parity with jhub-apps (revoke/re-grant flows) — model supports it; UI depth TBD.
- App templates / marketplace — a catalog of starter apps the UI/skill can clone.
- Stronger sandboxing (gVisor/Kata) for untrusted agent-generated content.
- Resource quotas per namespace/group — enforce launch limits.
| Decision | Choice | Rationale |
|---|---|---|
| Pod orchestration | App CRD + Go operator | One declarative contract for all producers; self-healing; GitHub optional (API writes CRs directly). |
| API / UI / MCP stack | FastAPI + React + FastMCP (Python/TS) | Reuse jhub-apps model + your frontend/backend skills; operator stays Go (matches nebari-operator). |
| Scope | Static + Python/pixi apps | Python was cut 2026-07-16 (overlap with python-capability-pack's framework/image model), then reintroduced the same day as zip/git/pvc source + runtime.pixiTask — no images, no build pipeline, one shared pixi runtime image. |
| GitOps | Optional, not required | API writes CRs dynamically via ServiceAccount; ArgoCD path for teams who want version control. |
{ "displayName": "Docs Site", "description": "Team documentation", "namespace": "team-analytics", "source": { "type": "git", "git": { "url": "...", "ref": "main", "subdir": "site" } }, "runtime": { "env": [{ "name": "LOG_LEVEL", "value": "info" }], "replicas": 1 }, "access": { "public": false, "groups": ["analytics"], "subdomain": "docs-site" }, "thumbnail": "data:image/png;base64,..." // optional }