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 devOpen http://localhost:3000, click Continue with Keycloak, sign in as alice. The first sign-in becomes the workspace owner.
Production: Docker
The shipped image only contains the Node app — bring your own Postgres and IdP.
docker run --rm \
-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:1.0.0Schema is managed by pnpm migrate (or node scripts/migrate.mjs) — run it once after bringing up Postgres, and on every upgrade.
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 -base64 16)"The chart bundles a Bitnami Postgres sub-chart by default. A pre-install / pre-upgrade Job runs pnpm migrate before any pods come up.
To use an external Postgres:
--set postgresql.enabled=false \
--set externalDatabaseUrl=postgres://user:pass@db.example.com:5432/stateboardOr 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
- In your realm, create a confidential client with id
stateboard(or anything you set inKEYCLOAK_CLIENT_ID). - Set the Valid redirect URI to
https://stateboard.example.com/api/auth/callback/keycloak. - Set Web origins to
https://stateboard.example.com. - Copy the client secret into
KEYCLOAK_CLIENT_SECRET. - Set
KEYCLOAK_ISSUERto 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.deSign-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.
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:
- Postgres — every board, screen, region, member, and audit row.
- 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–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_VERSIONThe 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.