Formbricks v6 makes SpiceDB the sole authorization decision engine and has no runtime legacy fallback.
PostgreSQL remains the relationship source of truth, with durable outbox delivery into SpiceDB. Existing
installations must complete the release-matched preparation and read-only gate before upgrading; a healthy
connection alone is not sufficient.
Understand the dependency
Formbricks writes projection intent in the same PostgreSQL transaction as the source mutation. BullMQ wakes the
delivery worker, but PostgreSQL is the durable queue. A failed delivery is retried idempotently and a stale or
dead-lettered revocation makes protected operations fail closed. A six-hour audit repairs attributable missing
or mismatched edges; operators must investigate state that cannot be repaired safely.
The general Formbricks
/health endpoint, application startup, and Kubernetes readiness and liveness probes
do not depend on SpiceDB. This prevents a SpiceDB outage from restarting unrelated Formbricks workloads. It
also means /health alone cannot prove that authorization data is healthy.
There is no browser health or administration page. Use the release-matched operator commands below.
Configure AuthZed
AUTHZED_ENABLED and AUTHZED_INSECURE accept true, false, 1, and 0. The endpoint must contain an
explicit port and no scheme, path, query, credentials, or whitespace. Examples include spicedb:50051,
grpc.authzed.com:443, and [::1]:50051.
Use AUTHZED_INSECURE=false for AuthZed Cloud or any endpoint outside a trusted private network. Plaintext
gRPC transmits the preshared token without TLS protection. Changing AuthZed configuration requires restarting
the Formbricks process.
Generate independent credentials with:
0600 .env, Docker secret, Kubernetes Secret, or external secret manager. Never put
the token in a NEXT_PUBLIC_* variable, command output, documentation, or source control.
See Environment Variables
for the complete validation contract.
Operate Docker and one-click installations
The released Compose stack runs one SpiceDB instance using a dedicatedspicedb database and login in the
bundled PostgreSQL container. Startup is ordered as follows:
SPICEDB_IMAGE_REF. SpiceDB is reachable only as spicedb:50051 inside the Compose network.
authzed-initialize is an idempotent, one-shot service that prepares fresh installations. Formbricks does not
depend on it, so application startup and /health remain independent from SpiceDB. The authzed-ops profile is
a short-lived operator container using the same release image and never starts during a normal Compose run.
AUTHZED_TOKEN and AUTHZED_DATABASE_PASSWORD, prepare the graph, and
run the read-only gate before reporting success. The update command preserves customized Compose files. An older
installation must merge the released AuthZed services and environment manually, then explicitly acknowledge the
v6 migration. The updater refuses to stop the existing application until upgrade prepare and upgrade check
succeed.
The bundled PostgreSQL service leaves track_commit_timestamp=off, so the SpiceDB Watch API is disabled. This
does not affect schema operations, relationship projection, permission checks, or the repair workflow.
Operate Kubernetes and Helm installations
The Formbricks chart supports two modes:authzed.mode=selfHostedcreates aSpiceDBClusterand points Formbricks at its private Kubernetes Service.authzed.mode=externalconfigures the Formbricks client for AuthZed Cloud or another external SpiceDB.
authzed.operator.install=false. Apply the matching SpiceDB CRDs before upgrading
the operator because Helm does not upgrade CRDs during a normal release upgrade.
Production deployments should use a dedicated PostgreSQL database and role. Provide a Secret containing
datastore_uri and preshared_key, then reference it through authzed.datastore.existingSecret and
authzed.auth.existingSecret. Require sslmode=require, verify-ca, or verify-full for managed PostgreSQL.
Bootstrap the SpiceDB role and database
A short-lived Job creates the dedicatedspicedb role and database before SpiceDB starts. It creates each only
when it is absent, so re-running it against an already initialised database is safe — but not inert: the role’s
password is reconciled to the chart’s Secret on every run. Rotate that password outside Helm and the next
upgrade will set it back, so update the Secret alongside it.
By default it connects as the postgres superuser the bundled PostgreSQL subchart creates. An existing
PostgreSQL installed with postgresql.auth.enablePostgresUser=false has no such role, and the upgrade fails
until the Job is told which role to use instead:
CREATEROLE and CREATEDB; it does not need to be a superuser. adminPasswordSecretName is
required whenever adminUsername is overridden, and adminPasswordKey whenever that Secret is configured
explicitly. Both are checked while the chart renders, so omitting one fails the render with the missing
value named — no Job is created. Without those guards the first would silently fall back to the bundled admin
password and the second would look up the subchart’s key name inside your own Secret, and neither shows up
until the Pod fails to start in the cluster.
CREATEROLE and CREATEDB are sufficient on their own only when the administrator also creates the
spicedb role — which is the normal case, including on re-runs. CREATE DATABASE ... OWNER spicedb
additionally requires the administrator to be able to SET ROLE to that owner, so the Job grants itself the
spicedb role before creating the database. From PostgreSQL 16 that grant is only possible for a role the
administrator holds ADMIN OPTION on, which it gets automatically by creating it.The one case this cannot repair is a spicedb role that already exists and was created by someone else,
on PostgreSQL 16 or newer: the administrator then holds no ADMIN OPTION on it and the Job fails rather than
silently skipping work. Grant it explicitly — GRANT spicedb TO fbadmin WITH ADMIN OPTION as a superuser or
as the role’s owner — or run the bootstrap as a superuser once. PostgreSQL 15 and older are unaffected:
CREATEROLE there carries authority over every non-superuser role.postgresql.auth.enablePostgresUser, or set authzed.bundledPostgresqlBootstrap.enabled=false — rather than
letting the Job fail inside the cluster. Disabling bootstrap remains the correct choice when the role and
database are provisioned by hand or by a platform team; the SpiceDB datastore_uri must then already point at
them.
For an externally managed PostgreSQL server, use authzed.externalPostgresqlBootstrap instead, which takes a
full administrator URL from a Secret and enforces TLS on it.
For an external AuthZed endpoint:
fully_consistent authorization are enabled by default. The operator runs SpiceDB datastore
migrations. A fresh Helm installation runs a release-matched, aggregate-only initialization Job after the
datastore and Formbricks migrations become available. Application startup and probes do not depend on this Job.
Existing releases never run it as an upgrade hook; they must use the explicit upgrade commands below.
The release notes print the exact deployment command. With the default release:
Upgrade an existing installation to v6
Formbricks v6 has no legacy authorization fallback. Do not deploy it over an installation whose graph has not been proven complete. First upgrade to the bridge-compatible v5 release named in the v6 release notes, enable durable projection, and take coordinated Formbricks and SpiceDB backups. Run the release-matched v6 image as the operator container or executable while the bridge release still serves traffic:prepare verifies configuration and datastore readiness, applies an empty or already-matching canonical schema,
drains the outbox, reconciles attributable relationships, and runs a final audit. If the remote schema is
non-empty and differs, first run schema check, review the diff and backup, then pass its exact remoteDigest:
check is read-only. It exits 0 only when AuthZed is enabled with fully_consistent, authenticated health is
good, the canonical schema matches, the outbox has no pending or dead-lettered work, no revocation has crossed a
warning threshold, and a complete dry-run relationship audit is clean. Exit 2 means the release remains
blocked; exit 1 means configuration or an operation failed. Output contains only aggregate counters and stable
error codes.
For one-click, set FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED=true in .env only after those commands pass.
For Helm, set authzed.migrationAcknowledged=true only in the v6 upgrade values. The chart refuses an upgrade
without it and refuses authzed.enabled=false or consistency weaker than fully_consistent.
Activate or upgrade SpiceDB
Use the following sequence for a new deployment, schema change, SpiceDB version change, or datastore restore.1
Back up both datastores
Create a PostgreSQL-consistent backup of the Formbricks database and the dedicated
spicedb database.
Record the deployed Formbricks and SpiceDB versions, configuration, and schema digest.2
Complete datastore migrations
Deploy the reviewed SpiceDB version and wait for
spicedb datastore migrate head to complete before the
new SpiceDB server starts. Never run migration and serving from different image references.3
Check connectivity
0. Disabled or unhealthy results exit 1.4
Check and apply the schema
0, drift exits 2, and an operational failure exits 1. Initialize an empty SpiceDB with:remoteDigest returned by the immediately preceding check:5
Audit and reconcile relationships
6
Run the release gate
0.7
Restart and observe
Restart Formbricks after environment changes. Recheck AuthZed health, schema status, projection metrics,
retry metrics, and SpiceDB logs.
docker compose --profile authzed-ops run --rm authzed-ops prefix. Kubernetes
examples use kubectl exec ... --. The command names and arguments after those prefixes are identical.
Roll back a v6 upgrade
Keep the exact bridge-compatible v5 image, Compose or Helm values, application database backup, SpiceDB backup, and schema digest until v6 acceptance is complete. Rollback means restoring that bridge image and its matching configuration; do not disable AuthZed inside a v6 image. Verify outbox delivery and a clean audit before resuming mutations. If either datastore was restored, use coordinated restore points or rebuild SpiceDB from PostgreSQL. The movinglatest tag remains on the bridge-compatible v5 release for at least 30 days after v6 stable is
published. Existing installations must select v6 explicitly during that window; this prevents an unattended
image pull from bypassing the migration gate.
Back up and restore
Back up the following together:- the authoritative Formbricks PostgreSQL database;
- the dedicated
spicedbPostgreSQL database; - AuthZed and datastore credentials in your secret manager;
- the deployed Formbricks and SpiceDB image versions;
- Compose or Helm configuration; and
- the schema digest reported by
schema check.
- Restore coordinated Formbricks and SpiceDB database backups from the same recovery point.
- Restore Formbricks PostgreSQL, initialize an empty compatible SpiceDB datastore, apply the release-matched schema, and rebuild the relationship graph with a full applying backfill.
Inspect and drain durable delivery
The v6 bridge writes projection intent in the same PostgreSQL transaction as an authorization source change. BullMQ wakes the delivery worker, but PostgreSQL remains the durable queue. Deletes, and updates that are not provably grants, are treated as revocations; direct authority fails closed when a revocation remains unresolved for 60 seconds or enters dead letter.status prints aggregate queue counts and ages only. It exits 0 when healthy, 2 at the 15-second warning or
45-second critical thresholds (and for any dead letter), and 1 for an operational failure. drain processes
revocations first and stops at the first batch that delivers nothing; a partially delivered batch is normal,
because a failure is charged only to the events it is attributable to. replay resets unresolved dead letters
so the normal idempotent reconcilers can retry them; investigate the cause before replaying. Dead-lettering
requires ten solitary failures carrying a code an event can actually cause, so no SpiceDB outage — unreachable,
rejected credential or internal error — produces one, and a dead letter always means PostgreSQL and SpiceDB
genuinely disagree. The six-hour audit
replays dead letters on its own after a clean run, which bounds a fail-closed denial at six hours.
These results contain no source IDs, relationship strings, credentials, or raw errors. This is different from
the detailed backfill report below, which intentionally contains operational identifiers.
Every six hours Formbricks also runs a full applying audit without prune. It automatically repairs attributable
missing and mismatched-permission relationships. It never deletes orphaned or unmanaged data and never repairs
a mismatched parent automatically.
Audit and repair relationships
The command prints one JSON result. It never prints credentials, database passwords, raw SDK errors, schema text, or raw relationship strings. It does print identifiers, deliberately: naming the affected records is what makes a drift report actionable. Most are Formbricks record IDs, but not all —unmanaged can surface object IDs belonging to no Formbricks
record at all. Treat the result as sensitive operational data.
orphans,mismatchedParentsandmismatchedPermissionsname the records that disagree, carrying organization, user, workspace, team, API-key, feedback-directory and feedback-directory-assignment IDs.mismatchedPermissionsadds the expected and observed relation names alongside the record it names.unmanagedreports relationships outside the managed vocabulary as an object type, an object ID and a relation name. Those object IDs are not limited to the kinds above — anything else sharing the SpiceDB instance appears here.failurescarries an organization ID for each failed unit that has one, so even a failed run emits identifiers. It is empty when the read that would have identified the organization is what failed.lastOrganizationIdis the resume cursor: the last organization the sweep reached, ornullif it reached none.
--after-organization-id=<cuid> taken straight from it, so an opaque token would have to be stored and mapped
back somewhere to stay useful. It is also the one field that can name an organization with nothing wrong: every
other identifier here comes from a record or relationship that drifted or failed, while the cursor is simply
wherever the sweep stopped. Redact it on the same terms as the rest.
Redirecting to a file keeps the result out of terminal scrollback and out of CI logs that capture stdout. Set
umask 077 and remove any earlier export first. The umask governs file creation only, so under a typical
umask 022 a new backfill.json lands world-readable — and redirecting over one that already exists truncates
it while leaving its existing mode untouched:
--after-organization-id=<cuid> is recorded verbatim. On a shared or session-recorded host, read the cursor
from the saved result instead of retyping it.
Interpret exit codes
Start with a dry run
orphanScope: "known_resources". Only a complete deployment sweep can find relationships for resources that
no longer exist in PostgreSQL or parent edges pointing from another tenant’s resource.
Reconcile PostgreSQL state
lastOrganizationId. A null cursor means the run
reached no organization, so there is nothing to resume after — rerun the sweep from the start instead:
organizationId sits behind the cursor, and a resume
will not retry it. Rerun those explicitly, in addition to resuming from the cursor:
organizationId is not one of those: the read that would have identified the
organization is itself what failed, so there is nothing to target. Within a sweep those also set truncated —
rerun the sweep rather than a single organization.
Prune orphans
Pruning is the only mode that removes relationships observed only in SpiceDB. It requires every safeguard:--organization-id or --workspace-id instead of --scope=all when the known problem permits a narrower
repair. A complete prune requires a SpiceDB datastore dedicated to one Formbricks deployment because object
IDs are not currently namespaced by AUTHZED_SYSTEM_KEY.
The default destructive cap is 500 orphaned resources. --max-prune=<number> may lower but never raise it. If
the run observes more than the cap, it deletes nothing. Investigate a wrong endpoint, wrong database, partial
restore, or shared SpiceDB before proceeding.
Interpret drift categories
Diagnose failures
Formbricks AuthZed logs use
component="authzed", stable errorCode values, and bounded fields such as
operation, projection, status, retryable, attemptCount, grpcStatus, and durationMs. They must not
contain tokens, database credentials, schema text, raw SDK errors, relationship strings, or actor/resource IDs.
Monitor AuthZed
Enable the existing Formbricks Prometheus or OTLP metrics exporter. The release-matched direct-authority signals are:
Starting PromQL checks:
pgxpool_empty_acquire, dispatch load, cache behavior, and datastore latency. Divide the datastore
connection budget across replicas and their read/write pools.
Use the incident checklist
- Confirm Formbricks
/healthindependently. - Run
formbricks-authzed health. - Check SpiceDB pods or containers and the last datastore migration.
- Check Formbricks projection failures, AuthZed terminal errors, retries, and latency.
- Restore SpiceDB or its datastore without changing authorization data manually.
- Run
schema check. - Run a full dry-run backfill.
- Apply repair and, only when justified, a guarded prune.
- Require a clean result before relying on SpiceDB authorization decisions.
- Preserve the sanitized command result, timeline, versions, and root cause for follow-up.