STATEBOARD/docs

Self-hosting

Docker Compose, Helm, Keycloak setup, airgap-friendly deployment.

StateBoard v1 is designed to deploy inside a company's own perimeter. One container + Postgres + your existing IdP. No outbound calls except to your IdP at runtime.

Local dev (Docker Compose)

The repo ships a docker-compose.yaml with Postgres + a pre-seeded Keycloak realm. Two test users (alice@stateboard.dev, bob@stateboard.dev) with passwords matching their usernames.

cp .env.example .env
docker compose up -d
pnpm install
pnpm migrate
pnpm dev

Open http://localhost:3000, click Continue with Keycloak, sign in as alice. The first sign-in becomes the workspace owner.

Production: Docker Compose (bring your own Keycloak)

If you already run Keycloak (or any OIDC IdP) and just want StateBoard + a Postgres next to it, the repo ships deploy/docker-compose.yaml. It contains:

  • postgres: a 16-alpine instance, data in a named volume, not exposed to the host.
  • migrate: runs node scripts/migrate.mjs once against the DB and exits. Idempotent on every up.
  • stateboard: the app, listening on port 3000, uploads in a named volume.

There is no Keycloak service. You point at the one you already run.

# 1. Pull the env template and fill it in
cp deploy/docker-compose.env.example deploy/.env

# 2. Edit deploy/.env, at minimum:
#    - STATEBOARD_BASE_URL          public HTTPS URL of the app
#    - BETTER_AUTH_SECRET           openssl rand -base64 32
#    - POSTGRES_PASSWORD            openssl rand -hex 32  (URL-safe; not -base64)
#    - KEYCLOAK_ISSUER              https://<your-kc>/realms/<your-realm>
#    - KEYCLOAK_CLIENT_ID           e.g. "stateboard"
#    - KEYCLOAK_CLIENT_SECRET       from the client you create below

# 3. Bring it up
docker compose -f deploy/docker-compose.yaml --env-file deploy/.env up -d

Then put your reverse proxy of choice (Caddy, nginx, Traefik, a cloud LB) in front of port 3000 so the public URL is HTTPS and matches STATEBOARD_BASE_URL.

Upgrades: change STATEBOARD_IMAGE to a newer tag, docker compose ... up -d. The migrate service runs again automatically; it is a no-op when nothing is pending.

The KEYCLOAK_* env-var names work with any OIDC provider. Better Auth uses OIDC discovery, so only the issuer URL needs to change. Auth0, Authentik, Zitadel, Okta, etc. all work the same way.

Plain docker run

If you'd rather wire it up yourself (existing Postgres elsewhere, no compose):

docker run -d --name stateboard \
  -p 3000:3000 \
  -v stateboard-uploads:/data \
  -e DATABASE_URL=postgres://user:pass@db:5432/stateboard \
  -e BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
  -e STATEBOARD_BASE_URL=https://stateboard.example.com \
  -e KEYCLOAK_ISSUER=https://keycloak.example.com/realms/acme \
  -e KEYCLOAK_CLIENT_ID=stateboard \
  -e KEYCLOAK_CLIENT_SECRET=... \
  ghcr.io/saschb2b/stateboard:latest

Run docker run --rm -e DATABASE_URL=... ghcr.io/saschb2b/stateboard:latest node scripts/migrate.mjs once before the first start, and again on every upgrade. (The compose file does this for you via the migrate service.)

Production: Helm

helm dependency build deploy/helm/stateboard
helm install stateboard ./deploy/helm/stateboard \
  --namespace stateboard --create-namespace \
  --set auth.baseUrl=https://stateboard.example.com \
  --set auth.secret="$(openssl rand -base64 32)" \
  --set auth.keycloak.issuer=https://keycloak.example.com/realms/acme \
  --set auth.keycloak.clientSecret=$KC_SECRET \
  --set postgresql.auth.password="$(openssl rand -hex 32)"

The chart bundles a Bitnami Postgres sub-chart by default. A pre-install / pre-upgrade Job runs pnpm migrate before any pods come up.

