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, andpgcryptois only a trusted extension from 13 onward — any role holdingCREATEon the database can install it there. Below PostgreSQL 13, installing it needs superuser, and boot ends atFATAL: 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
CREATEon the database (GRANT CREATE ON DATABASE parley TO parley;) — not ownership. There is no bootstrap step and noCREATE DATABASEanywhere 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:
hostNetworkpods. They use the node’s address, not a pod IP, so anamespaceSelectororpodSelectornever 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 webhooks
Section titled “Standup webhooks”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.
Plugins
Section titled “Plugins”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:
- Roll out
PLUGIN_SECRET_KEY= the old key andPLUGIN_SECRET_KEY_PREVIOUS= the new key to every replica. - Roll out
PLUGIN_SECRET_KEY= the new key andPLUGIN_SECRET_KEY_PREVIOUS= the old key to every replica. - Once that rollout is complete, run
parley secrets resealonce, with the same environment (in Kubernetes, a one-off Job orkubectl 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. - Remove
PLUGIN_SECRET_KEY_PREVIOUSonly after a run that exits 0 and logssecrets reseal finishedwithremaining0. 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.
Abuse and resource limits
Section titled “Abuse and resource limits”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.
LINK_REDEMPTION_IP_HOURLY_LIMIT
- 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.
LINK_LIMIT_PER_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.
Sign-in
Section titled “Sign-in”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.
Variables that do not exist
Section titled “Variables that do not exist”Worth stating, because they are natural guesses:
ALLOWED_ORIGIN— there isn’t one. The allowed origin is derived fromBASE_URL.- Pool size, statement timeout, room-code throttle tuning — fixed. See
Limits and defaults. Session lifetime is
not on this list: see
SESSION_IDLE_TTLandSESSION_MAX_TTLabove. - TLS certificate paths — Parley never terminates TLS. Use a reverse proxy.
TEST_DATABASE_URL
Section titled “TEST_DATABASE_URL”Read by the test suite only, never by the binary. It points at a throwaway database; the tests migrate and truncate it.