Berserk Docs

UI First-Boot Setup

Provisioning the first admin user — one-time setup link, automated env, or OIDC-only

Berserk does not ship with default credentials. On a fresh deployment with an empty users table, you choose one of three paths to provision the first admin account. The choice is a configuration value on the gateway service — gateway owns the auth edge (OIDC, sessions, bootstrap, login), the UI services downstream just consume gateway-injected identity headers.

Choosing a path

PathWhen to useConfig switch (on gateway)
One-time setup link (default for self-hosted)Interactive first boot. The operator opens a URL printed to the gateway container's stderr and chooses the admin's email + password.(no config — this is the default)
Pre-provisioned adminReproducible deploys where the admin's credentials live somewhere outside Berserk (Docker secrets, K8s Secret, Vault, a Helm values file, a YAML config file).bootstrap_admin_email + bootstrap_admin_password_file (or bootstrap_admin_password)
OIDC-only (bootstrap disabled)The admin should come from your IdP (Google Workspace, Okta, Azure AD, …) — no password-based identity exists at all.bootstrap_disabled: true + oidc_*
Trusted reverse proxyAn upstream authenticating proxy already in front of the gateway asserts the user (direct email header or SPIFFE on-behalf-of); users are auto-provisioned by email.trusted_proxy_enabled: true + trusted_proxy_*

The default is the one-time setup link. Reach for Path B if your deploys are fully unattended, Path C if you want admins to come from your IdP only, or Path D if an authenticating reverse proxy already fronts the gateway.

Field names vs env vars. Berserk services accept the same configuration field through three layered sources, in this precedence (lowest to highest): YAML config file (via --config /path/to/gateway.yaml or GATEWAY_CONFIG), env vars (GATEWAY_<FIELD_IN_UPPERCASE>), then CLI flags (--field-with-dashes). The tables below name the fieldbootstrap_admin_email, oidc_issuer, etc. — and the examples show whichever form is idiomatic for that deployment shape (Docker Compose: env; K8s: env from Secret; Helm: values.yaml; bare-metal: a YAML file or CLI flags). All four reach the same validated Config struct.

This is the default for self-hosted dev/single-tenant. No extra environment variables are required beyond the standard runtime config.

What the operator sees

When the container starts with users empty, the setup URL is printed to the container's stderr. Treat container logs as sensitive — anyone who can read docker logs / kubectl logs for this pod sees the active token until setup is completed.

Initial admin setup:
  Open: https://your-host.example.com/setup?token=…
  This link expires in 1 hour and can be used once.

Open the URL in a browser, fill in the form — email, given name, family name, password (minimum 12 characters) — and submit. You are redirected to /login and can sign in with the credentials you just set.

On Helm / Kubernetes

Path A is the chart default — leave the gateway.bootstrap block untouched (no adminEmail/adminPassword, disabled: false). The only prerequisite is a public URL, which the minted link is built from:

values.yaml
global:
  publicBaseUrl: "https://berserk.example.com"

The gateway runs as the gateway Deployment; read the one-time link from its logs:

kubectl logs -n bzrk deploy/gateway | grep -A2 "Initial admin setup"

Operational properties

  • One-time-use: the token is consumed atomically with the admin-row insert. A second submission of the same token returns 410 Gone because users is no longer empty.
  • Restart-safe: the token is stored as a SHA-256 hash, so the original secret is unrecoverable on restart. The container rotates the row at every startup with users empty — restart the container to mint a fresh URL.
  • TTL: the link expires one hour after issue. Restart the container to mint a fresh one.
  • Inert after first user exists: the /setup route returns 410 Gone permanently once any user is present. Bookmark-replay is not a concern.

The setup URL contains the active token. Don't paste it into shared channels (chat, ticket comments, screen captures of docker logs) — treat it like a single-use admin credential. The container's stderr is the canonical source.

Path B: Pre-provisioned admin

Use this when the admin credentials are managed by your secret system (Docker secrets, K8s Secret, Vault) or templated into a config file (Helm values, a checked-in YAML, etc.) and the deploy is fully unattended.

values.yaml
gateway:
  bootstrap:
    adminEmail: admin@example.com
    adminGivenName: Cluster
    adminFamilyName: Admin
    # adminPassword — pass via --set (below) so the plaintext stays out of the file
