Frontier Admin Console — Deployment Guide

How a raystack/frontier admin console deployment is built, configured, deployed, verified, and rolled back. Applies to any Helm-on-Kubernetes deployment.

Everything here is illustrative except the raystack/frontier paths. Hosts (<admin-host>, <connect-host>), ports, filenames, pipeline details, and secrets tooling are placeholders — substitute your deployment's actual values. Ports shown are the upstream defaults from config/sample.config.yaml. Verified against v0.112.1; re-verify paths against the deployed tag.

Contents

  1. How it works
  2. Deploy to an environment
  3. Configure an environment
  4. Admin login (bootstrap superuser)
  5. Verify a deployment
  6. Roll back
  7. Cut a release (upstream frontier)
  8. Gotchas

1. How it works

Every procedure below follows from four facts:

  1. The console is embedded in the backend. A React/Vite SPA, compiled to static assets and baked into the Go binary at build time (//go:embed all:dist, web/apps/admin/embed.go). No Node.js in production.
  2. One image serves every environment. The SPA fetches its config at runtime from GET /configs; nothing environment-specific is compiled in.
  3. Everything environment-specific lives outside the image — each environment's Frontier config (the ui: block, app.admin.bootstrap, ingress, etc.), typically rendered at deploy time from a secrets backend and mounted into the pod as a ConfigMap.
  4. Backend and console are one artifact — they version, deploy, and roll back together. The console cannot roll back alone.
raystack/frontier                        environment config
─────────────────                        ──────────────────
web/apps/admin                           frontier config (ui:, app.admin.bootstrap)
   │ vite build → dist/admin                │ rendered from secrets at deploy time
   │ //go:embed                             │
   ▼                                        ▼
docker.io/raystack/frontier:{tag} ──► helm upgrade → Kubernetes


                       running pod: SPA + /configs + /frontier-connect proxy + API

One binary runs two HTTP servers (cmd/serve.go), both exposed through the ingress:

ServerConfig keyDefault portServesIngress host
UIui.port8100SPA, GET /configs, /frontier-connect/ proxy<admin-host>
Connect/APIapp.connect.port8002ConnectRPC/gRPC, gRPC health, GET /ping<connect-host>

Both are deliberately exposed: browsers use the admin host; API clients and automation use the Connect host directly.

UI server routing (pkg/server/server.go): /configs returns the config JSON; /frontier-connect/ reverse-proxies to {app.host}:{app.connect.port} with the prefix stripped; everything else serves the SPA with an index.html fallback.

Local dev differs — Vite serves /configs (from configs.dev.json) and proxies /frontier-connect, with the SPA unbundled on :5173. Don't carry local ports or behaviour into an environment; see the local-setup doc.

Key paths upstream:

PathPurpose
web/apps/adminAdmin SPA (Vite outDir: dist/admin, base: /)
web/apps/admin/embed.go//go:embed all:distvar Assets embed.FS
web/apps/admin/configs.dev.jsonDev /configs payload
pkg/server/server.goServeUI / ServeConnect, /configs, proxy, /ping, health
pkg/server/config.goUIConfig, TerminologyConfig, ConnectConfig
internal/bootstrap/bootstrapuser.goSuperuser bootstrap
config/sample.config.yamlAnnotated reference for every config key

2. Deploy to an environment

A deploy renders the environment's config from the secrets backend, then runs helm upgrade with an explicitly chosen image tag. The contract, regardless of pipeline tooling:

  • Each environment pins an image version — an immutable vX.Y.Z tag. A pre-production environment may instead track a mutable tag rebuilt from upstream main (see Roll back for the consequence).
  • The tag chosen at deploy time always wins. The Helm values file may also carry an image.tag, but the deploy sets the tag explicitly — never read the values file as the deployed version. Keep it pinned to a real version anyway: it's the fallback for a manual helm upgrade, and the chart's own default would be latest.
  • Config is re-rendered on every deploy, reaching the pod as a mounted ConfigMap (e.g. at /etc/frontier/configs/config.yaml) that the binary is pointed at with -c. Individual keys can also be overridden by env vars using the FRONTIER_SERVICE_ prefix (config/config.go).

Chart raystack/frontier from https://raystack.github.io/charts/ (pin a chart version), release name frontier, in the target namespace.

Steps: run the deploy pipeline for the target environment (overriding the tag only to pin something other than the environment's configured version), watch the helm upgrade, then verify.


3. Configure an environment

3.1 Change an existing setting

Console settings live in the environment's Frontier config → the ui: block — not in the Helm values.

  1. Edit the ui: block and merge it.
  2. Redeploy the same image version. No rebuild — config is read at runtime.

Available settings (UIConfig, pkg/server/config.go):

ui:
  port: 8100
  title: "Frontier Admin"
  logo: "data:image/png;base64,…"
  app_url: app.example.com       # per environment
  token_product_id: ""           # product id used when adding credits
  organization_types: []         # industry list shown in the org form
  webhooks:
    enable_delete: false
  terminology:                   # optional per-entity rename; no server-side defaults
    app_name: ""                 # serialized to JSON as appName
    organization: { singular: "", plural: "" }   # likewise project, team, member, user

Everything except port is returned by GET /configs (UIConfigApiResponse, pkg/server/server.go). Most keys are also documented inline in config/sample.config.yaml, but that file lags the code — webhooks and terminology.app_name are missing from it — so UIConfig is the authority. A key on main may not exist in an older tag.

3.2 Add a new setting

A field the backend doesn't know about will not reach the SPA. Change the code upstream first:

  1. Add the field to UIConfig (pkg/server/config.go) and document it in the ui: block of config/sample.config.yaml — an undocumented key is invisible.
  2. Add it to UIConfigApiResponse and the /configs handler (pkg/server/server.go).
  3. Consume it in the SPA (contexts/App.tsx / the Config type).
  4. Test the whole path locally: put the value in web/apps/admin/configs.dev.json and run against a local backend (see the local-setup doc). Vite re-reads that file per request.
  5. Release upstream (Cut a release), set the value in the environment config, bump the environment's image version, and deploy.

For dev-only values, configs.dev.json alone is enough — no backend change.


4. Admin login (bootstrap superuser)

A superuser service account seeded from config at boot, so automation always has a way in. Requires image v0.108.0+ (raystack/frontier PR #1719).

Wire it once per environment, with both values stored in the secrets backend:

app:
  admin:
    bootstrap:
      client_id: "<from secrets backend>"      # a UUID
      client_secret: "<from secrets backend>"

Log in with Authorization: Basic base64(client_id:client_secret).

Behaviour (internal/bootstrap/bootstrapuser.go):

  • Re-seeded on every boot — idempotent, so redeploys never cause a lockout.
  • Rotate by changing client_secret and redeploying. Never change client_id — it's the service-account credential id.
  • Setting only one field is a hard boot failure (client_id and client_secret must be set together). Emptying both disables seeding — and since granting superuser requires a superuser, none can then be created via the API.
  • If the client_id already belongs to another principal, boot fails loudly rather than rotating the wrong account.

The app.admin.bootstrap block in config/sample.config.yaml documents the UUID requirement, uuidgen, rotation, and the cost of disabling it.


5. Verify a deployment

If the hosts are behind an internal load balancer, connect to the VPN first.

  1. Liveness

    curl http://<connect-host>/ping
    # → {"status":"SERVING"}
  2. gRPC health

    curl -X POST http://<connect-host>/grpc.health.v1.Health/Check \
      -H 'Content-Type: application/json' \
      -d '{"service":"raystack.frontier.v1beta1.AdminService"}'
    # also valid: raystack.frontier.v1beta1.FrontierService
  3. Configuration — the real test that the right env config was applied:

    curl http://<admin-host>/configs
    # → this environment's title, app_url, terminology, organization_types
  4. SPA — open http://<admin-host>/ and log in.

  5. Version — no HTTP endpoint exposes it. The running image is most reliable:

    kubectl -n <namespace> get deploy -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.containers[*].image}{"\n"}{end}'

    Alternatives: the startup log line frontier starting version=<tag>, or frontier version in the pod. A mutable tag is a moving target.


6. Roll back

Rollback = redeploy the previous image version. Versioned tags are immutable and the SPA is embedded, so backend and console roll back together.

  1. Deploy with the tag set explicitly to the last known-good vX.Y.Z.
  2. Update the environment's pinned image version to match, so the next deploy doesn't roll forward again.
  3. Verify.

Caveats

  • An environment tracking a mutable tag has no previous version to return to — roll it back by pinning a real vX.Y.Z.
  • The bootstrap superuser is re-seeded on boot, so rollback never causes a lockout — but going below v0.108.0 removes the feature and breaks anything that authenticates with it.
  • If only config was wrong, don't roll the image back — fix the ui: block and redeploy the same version (§3.1).

7. Cut a release (upstream frontier)

Only for shipping backend or console code, and only in raystack/frontier. The versioned image published to docker.io/raystack/frontier is the sole artifact a deployment consumes.

Push a tag matching v*.*.* (or dispatch release.yml). The release pipeline builds the SPA (make admin-appdist/admin), embeds it into the Go binary with version ldflags, and publishes {tag}, latest, and {tag}-amd64 for linux/amd64, plus {tag}-arm64 from a second job. There's no combined manifest list, so arm64 must be pulled by its own tag. Then bump the target environment's image version and deploy.

Test console changes locally before tagging — a release is the only route into an environment, so a bad build costs a full cycle.

Building locally? dist/admin must exist before go build or the binary ships without a UI — run make admin-app first. Dockerfile.dev reproduces the full embed in one multi-stage build. The npm SDK in web/sdk ships separately via web-sdk.yml.


8. Gotchas

SymptomCause / fix
API works but no UI; log says ui server disabled: no port specifiedui.port is 0 or unset — set it in the environment config and redeploy.
API works but no UI; log says failed to load uiBinary built without dist/admin. Rebuild via the release pipeline, or make admin-app before go build.
SPA loads but shows wrong title/terminologyWrong or stale env config. Check GET /configs, fix the ui: block, redeploy the same image.
New config field never appears in /configsField exists only in YAML, not in code — complete every step in §3.2.
Deployed version isn't what the values file saysExpected — the deploy-time tag always overrides it (§2).
"Rolled back" a mutable tag but nothing changedMutable tags have no previous version — pin an explicit vX.Y.Z.
Boot fails or superuser login failsBootstrap misconfigured: fields set without a redeploy, only one of the two fields populated, client_id colliding with another principal, or image older than v0.108.0 (§4).
Browser API calls failThe SPA proxies /frontier-connect/ to {app.host}:{app.connect.port} inside the pod. Confirm the ingress routes the admin host to the UI port and the connect host to the Connect port.