Skip to content

Configuration

Verified against v0.21.0 · cmd/parley/main.go, web/embed.go, internal/store/kudos.go, cmd/parley/configfile.go, internal/db/tls.go, internal/api/client_address.go, internal/api/secevent.go, internal/api/standup_webhook.go, internal/api/embed.go, internal/plugin/secrets.go, internal/plugin/bundles.go, internal/plugin/runtime.go, internal/api/pluginframe.go

Configuration is environment variables, optionally backed by a file that fills the gaps (PARLEY_CONFIG_FILE, below). There is no command-line flag other than -healthcheck, which the container image uses for its own HEALTHCHECK.

Parley is deliberately fail-fast: most variables stop the process on a bad value rather than falling back to a default, and every fatal exit prints a plain sentence naming the cause. A container that will not start is easier to diagnose than one running with configuration you did not intend.

The boot log echoes what the process actually resolved — read it to confirm.

DATABASE_URL

Default
none
Required
Yes
Bad value
Refuses to start, with a named reason on stderr

The Postgres connection string, e.g. postgres://parley:secret@db:5432/parley. Always required, in both auth modes. Parley makes up to six connection attempts at startup with doubling backoff between them — roughly a minute — and then exits with FATAL: could not connect to Postgres, so racing a database container is fine but an unreachable one is a CrashLoopBackOff.

TLS to the database is a control, and Parley refuses to run without it. The driver’s own default is sslmode=prefer, which negotiates TLS if the server offers it and silently falls back to plaintext if it does not — so an absent sslmode, or disable, allow or prefer, stops the process unless DATABASE_ALLOW_PLAINTEXT=true. require, verify-ca and verify-full are accepted; the boot line echoes the resolved mode as db_sslmode. For CUI or any data you would not put on the wire, the default is ?sslmode=verify-full&sslrootcert=/path/to/ca.pem — require encrypts but never checks the server’s certificate, and is warned about at boot.

A connection string that sets the same parameter twice — say ?sslmode=disable&sslmode=verify-full — stops the process, naming the repeated key. The driver keeps the first value and a reader naturally keeps the last, so there is no reading of it that is both honest and safe; write the parameter once.

What Parley expects on the other end:

  • PostgreSQL 13 or newer. The first migration runs create extension if not exists pgcrypto, and pgcrypto is only a trusted extension from 13 onward — any role holding CREATE on the database can install it there. Below PostgreSQL 13, installing it needs superuser, and boot ends at FATAL: database migration failed.
  • The database and the role must already exist. Parley creates its schema, never the database or the role, and the role named here needs CREATE on the database (GRANT CREATE ON DATABASE parley TO parley;) — not ownership. There is no bootstrap step and no CREATE DATABASE anywhere in Parley.

On Kubernetes this whole string is the value of one secret key — default database-url, set by database.secretKey. A secret split into username / password / host keys leaves the pod in CreateContainerConfigError. See Kubernetes.

DATABASE_ALLOW_PLAINTEXT

Default
false
Required
No
Bad value
Refuses to start, with a named reason on stderr

Accept a DATABASE_URL whose sslmode is absent, disable, allow or prefer — that is, one that may talk to Postgres in the clear. Must parse as a boolean; anything else stops the process.

Only reach for it when the database is on a link you already trust end to end: the bundled docker-compose.yml sets it because both containers sit on one host-local bridge network. On Kubernetes, across a VPC, or against managed Postgres, set sslmode on the connection string instead.

The libpq environment fallbacks PGSSLMODE and PGSSLROOTCERT are honored where the connection string says nothing, exactly as the driver honors them — so a PGSSLMODE=verify-full deployment is not falsely refused.

BASE_URL

Default
http://localhost:8080
Required
No
Bad value
Refuses to start, with a named reason on stderr
Derives
Cookie Secure flag · WebSocket and cross-site origin check · OIDC redirect URI

The address people actually use to reach Parley, with scheme and host. It must parse, or the process exits.

