Self-Hosting

Flextenant Chart

Deploy the orchestrator and portal, the core of flex.plane.

The flextenant chart deploys the two core components of flex.plane: the orchestrator (GraphQL API and control plane) and the portal (web dashboard). Both run as separate deployments within the same Helm release.

Configure the orchestrator

The orchestrator is the stateless API gateway. It needs to know about your OIDC provider, mesh network, and tenant configuration.

# charts/flextenant/values.yaml

oidc:
  clientId: "<portal-client-id>"        # From Zitadel portal app
  issuer: "https://id.flexplane.example.com"

zitadel:
  orgId: "<zitadel-org-id>"             # From Zitadel console
  projectId: "<zitadel-project-id>"     # From Zitadel console
  privateKeySecret: "zitadel-key"       # Secret created in identity setup

headscale:
  domain: mesh.flexplane.example.com    # Mesh control server domain

tenant:
  name: "My Organization"
  domain: "flexplane.example.com"

orchestrator:
  replicaCount: 1
  image:
    repository: ghcr.io/zeitlos/flex.plane/orchestrator
    tag: "a1b2c3d"

  controlUrl: "https://mesh.flexplane.example.com"

  resources:
    requests:
      cpu: 10m
      memory: 192Mi
    limits:
      memory: 1Gi
Make sure the OIDC client ID, organization ID, and project ID match the values from your Zitadel setup in the Identity Chart. Mismatched values will cause authentication failures.

The orchestrator exposes several ports:

PortService
8080GraphQL API and playground
9090Headscale control server
50443Headscale gRPC
7777Terminal proxy (VM console WebSocket)

Persistence

The orchestrator requires a persistent volume for Headscale state (SQLite database, node keys):

orchestrator:
  persistence:
    enabled: true
    storageClass: ""  # Uses cluster default
    size: 10Gi
    accessMode: ReadWriteOnce

Container registry

If your Proxmox nodes pull images from a private registry, configure the registry credentials:

containerRegistry:
  host: "ghcr.io"
  imagePullSecret: "regcred"

Configure the portal

The portal is a Nuxt 3 application that serves the web UI.

portal:
  replicaCount: 1
  image:
    repository: ghcr.io/zeitlos/flex.plane/portal
    tag: "a1b2c3d"

  resources:
    requests:
      cpu: 10m
      memory: 128Mi
    limits:
      memory: 1Gi

The portal reads its OIDC configuration from the orchestrator's settings. The key environment variables it needs are:

  • NUXT_OIDC_CLIENT_ID: same as the orchestrator's OIDC client ID
  • NUXT_OIDC_ISSUER_WELL_KNOWN: the OIDC discovery URL
  • NUXT_PUBLIC_BASE_URL: the public URL of the portal
  • NUXT_PUBLIC_TENANT_NAME: the display name for your tenant

These are typically set via extraEnv or derived from the chart values.

Deploy the chart

kubectl create namespace flexplane

helm install flextenant charts/flextenant \
  -n flexplane \
  -f my-flextenant-values.yaml

Watch the rollout:

kubectl rollout status deployment/flextenant-orchestrator -n flexplane
kubectl rollout status deployment/flextenant-portal -n flexplane

Configure ingress

Both the orchestrator and portal support Kubernetes ingress. Enable it in the values:

orchestrator:
  ingress:
    enabled: true
    tlsEnabled: true
    className: "nginx"
    annotations:
      cert-manager.io/cluster-issuer: "letsencrypt"

portal:
  ingress:
    enabled: true
    tlsEnabled: true
    className: "nginx"
    path: "/portal"
    annotations:
      cert-manager.io/cluster-issuer: "letsencrypt"

Verify the ingress resources were created:

kubectl get ingress -n flexplane

If you are using cert-manager for TLS, check that certificates are issued:

kubectl get certificates -n flexplane

Ingress paths

The default routing is:

PathServicePurpose
/graphqlOrchestratorGraphQL API endpoint
/playgroundOrchestratorGraphQL playground (development)
/portalPortalWeb dashboard
The portal is served under the /portal path by default. All API routes (/graphql, /playground, /health/*) are handled by the orchestrator. Make sure your ingress routes these paths to the correct service. In production, disable the GraphQL playground by setting orchestrator.extraEnv to include PLAYGROUND=false.

Health checks

The orchestrator exposes health endpoints for Kubernetes probes:

EndpointPurpose
/health/liveLiveness probe: is the process alive?
/health/readyReadiness probe: is it ready to serve traffic?

These are configured automatically in the chart's pod spec. The startup probe allows up to 5 minutes for initial startup (useful when Headscale is initializing).

Verify the deployment

Check the orchestrator health

curl -s https://flexplane.example.com/health/ready

A healthy orchestrator responds with a 200 OK.

Open the portal

Navigate to https://flexplane.example.com/portal/ in your browser. You should see the flex.plane login screen. Click Login to be redirected to Zitadel for authentication.

After logging in, you will see the dashboard. It will be empty since no nodes or VMs exist yet. That is expected.

Test the GraphQL API

If the playground is enabled, open https://flexplane.example.com/playground and run a test query:

query {
  zones {
    id
    name
  }
}

This will return an empty list until you register your first node, but it confirms the API is working and authentication is configured correctly.

You need to set the Authorization header in the playground to Bearer <your-token>. You can grab a token from the portal's browser session (check the network tab in dev tools).