Configuration
Verified against v0.10.0 · cmd/parley/main.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
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 honoured
where the connection string says nothing, exactly as the driver honours 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.
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.
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 passcode
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 honouring.
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 arrives with PLUGIN_DIR. Every variable here is
optional, and an instance with PLUGIN_DIR unset never instantiates a
WebAssembly runtime at all.
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.
Rotating it makes every stored secret undecryptable — re-enter them.
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
- Unset means no plugin host runs; a bundle that is missing fails that plugin's calls, not the boot
The directory plugin bundles are read from, named <name>-<version>.wasm.
Leave it unset and no plugin host is created: the outbox and job workers keep their nil handlers, nothing is compiled, and no WebAssembly runtime exists in the process. Setting it is the decision to run plugin code.
A name or version containing a path separator or .. is refused rather than
cleaned.
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. Calls past the cap are refused rather than queued.
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.
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.
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’s wall may hold. Over the cap a give is a 409; a member withdrawing one of their own frees a slot.
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 passcode 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.