This is the single most consequential setting. An https scheme is what turns on the session cookie’s Secure flag; the scheme and host together form the origin that the WebSocket and the cross-site guard compare against; and the OIDC redirect is <BASE_URL>/auth/callback.

Get it wrong and the symptoms look unrelated to configuration: boards stuck at “reconnecting”, logins that do not stick, sign-in landing on the wrong host.

When BASE_URL is set explicitly, the app page also carries it as its og:url and canonical link, and names <BASE_URL>/og.png as its share image with a large-image card, so a pasted link previews under the instance’s own address with a picture. Left at the default, all of them are omitted rather than advertise localhost, and the preview is the text-only kind.

PORT

Default
8080
Required
No
Bad value
Ignored

The listening port. Combined with BIND_ADDR (empty by default) this is the listen address: :8080 on every interface, or 127.0.0.1:8080 if you set BIND_ADDR=127.0.0.1.

BIND_ADDR

Default
none
Required
No
Bad value
Refuses to start, with a named reason on stderr

A bare host or IP address to bind, with no port — the port is PORT. Empty (the default) listens on every interface. Set 127.0.0.1 to keep a bare-binary install on loopback. IPv6 literals are accepted (BIND_ADDR=::1 listens on [::1]:PORT). A host:port form is refused.

This is not a Helm chart value. Kubernetes pods bind all interfaces; NetworkPolicy is the control. Setting it in extraEnv would hide the process from probes and the Service.

-healthcheck still probes 127.0.0.1 when this is empty, 127.0.0.1, or localhost; otherwise it probes BIND_ADDR.

LOG_LEVEL

Default
info
Required
No
Bad value
Refuses to start, with a named reason on stderr

One of debug, info, warn, error. Anything else stops the process rather than silently defaulting. Output is JSON on stdout.

METRICS_ENABLED

Default
false
Required
No
Bad value
Refuses to start, with a named reason on stderr

Whether to serve an unauthenticated Prometheus exposition at /metrics. Must parse as a boolean; anything else stops the process.

Off by default. When on, the route is public to anyone who can reach the process — there is no session check and no second listen port. Scrape it from the cluster network, keep it off any public ingress, and admit only the scraper’s pods with networkPolicy.extraIngress. NetworkPolicy cannot filter by path, so the rule admits those pods to port 8080 as a whole. See Observability.

EMBED_PROVIDERS

Default
(empty)
Required
No
Bad value
Refuses to start, with a named reason on stderr