helm install berserk berserk/berserk \
  --namespace bzrk \
  -f values.yaml \
  --set gateway.bootstrap.adminPassword="your-very-strong-password-here"

The chart writes adminPassword into the Helm-managed gateway-secrets Secret (key bootstrap_admin_password) and wires it into the gateway as GATEWAY_BOOTSTRAP_ADMIN_PASSWORD. Passing it with --set keeps the plaintext out of a checked-in values file; the password must be at least 12 characters. The admin row is created once, on the first boot with an empty users table — on later helm upgrade calls you don't repeat the flag. See Dependencies → Gateway Auth Secrets for managing that Secret externally.

Operational properties

  • The admin row is inserted once, on the first boot with users empty. Subsequent boots are no-ops.
  • Changing bootstrap_admin_password after the first boot has no effect — use the password reset flow (or rotate via the admin API) to change it.

Path C: OIDC-only (bootstrap disabled)

Set bootstrap_disabled: true (env: GATEWAY_BOOTSTRAP_DISABLED=true) to switch off both the setup-link path and the pre-provisioned-admin path. With it set, the /setup route returns 410 Gone and the container emits no setup banner. The first admin comes from your configured OIDC IdP instead — there's no DB-side seeding, just config:

FieldRequiredNotes
oidc_issueryesOIDC issuer URL (e.g. https://login.acme.com/realms/main). .well-known/openid-configuration is fetched lazily on first sign-in.
oidc_client_idyesOIDC client_id registered with the IdP.
oidc_client_secretone-ofInline client_secret. Must be at least 32 characters — the config layer rejects shorter values to bound the leak surface of the /admin/config operator-view page. Mutually exclusive with oidc_client_secret_file.
oidc_client_secret_fileone-ofFile path holding the secret (Docker-secrets style). Trailing newlines are trimmed. Takes precedence when both are set.
oidc_bootstrap_admin_emailsyes for OIDC-onlyComma/whitespace-separated list (or YAML sequence) of email addresses that gateway auto-promotes to cluster admin on first OIDC sign-in. Without at least one entry, an OIDC-only deployment lets users sign in but leaves every admin surface (/admin/config, the Invites tab on /admin/cluster/users) locked — no one has the AdminManageInvites role. Matched case-insensitively against the IdP's email claim.
oidc_scopesnoWhitespace- or comma-separated list (or YAML sequence) of extra scopes. openid email profile are always requested regardless of what you set here.
oidc_require_email_verifiednoReject id_tokens whose email_verified claim is not true. Default true; only set false for IdPs you trust to enforce verification some other way.

In Helm, these map onto gateway.config.oidc.*, plus gateway.bootstrap.disabled: true to turn the deployment OIDC-only:

values.yaml
gateway:
  bootstrap:
    disabled: true # turns off the setup-link AND pre-provisioned-admin paths
  config:
    oidc:
      enabled: true
      issuer: "https://login.acme.com/realms/main"
      clientId: "berserk"
      requireEmailVerified: true
      # scopes: [offline_access]   # openid email profile are always requested
      bootstrapAdminEmails:
        - admin@acme.com
  secrets:
    oidcClientSecret:
      existingSecret:
        name: gateway-oidc-secrets
        key: client_secret

The client_secret never goes in the values file — the chart wires GATEWAY_OIDC_CLIENT_SECRET from the gateway-oidc-secrets Secret, which is always external (you create it). See Dependencies → OIDC Client Secret.

After the gateway boots, sign in at /auth/oidc/login. The first verified email the IdP returns is auto-provisioned as a user; if that email is in the oidc_bootstrap_admin_emails allowlist, gateway also enqueues an admin grant via the existing grant-outbox so the user lands on /admin/* already privileged. Subsequent OIDC sign-ins from the same (issuer, subject) reuse that row.

To stop OIDC auto-provisioning — authenticate only users you've already added, rejecting unknown IdP logins with a 403 — set the deployment-wide auto_provision_users: false (directory mode).

Trust boundary, two layers.

  1. Who can sign in — anyone the configured IdP authenticates with a verified email. There is no domain whitelist or per-email allowlist on sign-in itself. Point this at a workforce IdP (Okta, Workspace, EntraID) or a single-tenant Keycloak realm; pointing it at a consumer-signup IdP (Google personal, GitHub) effectively makes the sign-up surface public.
  2. Who gets admin — only the addresses listed in oidc_bootstrap_admin_emails. Everyone else lands as a regular user (no cluster role). To promote additional admins later, an existing admin grants the role via the permissions service.

Setting bootstrap_disabled: true together with bootstrap_admin_password / bootstrap_admin_password_file is a startup error — pick one path.

deployment_mode: saas additionally requires OIDC to be configured (password login is refused), and the gateway will refuse to start without it.

Path D: Trusted reverse proxy

When an upstream authenticating reverse proxy already sits in front of the gateway, it can assert the signed-in user in a request header and Berserk will trust it — a fourth credential type alongside sessions, CLI tokens, and service tokens. Two extraction strategies are supported, each enabled independently (at least one is required):

  • Direct email header — the proxy forwards the user's email in a configured header (e.g. X-PP-USER).
  • SPIFFE on-behalf-of — the proxy forwards a SPIFFE identity (e.g. on-behalf-of: spiffe://<trust-domain>/user/<name>). The gateway strips a configured prefix to recover the username and appends a configured email domain to form the email (<name>@<email-domain>).

Identity is trusted on network grounds alone — there is no cryptographic verification — so the gateway must be unreachable except through the proxy. By default the user is auto-provisioned by email on first sight; set the deployment-wide auto_provision_users: false for directory mode, where only users that already exist in Berserk may sign in (see below).

SettingRequiredPurpose
trusted_proxy_enabledyesTurns the path on. Off by default.
trusted_proxy_user_headerone strategyDirect-email strategy. Header carrying the asserted email. Must not collide with X-Bzrk-* or the stripped reverse-proxy aliases (X-Forwarded-User, Remote-User, …) — the gateway refuses to start if it does.
trusted_proxy_on_behalf_of_headerone strategySPIFFE strategy. Header carrying the SPIFFE identity (e.g. on-behalf-of). Setting it enables the strategy; the same collision rules apply.
trusted_proxy_on_behalf_of_prefixwith on-behalf-ofSPIFFE URI prefix stripped to recover the username (e.g. spiffe://prod.example.net/user/). Pins the trust domain and /user/ path — a value that doesn't start with it is rejected.
trusted_proxy_on_behalf_of_email_domainwith on-behalf-ofEmail domain appended to the username (e.g. example.com).
trusted_proxy_allowed_domainsnoRestrict auto-provisioning to these email domains. Empty = any. Inert in directory mode (the user list is the allowlist; an existing user authenticates regardless of domain).
trusted_proxy_admin_emailsnoEmails auto-promoted to cluster admin on first sign-in, mirroring oidc_bootstrap_admin_emails. Inert in directory mode.
auto_provision_usersno (deployment-wide)Auto-provision users on first sight (default true). Set false for directory mode: only users that already exist authenticate; unknown emails are rejected (401) and never created. Applies to every assertion method (OIDC + trusted-proxy), not just this path.

At least one of trusted_proxy_user_header or trusted_proxy_on_behalf_of_header must be set when the path is enabled. When both are configured and a request carries both headers, on-behalf-of wins (the gateway falls back to the email header only when the SPIFFE header is absent).

In Helm these map onto gateway.config.trustedProxy.*. No secret material is involved — the headers are non-secret:

gateway:
  config:
    trustedProxy:
      enabled: true
      # Strategy 1 — direct email header (leave empty to disable):
      userHeader: X-PP-USER
      # Strategy 2 — SPIFFE on-behalf-of (set enabled: false to disable):
      onBehalfOf:
        enabled: true
        header: on-behalf-of
        spiffePrefix: "spiffe://prod.example.net/user/"
        emailDomain: example.com
      allowedDomains: [] # e.g. [corp.example]
      adminEmails: [] # e.g. [admin@corp.example]
  # config:
  #   autoProvisionUsers: false           # directory mode, deployment-wide (see below)

Asserted identity is trusted on network grounds alone.

The gateway honors the proxy headers on its normal listener with no cryptographic verification, so a header value behaves like a long-lived bearer credential for whoever it names. The gateway must not be reachable except through the trusted proxy — otherwise a caller can assert any user. Restrict ingress accordingly (e.g. a NetworkPolicy that admits only the proxy).

Directory mode (no auto-provisioning)

Directory mode is a deployment-wide posture — auto_provision_users: false (Helm: gateway.config.autoProvisionUsers: false) — that applies to every assertion-based method (OIDC and trusted-proxy). Reach for it when you'd rather manage the user list yourself: the gateway authenticates an asserted email only if it already maps to an existing, enabled user, and rejects an unknown email — no row is created (a 401 on the trusted-proxy path; a 403 on the OIDC callback). trusted_proxy_admin_emails / oidc_bootstrap_admin_emails are inert here (nothing is provisioned, so there's no first-sight grant).

Because the user table starts empty, the first admin doesn't come through an assertion — it comes from the first-boot bootstrap (Path A's one-time /setup link or Path B's pre-provisioned admin). That admin then adds the rest via invites or the admin UI, and the assertion methods authenticate exactly that managed set thereafter. On the trusted-proxy path the rejection is deliberately indistinguishable on the wire from any other 401, so the proxy can't probe who is or isn't a Berserk user.

Directory mode gates authentication (who gets in). It is orthogonal to the extraction strategy — combine it with the email header, SPIFFE on-behalf-of, or both.

Switching paths after deploy

If you started with the one-time setup link but haven't yet completed setup, you can switch to OIDC-only by setting bootstrap_disabled: true and restarting. The startup branch reaps any live setup-token row under the same advisory lock, so an unconsumed link issued before the switch becomes invalid.

Once the first user exists, the /setup route is permanently inert regardless of what bootstrap_* fields are set to. Switching config after first boot has no effect on existing users.

Adding more users after bootstrap

In self-hosted with password login (Path A or B), additional users come in via invites. Any authenticated user can mint a one-time accept URL from the Invites tab on /admin/cluster/users (the "Invite member" button). The recipient sets their own password at /invites/accept?token=… — no SMTP, the inviter shares the URL out of band.

Two rules worth knowing:

  • Self-service mints are role-less. A non-admin can mint an invite, but the resulting user lands with no cluster role. Granting admin / maintainer / observer via the invite's global_role field requires AdminManageInvites (i.e. you have to be an admin). The UI dropdown enforces this server-side; non-admins picking a role get a 403.
  • SaaS mode refuses invites. Password sign-up is forbidden in SaaS, so POST /api/invites returns 400. Use oidc_bootstrap_admin_emails for the first admin and grant subsequent admins from the UI.

In OIDC-only deployments (Path C), there's no invite step — users sign in via the IdP and get auto-provisioned. oidc_bootstrap_admin_emails is the only knob; everyone else lands as a regular user.

Common runtime config

These are required on the gateway service regardless of which bootstrap path you choose. Field names below; corresponding env vars are GATEWAY_<FIELD> upper-cased.

FieldNotes
database_urlPostgreSQL connection string. Gateway runs migrations on startup.
bindBind address. Default 0.0.0.0:9500.
public_base_urlRequired. Public-facing URL the browser hits (https://berserk.example.com). Used to mint absolute OIDC redirect URIs and the URL printed in the setup banner. Set this to whatever an end-user types into the address bar.
cookie_signing_keyAt least 64 bytes of random data (any encoding). Required in production. In dev with no key set, an ephemeral one is generated (sessions die on restart).
cookie_securetrue in production (sets the Secure flag on session cookies — requires HTTPS). false for plain-HTTP localhost dev.
service_tokenPre-shared bearer the gateway carries on every proxied hop. Constant-time-compared by apps/ui and apps/admin-ui. Treat as a secret.

The Helm chart wires these from the gateway-secrets Secret it provisions; you only set the values directly when running the binary outside Helm.

Verifying bootstrap worked

After whichever path you chose:

  1. curl https://<gateway-host>/ should return 303 See Other redirecting to /login.
  2. /login should render the login form (or the OIDC sign-in button, depending on what you provisioned).
  3. Signing in returns a Set-Cookie: __Host-session=… header and lands you on / (the data-plane UI).
  4. bzrk login against the same gateway should also succeed — see Berserk CLI → Authenticating.

If the container exits at startup with a config error rather than serving traffic, the resolver caught a contradictory combination — see the binary's stderr for the field name and reason.

On this page