Use a URL-safe password for the bundled Postgres (openssl rand -hex 32). The chart interpolates it into DATABASE_URL (a postgres:// URL), so a base64 password, whose / is reserved in a URL, produces a cryptic Invalid URL at startup. A non-URL-safe password must be percent-encoded. If you bring your own externalDatabaseUrl, encode the password there too.

To use an external Postgres:

--set postgresql.enabled=false \
--set externalDatabaseUrl=postgres://user:pass@db.example.com:5432/stateboard

Or read the URL from a Secret via externalDatabaseUrlExistingSecret.{name,key}.

Multi-replica deployments need persistence.accessMode: ReadWriteMany for the uploads volume. Postgres handles concurrent connections fine on its own, but two pods writing screenshots to the same RWO PVC will not.

Keycloak setup

  1. In your realm, create a confidential client with id stateboard (or anything you set in KEYCLOAK_CLIENT_ID).
  2. Set the Valid redirect URI to https://stateboard.example.com/api/auth/oauth2/callback/keycloak.
  3. Set Web origins to https://stateboard.example.com.
  4. Copy the client secret into KEYCLOAK_CLIENT_SECRET.
  5. Set KEYCLOAK_ISSUER to the realm URL: https://keycloak.example.com/realms/<realm>.

That's it. StateBoard discovers all OIDC endpoints from the issuer's .well-known/openid-configuration.

Email-domain allowlist

By default, anyone Keycloak considers signed-in can join the workspace (subject to whatever the realm enforces). To narrow further:

STATEBOARD_ALLOWED_EMAIL_DOMAINS=acme.com,acme.de

Sign-ins from other domains see a "your email domain isn't allowed" error during the OIDC return.

First user becomes owner

The first user to complete sign-in becomes the workspace owner. Subsequent users land as editor by default. Change this with STATEBOARD_DEFAULT_ROLE=viewer if you want new sign-ins to be read-only until promoted.

The owner can change roles or remove members at /settings/members.

TLS: self-signed or internal CA certificates

At runtime StateBoard makes one outbound call: to your OIDC provider, for discovery and the token exchange during sign-in. If that endpoint presents a certificate signed by an internal/private CA, or a self-signed cert, Node rejects it and sign-in fails right after the Keycloak redirect. The server logs show a TLS error like UNABLE_TO_VERIFY_LEAF_SIGNATURE, SELF_SIGNED_CERT_IN_CHAIN, or unable to get local issuer certificate.

Fix it by trusting your CA, not by turning verification off. The runtime honors Node's standard NODE_EXTRA_CA_CERTS: point it at a PEM bundle and that CA is trusted for the call. Nothing else changes.

Provide the CA that signed the IdP's certificate. If the cert is truly self-signed (a leaf with no separate CA), use that certificate itself as the bundle.

Docker / Compose

Mount the CA read-only and point NODE_EXTRA_CA_CERTS at it, alongside your usual env:

# add to your `docker run` flags (or the compose service):
-v /path/to/internal-ca.pem:/etc/ssl/internal-ca.pem:ro
-e NODE_EXTRA_CA_CERTS=/etc/ssl/internal-ca.pem

Helm

Hand the chart your CA and it mounts it read-only and sets NODE_EXTRA_CA_CERTS for you:

# inline PEM file:
helm upgrade ... --set-file caCert.pem=internal-ca.pem

# or a Secret / ConfigMap you already manage:
helm upgrade ... --set caCert.existingSecret=corp-ca --set caCert.key=ca.crt

Last resort (insecure)

NODE_TLS_REJECT_UNAUTHORIZED=0

This turns off certificate verification for the entire process: it accepts any certificate, which defeats TLS and leaves the token exchange open to interception. Use it only to confirm the certificate is the cause, never in production. Trust the CA instead.

Airgap considerations

The runtime makes only one outbound call: to your configured Keycloak issuer (and only during sign-in). No telemetry, no analytics, no license server, no phone-home, no remote font loading. The docs search index is built locally with Orama.

If your IdP is inside the same network as StateBoard (which it almost certainly is), the deployment is airgap-clean.

Backups

Two things to back up:

  1. Postgres: every board, screen, region, member, and audit row.
  2. Uploads PVC (/data): the screenshot files themselves. Filenames are recorded in the DB, so the two backups must restore in lockstep.

A typical screenshot is 0.5 to 2 MB. A team running quarterly reviews on 10 products with ~10 screens each will use well under 1 GiB per year.

Upgrading

Helm:

helm dependency build deploy/helm/stateboard
helm upgrade stateboard ./deploy/helm/stateboard \
  --namespace stateboard --reuse-values \
  --set image.tag=NEW_VERSION

The migrate Job runs automatically before the new pods come up. If you disabled it (migrate.enabled=false), run pnpm migrate against the database manually first.

What we'll add later

  • v1.x: headless capture from URL, Jira issue linking.
  • v2: scheduled re-capture, time-travel / diff view, Slack notifications, Notion / Confluence embed.
  • v3: auto region-detection from the DOM, journey views, portfolio rollup, audit-log UI.

On this page