Comma-separated meeting clients Parley may run inside. The only one today is meet (Google Meet). Empty turns the embedded session off: every /embed/* and /api/embed/* route answers 404 and bearer tokens are never read. An unknown name, a provider missing its own setting, or a BASE_URL that is not https stops the process. See Embedded sessions.

MEET_CLOUD_PROJECT_NUMBER

Default
(empty)
Required
No
Bad value
Refuses to start, with a named reason on stderr

The Google Cloud project number of your Meet add-on. Required, as digits, when EMBED_PROVIDERS includes meet; ignored otherwise. See Google Meet add-on.

TRUST_PROXY_HEADERS

Default
false
Required
No
Bad value
Refuses to start, with a named reason on stderr

Whether to derive a client address from X-Forwarded-For. Must parse as a boolean.

Set it to true if and only if a proxy you control sets the header itself. It is wrong in both directions:

  • A client-reachable network is trusted — callers can forge their address and defeat address-based room-code and identity-creation throttles.
  • Behind a proxy and false — every request appears to come from the proxy, so the whole internet shares one throttle bucket and eight bad guesses lock everyone out of a space.

Both shipped manifests default it to false. X-Real-IP and True-Client-IP are never used.

TRUSTED_PROXY_CIDRS

Default
none
Required
When TRUST_PROXY_HEADERS=true
Bad value
Ignored when proxy trust is false; otherwise refuses to start

Required when TRUST_PROXY_HEADERS=true; otherwise ignored. A comma-separated allowlist of every immediate and intermediate proxy hop, such as 127.0.0.1/32,10.20.0.0/16.

Parley walks X-Forwarded-For right-to-left through those trusted hops and uses the first untrusted address. It never reads the leftmost value, so a proxy that appends to the header rather than overwriting it is fine. It ignores malformed chains and forwarded headers from an untrusted immediate peer. The same allowlist is what may supply an inbound X-Request-Id; a peer outside it, or a value that is not 128 bytes of printable ASCII, is discarded and Parley generates an id. See Observability.

0.0.0.0/0 and ::/0 are refused at startup. No real topology has an all-of-the-internet proxy hop, and a default route here is “trust the header from anyone” wearing a value. Nothing else is refused on width — see below for why a /16 has to keep working.

On Kubernetes, the only value that works is the pod CIDR — and it is client-reachable. The immediate peer Parley sees is the ingress controller’s pod IP, and pod IPs are reassigned on every reschedule, so listing individual addresses breaks on the controller’s next restart. That leaves the pod CIDR (10.42.0.0/16 on k3s, 10.244.0.0/16 on kubeadm with flannel, whatever your CNI hands out) — a network every workload in the cluster can reach. Any pod can open a connection straight to Parley’s Service, present its own X-Forwarded-For, and choose its client address: that is the room code throttle and the open-mode identity-creation quota, both bypassed.

So on Kubernetes this setting is only safe alongside something that stops other pods reaching the Service. The chart ships a NetworkPolicy for exactly that — networkPolicy.enabled=true, see Kubernetes. A service-mesh authorization policy or a dedicated node pool does the same job.

Two things a NetworkPolicy cannot cover, whatever you set:

  • hostNetwork pods. They use the node’s address, not a pod IP, so a namespaceSelector or podSelector never matches them.
  • Node IPs inside a trusted CIDR. Traffic sourced from a node arrives from the node address; if that address falls inside TRUSTED_PROXY_CIDRS, its forwarded header is trusted regardless of the policy.

And TRUSTED_PROXY_CIDRS only closes the hole at Parley’s boundary. Narrow your proxy’s own forwarded-header trust as well — Traefik entryPoints.*.forwardedHeaders.trustedIPs, ingress-nginx proxy-real-ip-cidr — or a forged header is simply accepted one hop upstream and arrives with the proxy’s blessing.

POD_NAME

Default
none
Required
No
Bad value
Ignored

Optional, and only useful on Kubernetes. Stamped on the presence rows this process writes, so a row that outlives its pod can be traced back to the pod that wrote it. Unset, Parley uses a random per-process id instead and nothing else changes. The chart fills it in from metadata.name.

PARLEY_CONFIG_FILE

Default
none
Required
No
Bad value
Refuses to start if the file is missing or a line is not KEY=value

Path to an optional file of KEY=value lines, merged under the environment: a variable already set in the environment keeps its value and the file only fills the gaps, so a container’s environment can always override a file baked into an image.

The syntax is deliberately tiny — blank lines, # comments, and KEY=value with optional surrounding whitespace and optional quotes around the value. There is no interpolation, no export, and no multi-line value. Naming a file that does not exist, or a line without an =, stops the process: a typo’d path that quietly ran on defaults is the failure fail-fast exists to prevent.

Leaving it unset is fine and is what every deployment in these docs does.

SESSION_IDLE_TTL

Default
2160h
Required
No
Bad value
Refuses to start if the value is not a positive Go duration

How long a session survives without activity. Any write or WebSocket connect renews the window; a GET never does, so a third-party page cannot keep a victim’s session alive with an <img src>.

CUI and other hardened deployments usually want this in hours rather than days — 8h ends a session left open overnight.

SESSION_MAX_TTL

Default
2160h
Required
No
Bad value
Refuses to start if the value is not a positive Go duration

The absolute lifetime of a session, measured from the moment its token was issued. No amount of activity extends it: a stolen token that keeps being used still stops working when this elapses, and the person has to sign in again.

Set it shorter than SESSION_IDLE_TTL and it is the cap that ends every session. The cookie’s Max-Age is the smaller of the two, so the browser never holds a cookie the server has stopped honoring.

Rows past either lifetime are deleted by a background pass that runs hourly, so lapsed credential hashes do not accumulate.

STANDUP_WEBHOOK_HOSTS

Default
none
Required
No
Bad value
Refuses to start if an entry is not a hostname with at most one leading *. label

The destination hosts a space owner’s standup webhook may point at, comma-separated, for example hooks.example.com,*.chat.example.org. A *. entry matches subdomains and not the apex, the same rule a plugin’s fetch allowlist uses. Unset, the default, allows no webhook at all: a space owner who configures one is answered 400.

Standup webhooks also need PLUGIN_SECRET_KEY, because the signing secret is stored encrypted with it. Without that key the webhook route answers 503 and no delivery runs.

Every delivery goes through the plugin fetch guard, so an allowlisted name that resolves to a private, loopback, link-local or metadata address is refused at delivery time, however it is spelled.

Parley’s plugin foundations — the event outbox, the job queue and plugin storage — are present from v0.6 onwards, and the WebAssembly host that runs plugins on top of them always runs. Every variable here is optional. The host compiles a module only when an enabled install is first called, so an instance with nothing installed never builds one.

What a plugin can and cannot do, and the mechanism behind each claim, is in Plugin sandbox.

PLUGIN_SECRET_KEY

Default
none
Required
No
Bad value
Refuses to start if the value is not base64 that decodes to exactly 32 bytes

The key plugin secrets are encrypted with at rest, base64-encoded and exactly 32 bytes when decoded. Generate one with openssl rand -base64 32.

Leave it unset and plugin secrets are simply unavailable: a plugin that asks for the secrets capability fails to install rather than storing its credentials in the clear. There is no plaintext column to fall back to.

Secrets are written in one of two forms. Until parley secrets reseal has finished successfully, they are written unbound, with no additional data and no key fingerprint, the form older versions read, so a rolling upgrade never leaves an older replica facing a row it cannot open; either form opens. A successful reseal converts every row to the bound form, sealed to where it lives (the plugin install and secret name, or the standup webhook’s space) and to a fingerprint of the key that sealed it, and records that it finished. Only from then does the binding hold: writes are bound, an unbound row no longer opens, and a row copied to another install, name or table does not decrypt.

A secret no configured key opens is reported as undecryptable, not as missing: a plugin reading it gets an error, and a standup webhook delivery fails. Rotating without PLUGIN_SECRET_KEY_PREVIOUS makes every stored secret undecryptable — re-enter them.

After upgrading from a version without key fingerprints, once every replica runs the new version, run parley secrets reseal once (below). Boot never rewrites a secret; it logs how many are not yet under the current key.

PLUGIN_SECRET_KEY_PREVIOUS

Default
none
Required
No
Bad value
Refuses to start if the value is not base64 that decodes to exactly 32 bytes, equals PLUGIN_SECRET_KEY, or is set without PLUGIN_SECRET_KEY

A second key, used only to decrypt, never to encrypt. It lets every replica read secrets under two keys while a rotation rolls out.

To rotate, in this order, so every replica can decrypt with the new key before any replica encrypts with it:

  1. Roll out PLUGIN_SECRET_KEY = the old key and PLUGIN_SECRET_KEY_PREVIOUS = the new key to every replica.
  2. Roll out PLUGIN_SECRET_KEY = the new key and PLUGIN_SECRET_KEY_PREVIOUS = the old key to every replica.
  3. Once that rollout is complete, run parley secrets reseal once, with the same environment (in Kubernetes, a one-off Job or kubectl exec). It re-seals every plugin secret and standup webhook secret that does not already open bound under the current key; each write is a compare-and-set on the ciphertext it read, so a secret changed while it runs is left alone. Then, holding both tables against writes, it checks every row and records that the binding holds only if every row passes. Running it again is safe.
  4. Remove PLUGIN_SECRET_KEY_PREVIOUS only after a run that exits 0 and logs secrets reseal finished with remaining 0. A run that fails, or finds a row that does not open bound under the current key, exits nonzero, records nothing and never logs that line; keep the previous key and run it again.

PLUGIN_EVENT_RETENTION

Default
168h
Required
No
Bad value
Refuses to start if the value is not a positive Go duration

How long a fully-delivered plugin event and its delivery rows are kept before a background pass prunes them. An outbox that never prunes is an unbounded table on a single-container deploy.

An event still waiting on a delivery is never pruned, however old it is. Deliveries that keep failing are dead-lettered after a bounded number of attempts, so a broken subscriber cannot pin the table open forever.

PLUGIN_DIR

Default
none
Required
No
Bad value
A bundle that is missing or refused fails that plugin's calls, never the boot

A directory of plugin bundles. At boot every *.parley file in it is verified against PLUGIN_TRUSTED_KEYS and stored in the database with its original bytes; a refused file is logged and skipped. One publisher holds each name and version: the first signed bundle wins, a different bundle for the same name and version is refused with an error in the log, and an unsigned bundle never displaces a signed one.

A plugin’s code, UI and slots come from one source: the stored bundle for its name and version (the signed one, if there is also an unsigned one). Every replica re-verifies the stored file against its own trust set before using it. A stored bundle that is not trusted is refused; it never falls back to a file.

Loose files named <name>-<version>.wasm (with .ui.js and .slots.json beside them) are legacy and unsigned. They are read only when no bundle at all is stored for that name and version, and a read logs a warning, at most once every few seconds per plugin version. Unset, only stored bundles are served; the plugin host runs either way.

A running host re-checks which bundle answers at most every 5 seconds, so a bundle stored by another replica replaces a loose file within that bound. Trust is checked on every call: a bundle whose key is no longer trusted stops running on the next call. The trust set is read at boot, so revoking a key means removing it from PLUGIN_TRUSTED_KEYS and restarting each replica. A bundle whose key is no longer trusted keeps its name and version, refused rather than replaced, so republishing under a rotated key needs a new version.

A name or version containing a path separator or .. is refused rather than cleaned.

PLUGIN_TRUSTED_KEYS

Default
none
Required
No
Bad value
Refuses to start if an entry is not base64 of a 32-byte Ed25519 public key

Comma-separated Ed25519 public keys, in base64 — the contents of a .pub file from parley plugin keygen. A .parley bundle is imported only if one of these signed it, and a stored bundle stops being served as soon as its key is removed from this list. Unset trusts no key.

PLUGIN_ALLOW_UNSIGNED

Default
false
Required
No
Bad value
Refuses to start unless the value is exactly true or false

Also accept unsigned .parley bundles, at import and when serving. Off by default; turning it off again stops unsigned bundles being served.

PLUGIN_CALL_TIMEOUT

Default
2s
Required
No
Bad value
Refuses to start if the value is not a positive Go duration

How long one plugin call may run before it is stopped. Enforced twice — by the call’s context and by the Extism manifest — because a hang on the path a room waits on is the failure that matters most.

PLUGIN_MEMORY_PAGES

Default
256
Required
No
Bad value
Refuses to start if the value is not a positive integer

The memory cap for one plugin instance, in 64 KiB WebAssembly pages. The default is 16 MiB. Enforced both by the wazero runtime and by the manifest.

PLUGIN_MAX_CONCURRENT_CALLS

Default
8
Required
No
Bad value
Refuses to start if the value is not a positive integer

How many plugin calls may be in flight across all installs at once. A room action or a state build past the cap waits, for up to twice PLUGIN_CALL_TIMEOUT; an event or a job past it is refused and retried.

A per-call timeout and a memory cap bound one call. Without this cap a plugin still exhausts the process by making many calls that are each individually well-behaved.

A room action also holds one database connection while it runs, to keep the room’s actions one at a time across replicas, so this is the most connections plugin actions can hold on one replica. The pool has ten. Whatever this is set to, room actions leave the last two free and answer 503 past that.

PLUGIN_MAX_CALLS_PER_PLUGIN

Default
2
Required
No
Bad value
Refuses to start if the value is not a positive integer

How many calls one install may have in flight, so one plugin cannot take the whole budget from the others. A room runs one action at a time, so this is also how many rooms of one plugin can be mid-action at once on a replica. An action or a state build past it waits, holding no database connection, for up to twice PLUGIN_CALL_TIMEOUT; an action is answered 503 if no call has finished by then. Events and jobs past the cap are refused at once and retried.

Sizing for interactive plugins: one action is two calls on the replica that received it, the action and one state build shared by everyone watching, and a call of a typical ceremony plugin takes a few milliseconds. Two slots therefore serve a few hundred actions a second for one plugin on one replica, and a burst waits in line instead of failing. Raise this if one plugin’s rooms regularly answer 503 the plugin is at capacity, or if its calls are slow; raise PLUGIN_MAX_CONCURRENT_CALLS with it when several plugins are busy at once, and keep that at or below the database pool size minus two. A 409 the room is busy means one room’s own actions queued for longer than twice the call timeout, which more slots do not fix: the plugin’s actions are slow.

PLUGIN_MODULE_CACHE_SIZE

Default
16
Required
No
Bad value
Refuses to start if the value is not a positive integer

How many compiled plugin modules stay resident. A module is compiled once when its plugin is enabled, evicted least-recently-used past this bound, and evicted outright when its plugin is disabled.

Every value below must be a positive integer. Invalid or zero values stop the process.

IDENTITY_IP_HOURLY_LIMIT

Default
10
Required
No
Bad value
Refuses to start, with a named reason on stderr

Open-mode identity creations allowed per verified client address per hour.

IDENTITY_GLOBAL_HOURLY_LIMIT

Default
500
Required
No
Bad value
Refuses to start, with a named reason on stderr

Open-mode identity creations allowed across the instance per hour.

Default
50
Required
No
Bad value
Refuses to start, with a named reason on stderr

Signed-link redemptions allowed per verified client address per hour. Redemption is charged against its own bucket rather than the open-mode one above, so a team behind a single office address can reach a link’s 25-redemption cap without being turned away. Keep it at or above 25. The instance-wide ceiling (IDENTITY_GLOBAL_HOURLY_LIMIT) still applies to every redemption.

SPACE_LIMIT_PER_IDENTITY

Default
50
Required
No
Bad value
Refuses to start, with a named reason on stderr

Maximum spaces one identity may create.

SESSION_LIMIT_PER_SPACE

Default
500
Required
No
Bad value
Refuses to start, with a named reason on stderr

Maximum sessions retained in one space.

DECK_LIMIT_PER_SPACE

Default
20
Required
No
Bad value
Refuses to start, with a named reason on stderr

Maximum saved card decks one space may keep. A deck is a template a session copies its cards from, so lowering this never affects a session already created.

KUDO_LIMIT_PER_SPACE

Default
500
Required
No
Bad value
Refuses to start, with a named reason on stderr

Maximum kudos one space may be given in any rolling 30 days (a fixed window). Over the cap a give is a 409; a kudo older than 30 days, or a member withdrawing one of their own, frees a slot. Storage is therefore bounded per 30 days, not for a space’s whole life: about 6,000 short rows a year at the default.

STORY_LIMIT_PER_SESSION

Default
500
Required
No
Bad value
Refuses to start, with a named reason on stderr

Maximum stories retained in one planning-poker session.

Default
20
Required
No
Bad value
Refuses to start, with a named reason on stderr

Maximum live signed links one room may hold at once. Revoked and expired links are never deleted but stop counting, so revoking one frees a slot.

WS_MAX_PER_TOKEN

Default
8
Required
No
Bad value
Refuses to start, with a named reason on stderr

Maximum live WebSocket connections one session token may hold on one replica. Several browser tabs are ordinary use; past the cap the upgrade is refused with a 429 before the socket is created. The count is per replica and per token, so a person with two devices has two budgets, and closing a tab frees a slot immediately.

AUTH_MODE

Default
open
Required
No
Bad value
Refuses to start, with a named reason on stderr

open for no accounts, oidc to require sign-in through an identity provider. Anything else stops the process. Fixed at boot.

Open mode is trusted-network-only. A public deployment needs a room code or an external SSO/authentication proxy plus ingress abuse controls.

See Authentication before switching a running instance — it signs everyone out.

OIDC_ISSUER

Default
none
Required
When AUTH_MODE=oidc
Bad value
Ignored in open mode; otherwise refuses to start
Derives
The discovery document at <issuer>/.well-known/openid-configuration

Required when AUTH_MODE=oidc. Must be an http or https URL with a host. Parley reads the issuer’s discovery document, so any conformant provider works and switching providers is a configuration change.

OIDC_CLIENT_ID

Default
none
Required
When AUTH_MODE=oidc
Bad value
Ignored in open mode; otherwise refuses to start

Required when AUTH_MODE=oidc.

OIDC_CLIENT_SECRET

Default
none
Required
No
Bad value
Ignored

Optional. Parley uses PKCE, so it works as a public client with no secret at all — which is the right choice if your provider offers it.

The Helm chart supports both: set auth.oidc.publicClient=true for a public registration, or auth.oidc.existingSecret for a confidential one. It refuses to render if you set neither, or both.

OIDC_SCOPES

Default
profile email
Required
No
Bad value
Ignored

Space-separated. openid is always prepended, so you never list it. email is requested only to derive a display name; no email address is stored.

OIDC_ORG_CLAIM

Default
groups
Required
No
Bad value
Ignored

Read only in OIDC mode. The id_token claim that carries the caller’s groups; Organizations and claim mapping covers the setup end to end. At each sign-in its values are matched against the claim value an admin registered on each org, and membership is granted for every match.

Matching is exact and case-sensitive. A value no org claims grants nothing and never creates an org.

The claim may arrive as a single string, as an array of strings, or not at all; all three are accepted, and a token without it simply maps to no org. Entries that are not strings are ignored.

Membership lags in both directions: the session cookie is long-lived, so a group added or removed at the provider reaches Parley at the person’s next sign-in. A membership an admin revoked is a tombstone and stays revoked no matter how often the claim arrives again.

Microsoft Entra replaces this claim with a _claim_names pointer once a user is in more than 200 groups. Parley does not follow that pointer, so such a user maps to no org and needs membership granted another way; the sign-in is logged with a warning.

PARLEY_DEFAULT_ORG_CLAIM

Default
none
Required
No
Bad value
Refuses to start if the default org is missing or the value is empty

Points the built-in default org at one of your provider’s groups, so a fresh instance has something for OIDC_ORG_CLAIM to match. Applied at boot, after migrations. Leave it unset once your orgs carry their own claim values.

PARLEY_BOOTSTRAP_ADMIN

Default
none
Required
No
Bad value
Refuses to start unless it is an issuer|subject pair

Grants admin of the default org to one identity the first time it signs in, written as issuer|subject — for example https://keycloak.example.com/realms/yourteam|8f2c….

It is the pair from the id_token rather than a user id because the account does not exist until that person first signs in. Ignored in open mode.

Without it a fresh instance is unusable: no org matches any claim, so nobody is an admin, and nobody can create the first org. It grants membership; it never restores one an admin revoked.

It works whether or not they already belong to the default org: an existing member is promoted to admin at their next sign-in. The one case it will not act on is a revoked membership, which stays revoked and logs a warning — undo the revocation first, then sign in again.

Worth stating, because they are natural guesses:

  • ALLOWED_ORIGIN — there isn’t one. The allowed origin is derived from BASE_URL.
  • Pool size, statement timeout, room-code throttle tuning — fixed. See Limits and defaults. Session lifetime is not on this list: see SESSION_IDLE_TTL and SESSION_MAX_TTL above.
  • TLS certificate paths — Parley never terminates TLS. Use a reverse proxy.

Read by the test suite only, never by the binary. It points at a throwaway database; the tests migrate and truncate it.