Skip to content

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.pemrequire 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 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:

  • 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 honouring.

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 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.

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’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.

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 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.

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.