Environment variables
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Environment variables
Every env var Chatly understands — required, optional, and the long list of things that are NOT operator-set because they're BYOK per workspace.
The platform operator (you) sets a tiny number of env vars. Everything else — provider keys, integration tokens, IdP credentials, LLM keys — is BYOK at the workspace level, stored encrypted per workspace in the database, decrypted only at call time.
The point of this discipline: when you read your .env, you can be sure that every secret in there is yours, not someone else's customer's. If you leak it, the blast radius is your platform's operator key material — not 500 customers' Stripe keys.
Warning — MFA_ENCRYPTION_KEY is the most important secret you'll ever hold
This key seals every workspace's BYOK credentials. Lose it and you lose access to every customer's stored Twilio / LLM / IdP keys, and your customers will need to re-paste them. Back it up encrypted offline, store the backup in two different places.
Required (platform won't start without these)
# ── Postgres ─────────────────────────────────────────────────────
DATABASE_URL=postgresql://livechat_user:password@host:5432/livechat_prod
DATABASE_POOL_MAX=20 # per process; tune by service
# ── Redis 7+ ─────────────────────────────────────────────────────
REDIS_URL=redis://default:password@host:6379/0
# ── JWT signing (RS256) ──────────────────────────────────────────
# Generate once:
# openssl genrsa -out jwt.pem 2048
# openssl rsa -pubout -in jwt.pem -out jwt.pub
# base64 -w0 jwt.pem
JWT_PRIVATE_KEY_PEM_B64=<base64>
JWT_PUBLIC_KEY_PEM_B64=<base64>
# ── BYOK-credentials sealing key ────────────────────────────────
# 32 bytes, AES-256-GCM. Generate: openssl rand -base64 32
# DO NOT LOSE — see warning above.
MFA_ENCRYPTION_KEY=<32-byte base64>Required vars at a glance
Name | Type | Description |
|---|---|---|
|
| Pooler URL (pgbouncer) on prod. Direct connection on dev. |
|
| Include |
|
| RS256 private key. 2048-bit minimum. |
|
| Matching public key. Used to verify tokens across services. |
|
| AES-256-GCM key sealing per-workspace BYOK creds. |
Strongly recommended
JWT_ISSUER=chatly # appears in `iss` claim
JWT_ACCESS_TTL_SECONDS=900 # 15 min — short and sweet
JWT_REFRESH_TTL_SECONDS=2592000 # 30 days
LOG_LEVEL=info # warn|info|debug|trace
CORS_ORIGINS=https://app.chatly.example,https://chatly.example,https://help.chatly.example
# Platform-level admin API
ADMIN_API_KEY=<32-byte base64> # secret for /v1/admin/*
ADMIN_USER_IDS=<uuid>,<uuid> # operator staff allowed in admin console
# Public hostnames (used to build URLs)
API_HOST_FQDN=api.chatly.example
REALTIME_HOST_FQDN=ws.chatly.example
DASHBOARD_HOST_FQDN=app.chatly.example
HELP_HOST_FQDN=help.chatly.example
MARKETING_HOST_FQDN=chatly.example
# Optional second hostname for the marketing site. Traefik matches ONLY the
# names in its rule, so with just MARKETING_HOST_FQDN=chatly.example the www
# form has no router and answers 503 — which is exactly what happened on
# chatlychat.com. Set it if www resolves to this host. A separate variable
# rather than a `www.` prefix on the one above, so a deployment whose marketing
# host IS already www.something does not end up matching www.www.something.
MARKETING_WWW_FQDN=www.chatly.example
WIDGET_HOST_FQDN=widget.chatly.example
VOICE_HOST_FQDN=voice.chatly.example
PUBLIC_HOST_FQDN_BASE=chatly.example
# Internal service-to-service secrets. Required — see below.
INTERNAL_API_TOKEN=<48-byte base64>
INTERNAL_WORKER_SECRET=<48-byte base64>Danger — Do not publish the AI service
There is no
AI_HOST_FQDN, and you should not create one. The AI service is reached only over your internal Docker network, athttp://livechat-ai:4002, by the API, the worker and the voice service. Nothing in a browser calls it — the dashboard's AI features go through the API. Earlier versions of this page listed anAI_HOST_FQDNand the compose file published the service on it; that was a mistake, and if you set one previously, remove the hostname and the DNS record.
Required — internal service secrets
These two are not optional and not customer-facing. They authenticate your own services to each other, and a deployment without them either refuses to start or runs with an unauthenticated internal surface.
INTERNAL_API_TOKEN=$(openssl rand -base64 48)
INTERNAL_WORKER_SECRET=$(openssl rand -base64 48)INTERNAL_API_TOKEN is a bearer token presented on every hop between your services. It is the only authentication the AI service has: every one of its routes requires it, because those routes take a workspace id from the request body and charge that workspace's AI budget and BYOK provider credits. Without the token, anything that could reach the service could spend your customers' money. The AI service therefore refuses to boot in production if it is unset, rather than starting up unprotected.
INTERNAL_WORKER_SECRET is an HMAC key the worker signs outbound channel-send requests to the API with. The worker refuses to boot in production if it is unset.
Both must be at least 16 characters and identical on every service — set them once at the top of .env.prod and every container inherits them. Do not leave either one empty: an empty shared secret is not a weak secret, it is no secret, and both services reject an empty value rather than compare against it.
To rotate: set the new value, then restart all services together. There is no grace period — a mismatch between two services means the calls between them fail, so a rolling restart will drop AI requests and outbound channel sends for the duration.
Optional — your billing (only if you sell Chatly)
Only if you charge customers for Chatly subscriptions and you are the merchant of record:
STRIPE_API_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# One price id per paid plan. Create them with:
# STRIPE_API_KEY=... node scripts/seed-stripe.mjs # dry run
# STRIPE_API_KEY=... node scripts/seed-stripe.mjs --apply # creates them
STRIPE_PRICE_STARTER=price_...
STRIPE_PRICE_GROWTH=price_...
STRIPE_PRICE_BUSINESS=price_...
# OPTIONAL — annual billing at 20% off. Same script creates these yearly
# prices alongside the monthly ones. Leave all three unset to sell monthly
# only: the dashboard then shows no Monthly/Annual control at all, rather
# than offering a cadence checkout would refuse.
STRIPE_PRICE_STARTER_ANNUAL=price_...
STRIPE_PRICE_GROWTH_ANNUAL=price_...
STRIPE_PRICE_BUSINESS_ANNUAL=price_...There is no publishable key to set. Checkout runs as a server-side Stripe Checkout Session and the browser is redirected to Stripe's own page, so no Stripe key is ever handed to the frontend.
Set the annual three together or not at all. A partial set means one plan silently loses its annual option while the others keep theirs; the operator readiness page flags that as an error rather than letting it pass.
Self-hosted deployments without a billing offering can leave these unset — Chatly's plan-gating reads from a static config instead of Stripe.
Conversation caps are enforced by default
PLAN_CONVO_LIMITS_ENFORCED defaults to true. A workspace that has used its monthly conversation allowance is refused new conversations until the count resets at the start of the next calendar month; conversations already open are never cut off, and the workspace owner is emailed at 80% and at 100%.
PLAN_CONVO_LIMITS_ENFORCED=false # sell no plans? turn the cap off entirelySet it to false if you do not sell Chatly subscriptions, or during an incident where you suspect the usage meter itself. The structured plan_limit_check log line is emitted either way, so turning enforcement off costs you the refusals and not the telemetry. Anything that is not an explicit false / 0 / no / off counts as on.
Optional — shared object storage (Cloud only)
Only if you run a hosted offering with a single shared bucket. Self- host customers should leave this unset and let each workspace BYOK their own S3-compatible bucket:
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
S3_REGION=us-east-1
S3_BUCKET=chatly-prod
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_BASE_URL=https://cdn.chatly.example # for signed URLsIf unset, the platform falls back to MinIO (bundled in the docker- compose) for development, and refuses attachments in production.
Optional — continuous WAL archiving (point-in-time recovery)
Read by the pgBackRest scripts under infra/scripts/, not by the platform services. Setting them is what turns "restore last night's dump" into "restore to 16:11 yesterday". The full procedure — including the fact that archive_command runs inside the Postgres container, so enabling this means changing that container's image — is in Backups + disaster recovery.
Leave all of this unset and you still get the nightly logical dump. Set it and you get both; keep both, they fail differently.
# ── Required, if you want PITR at all ────────────────────────────────
PGBACKREST_S3_BUCKET=chatly-backups
PGBACKREST_S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
PGBACKREST_S3_KEY=<access key SCOPED TO THE BACKUP BUCKET>
PGBACKREST_S3_KEY_SECRET=<secret key>
# ── Everything else has a working default ────────────────────────────
PGBACKREST_STANZA=chatly # the repository's name for this cluster
PGBACKREST_REPO_PATH=/pgbackrest # prefix in the bucket; keep it off the dumps' prefix
PGBACKREST_S3_REGION=us-east-1
PGBACKREST_S3_URI_STYLE=path # path for MinIO/Ceph, host for real AWS S3
PGBACKREST_S3_VERIFY_TLS=y # n only against a local self-signed MinIO
PGBACKREST_RETENTION_FULL=4 # full backups kept — this IS the rewind window
PGBACKREST_COMPRESS_TYPE=zst # zst | lz4 | gz | none
PGBACKREST_ARCHIVE_TIMEOUT=60 # seconds; forces a WAL switch on an idle database
PGBACKREST_PG_PATH=/var/lib/postgresql/data
PGBACKREST_PG_USER=postgres
PGBACKREST_PG_DATABASE=postgres
PGBACKREST_PG_SOCKET_PATH=/var/run/postgresql
PGBACKREST_CONTAINER= # name of the Postgres container to drive from the host
PGBACKREST_RECOVERY_TIMEOUT=900 # seconds a restore waits for replay before giving upWarning — Scope the backup key to the backup bucket
PGBACKREST_S3_KEYends up in a file on the database host (/etc/pgbackrest/pgbackrest.conf, written 0600 and owned bypostgres). It grants write access to your backup repository, so give it a key that can reach nothing else. An attacker who can delete your backups has turned a recoverable incident into an unrecoverable one.
PGBACKREST_RETENTION_FULL is the one number people get wrong. pgBackRest expires the archived WAL belonging to an expired full backup, so this is not just "how many backups" — it is how far back you can rewind. With a weekly full and the default of 4, that is roughly 28 days.
Optional — voice
LIVEKIT_URL=wss://livekit.chatly.example
LIVEKIT_API_KEY=APIxxxxxxxx
LIVEKIT_API_SECRET=<48-byte base64>
# (no LIVEKIT_WEBHOOK_SECRET — LiveKit signs webhooks with the API key/secret pair)
LIVEKIT_SIP_TRUNK_URI=sip:[email protected]:5060
# Cobrowse TURN relay (optional; STUN-only without it)
TURN_URL=turn:turn.chatly.example:3478
TURN_SECRET=<shared secret, RFC 5766 short-term credentials>Generate the key + secret with openssl rand or the livekit-server generate-keys CLI. The same key must be configured in infra/livekit/livekit.yaml.
TURN_URL and TURN_SECRET are a pair — setting one without the other is rejected at boot. They are read by the API to mint short-lived visitor credentials for the cobrowse data channel, and are unrelated to LiveKit's own TURN, which is configured under turn: in infra/livekit/livekit.yaml rather than by an environment variable.
Optional — observability
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc:4318
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer ...
OTEL_SERVICE_NAME=chatly-api
OTEL_SERVICE_VERSION=0.42.0
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1 # sample 10% of root tracesOptional — the AI kill switch
AI_ENABLED=false # set on the `ai` AND `worker` servicesThe one env-var kill switch in the platform. Unset (or empty) means AI is on, which is the normal state — you do not need to set it to deploy. Setting it stops every outbound LLM, embedding and speech-to-text call the process would make, including background ones like summarisation and intent classification. Both the ai service and the worker call vendors directly, so set it on both or half your AI traffic keeps running.
It fails closed: only true/1/yes/on/enabled count as on once the variable is present, so a typo stops AI rather than silently leaving it running. Check checks.aiEnabled on the ai service's /health to confirm a rollout took the value.
Optional — first-run seeding
pnpm db:seed creates the demo workspace and its owner account. It reads two variables, and both have defaults, which is the part that matters:
[email protected]
SEED_ADMIN_PASSWORD=<something long>Unset, the seed creates [email protected] with the password ChangeMe123!. That account is a workspace owner. If you run the seed on a machine that is reachable from the internet and leave these unset, you have published a known-credential owner account — set both before seeding, or change the password immediately after. The seed is a no-op on a second run (it skips when the acme workspace already exists), so it will not silently re-create the account later.
Optional — public origins baked into outbound links
These are not used to route traffic. They are used to build absolute URLs that end up in emails, SMS bodies and third-party callbacks, where a relative or localhost URL is simply a dead link. Every one has a fallback, and every fallback is either localhost or our hosted domain — so leaving them unset on a self-hosted deployment does not break the boot, it sends your customers somewhere that is not you.
HELP_CENTER_PUBLIC_URL=https://help.chatly.example
MARKETING_URL=https://chatly.example
MARKETING_PUBLIC_URL=https://chatly.example
STATUS_PUBLIC_URL=https://chatly.example
CHATLY_API_URL=http://livechat-api:4000
VOICE_PUBLIC_BASE_URL=https://voice.chatly.example
API_PUBLIC_BASE_URL=https://api.chatly.example
NEXT_PUBLIC_API_HOST=https://api.chatly.example
NEXT_PUBLIC_PORTAL_URL=https://portal.chatly.example
NEXT_PUBLIC_KB_PUBLIC_ID=<a channel public id>Variable | Read by | Unset behaviour |
|---|---|---|
| worker (survey sends, | Survey and form links are built against |
| worker (GDPR export email) | The one-time export download link is built against |
| worker (status notifications) | Second choice after |
| worker (status notifications) | Unsubscribe links fall through to |
| marketing app, | Defaults to |
| voice service, API | The externally reachable origin Twilio is handed for queue / hold-music / IVR / voicemail callbacks. Twilio signs the full URL, so an internal address fails signature verification. Falls back to the forwarded |
| API (outbound voice) | Second choice after |
| customer portal (server-side proxy, login, KB search) | Defaults to |
| customer portal (sign-out redirect) | Sign-out redirects to |
| customer portal (KB search) | Unset, the portal KB tab renders an explanatory empty state and never searches. It wants a channel public id, the same value the widget snippet uses. |
Optional — secrets that fall back to something weaker
Each of these has a fallback that keeps the platform booting. None of the fallbacks is a per-process random value, so "it still works" is not the same as "it is still secure".
STATUS_UNSUBSCRIBE_SECRET=<32-byte base64>
SIDE_CONV_SIGNING_SECRET=<32-byte base64>
DASHBOARD_APP_TOKEN_SECRET=<32-byte base64>STATUS_UNSUBSCRIBE_SECRET HMACs status-page unsubscribe links. It must be identical on the API and the worker — the API mints the token into the queued job, the worker's link is verified back at the API — so a mismatch makes every unsubscribe link fail closed. Minimum 16 characters. Unset, both sides fall back to the constant dev-status-unsubscribe-secret, which is compiled into the open-source bundle: anyone can then mint a valid unsubscribe token for any subscriber id. Set it in production.
SIDE_CONV_SIGNING_SECRET signs the side-conversation id baked into outbound ticket email Message-IDs, so inbound replies route back without a forgeable id. Minimum 16 characters. Unset, it derives a secret from your JWT private key (or, failing that, from JWT_ISSUER) — not published, but it couples two unrelated rotations: rotating JWT keys silently orphans every in-flight side conversation. Set it explicitly in production.
DASHBOARD_APP_TOKEN_SECRET signs the short-lived tokens handed to embedded marketplace dashboard apps. Minimum 32 characters, and deliberately optional — it is needed only once a workspace installs an app that declares an embedded surface, and the API returns a 503 at that moment rather than refusing to boot. A deployment with no marketplace apps can leave it unset.
Optional — recording and generated-asset storage (R2 / S3-compatible)
Separate from the S3_* block above. S3_* is the shared attachment bucket; R2_* is where three specific producers put bytes they generate: voice call recordings and voicemail, synthesised TTS audio for the voice_play_audio workflow action, and PDFs from generate_pdf.
R2_BUCKET=chatly-media
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_ACCOUNT_ID=<cloudflare account id>
R2_ENDPOINT=https://s3.example.com
R2_REGION=auto
R2_PUBLIC_BASE_URL=https://media.chatly.exampleR2_BUCKET, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are all required together, plus one of R2_ENDPOINT (any S3-compatible host — MinIO, B2, Cloudflare R2) or R2_ACCOUNT_ID (shorthand that expands to https://ACCOUNT.r2.cloudflarestorage.com). R2_ENDPOINT wins if both are set. R2_REGION defaults to auto. R2_PUBLIC_BASE_URL, when set, is prefixed to the object key to form the returned URL — set it if the bucket is public or behind a CDN; leave it unset and the raw endpoint URL is returned instead.
Miss any required one and the config resolves to nothing at all — there is no partial mode. Every producer then falls back to inlining the bytes as a data: URL. Nothing errors, nothing is logged above info, and the consequence is size: a call recording or a multi-page PDF as a data URL is a row in Postgres, not an object in a bucket.
Optional — calendar sync (Google / Outlook)
Meeting booking reads a connected agent's free/busy. The OAuth client is the operator's (one app registration for your whole deployment); the per-agent tokens are stored per workspace.
GOOGLE_CALENDAR_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CALENDAR_CLIENT_SECRET=...
GOOGLE_CALENDAR_REDIRECT_URI=https://api.chatly.example/v1/calendars/sync/google/callback
OUTLOOK_CLIENT_ID=...
OUTLOOK_CLIENT_SECRET=...
OUTLOOK_REDIRECT_URI=https://api.chatly.example/v1/calendars/sync/outlook/callbackThe two _REDIRECT_URI values are overrides. Left unset, the callback is derived as API_PUBLIC_URL + /v1/calendars/sync/google/callback (or /outlook/callback), which is right for a normal deployment — set them only when the URL registered with the identity provider differs, e.g. because a gateway rewrites the path.
Without the client id and secret for a provider, connecting a calendar of that type fails with a misconfigured error at the moment an agent tries; the rest of meetings keeps working. Nothing fails at boot.
Optional — PDF rendering for the generate_pdf workflow action
Chromium is not in the worker image. The generate_pdf action posts { html, options } to an HTTP renderer and expects PDF bytes back. First choice is a per-workspace pdf_renderer integration; these two are the deployment-wide fallback for self-hosted installs where a per-workspace integration is overkill:
DOC_API_URL=https://pdf.chatly.example/render
DOC_API_KEY=<bearer token>DOC_API_KEY is optional even when DOC_API_URL is set — it is only sent as an Authorization: Bearer header if present. With neither the integration nor DOC_API_URL, the action throws misconfigured and the workflow run fails at that step. Every other step still runs.
Optional — voicemail transcription
AI_SERVICE_URL=http://livechat-ai:4002Read by the voice service only, to reach the AI service's /v1/audio/transcriptions. Voicemail speech-to-text goes through the AI service so it is cost-metered and quota-gated like every other AI call. Unset, voicemails are still recorded, stored and playable — they are simply never transcribed, and there is no error anywhere. There is deliberately no fallback to a direct, unmetered provider call.
Optional — the platform Meta app (WhatsApp embedded signup)
META_APP_ID=1234567890123456
META_APP_SECRET=<app secret from Meta app → Settings → Basic>
META_VERIFY_TOKEN=<any long random string>
META_WHATSAPP_CONFIG_ID=9876543210987654All four are optional, and a deployment that sets none of them still connects WhatsApp, Messenger and Instagram — every customer brings their own Meta app and pastes their own credentials on the channel install form. That is the default and it is not going away.
What these buy you is the other onboarding model. With all three of META_APP_ID, META_APP_SECRET and META_WHATSAPP_CONFIG_ID set, the WhatsApp install dialog offers Meta's embedded signup: the customer clicks one button, signs in to Facebook, picks or creates a WhatsApp Business Account, and never sees an app id, an app secret or a phone-number id. Your Meta app is the party onboarding them, which is why these are operator env vars and not per-workspace BYOK.
Platform Meta app
Name | Type | Description |
|---|---|---|
|
| Numeric App ID, Settings → Basic. Optional. No default. Unset: embedded signup is not offered and the OAuth-token exchange for Messenger/Instagram is unavailable; bring-your-own-app is unaffected. |
|
| The matching app secret. Required whenever META_APP_ID is set — boot fails otherwise. No default. Also the key that verifies |
|
| Any long random string, echoed back during Meta's webhook handshake. Optional. No default. Used only as a fallback when a channel has no |
|
| Facebook Login for Business configuration id (not the app id). Optional. No default. Unset: |
To create the configuration id: in your Meta app add Facebook Login for Business, open Configurations → Create configuration, choose the WhatsApp Business Onboarding login variation, and copy the resulting ID. It belongs to one app — a configuration id from a different app is rejected by Meta at the login dialog.
Warning — It fails closed, on purpose
There is no partial state. Set two of the three and embedded signup stays off and tells you which one is missing; the WhatsApp install dialog shows the bring-your-own-app form exactly as it does today. A button that appears and then fails inside Meta's popup is indistinguishable from a Meta outage, so it is never rendered unless the server has confirmed all three are present.
META_APP_SECRET is the one credential here that is genuinely yours and genuinely dangerous: it signs and verifies the webhooks of every customer onboarded through embedded signup on this deployment. It is never written to a channel row, never returned by any API response, and never logged — the capability endpoint returns only the app id and the configuration id, both of which Meta itself puts in the browser. Rotating it is an env change and a restart, not a data migration.
Optional — proxy trust and the platform API
API_TRUST_PROXY=1
PLATFORM_API_ENABLED=trueAPI_TRUST_PROXY decides which hops in front of the API may tell it the client's address. Unset — the normal case — the API trusts loopback and private address space only, which is correct both behind a reverse proxy on the same network and with no proxy at all. Set it to a hop count (API_TRUST_PROXY=1) or a comma-separated list of IPs/CIDRs when your topology needs something else. true, all and * are rejected at boot: they would let any caller pick its own client IP and defeat every IP-scoped rate limit. A bad value is a refusal to start, not a silent default.
PLATFORM_API_ENABLED defaults to false, and every /v1/platform/* route 404s until it is set. That surface provisions workspaces across tenants; it is off unless an operator turns it on at the deployment level, so a compromised admin session cannot enable it.
Optional — the visitor's country
GEO_COUNTRY_HEADER=CF-IPCountryUnset is a valid, fully working deployment. Leave it alone and every visitor's country is simply blank — the live-visitor list, the contact record and the workflow builder all render that exactly as they did before this setting existed. Nothing errors, nothing is half-filled, and no background job starts.
Chatly does not ship a GeoIP database and does not store visitor IP addresses. The address arrives on the request, is collapsed to a /24 (or /48) and HMAC'd into a device fingerprint, and is never written down — so there is nothing on disk to geolocate later, by us or by anyone who obtains a copy of your database. What we can do is read a country your own edge already worked out and keep the two letters.
If you run behind Cloudflare, this already works with no configuration: Cloudflare sets CF-IPCountry on every proxied request, which is the header Chatly reads by default. (Check Network → IP Geolocation is on in your Cloudflare dashboard — that toggle is what adds the header.)
Set GEO_COUNTRY_HEADER only if something else fronts you and uses a different name:
Edge | Header |
|---|---|
Cloudflare |
|
AWS CloudFront |
|
Fastly |
|
Google App Engine / IAP |
|
NGINX / Traefik GeoIP module | whatever you name it |
The value must be an ISO 3166-1 alpha-2 code. XX (Cloudflare's "unknown"), T1 (Tor exit node) and the ISO user-assigned ranges are discarded rather than stored — a visitor labelled XX in your inbox reads as a country, and a workflow condition on it is a rule nobody can explain.
Warning — A header is only as trustworthy as the hop that sets it
A country header is only evidence if the hop that sets it is the only way to reach your origin. Cloudflare overwrites
CF-IPCountryon every proxied request, and Chatly's own deployment additionally firewalls 80/443 to Cloudflare's published ranges — both halves are needed. If your origin is reachable directly, a visitor can send the header themselves and choose their own country. Nothing security-relevant reads it (it is display, filtering and routing only), but do not build an access rule on it.
A country a customer supplies always wins over the derived one. If you map a CRM field onto country, set it through the Contacts API, pass it to identify(), or collect it on a pre-chat form, that value is what agents and workflows see; the header only ever fills a gap.
Optional — raw-PEM JWT keys
The documented form is base64 (JWT_PRIVATE_KEY_PEM_B64 / JWT_PUBLIC_KEY_PEM_B64). The un-encoded form is also accepted, for secret stores that handle multi-line values:
JWT_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----"Supply one form or the other, not both. In .env files the base64 form is much less error-prone — a PEM's newlines have to survive the file, docker compose, and the container's environment intact.
Feature flags are not env vars
Everything else that can be switched on or off platform-wide lives in the database, not the environment — one row in feature_flags, with optional per-workspace overrides in workspace_feature_flags. The catalogue is FEATURE_GATES in packages/shared/src/feature-flags: campaign_sending, bulk_import, outbound_voice_calls, ai_spend_cap_enforcement, presence_aware_routing. Absence of a row means the shipped default, so a fresh install needs no flag rows at all.
Manage them from the dashboard rather than by hand; a flag row takes effect without a deploy, which is the point of it not being an env var.
What you DON'T set
These all live per-workspace, BYOK, encrypted with MFA_ENCRYPTION_KEY:
BYOK per-workspace (NOT operator env vars)
Name | Type | Description |
|---|---|---|
|
| Customer's Twilio account. Stored on the channel row. |
|
| Customer's LLM keys. Stored in |
|
| Customer's Meta apps + Page tokens. The |
|
| Customer's email provider. |
|
| Customer's mobile-app project credentials. |
|
| Customer's CRM / connector OAuth tokens. |
|
| Customer's IdP. Stored on the SSO connection row. |
|
| Hashed at rest. Customer's IdP holds the plaintext. |
|
| Hired-agent marketplace escrow accounts. |
Warning — If you find yourself wanting to set one of these at the operator level, stop
You're misreading the architecture. Each customer brings their own credentials. The operator-level
.envnever contains a secret that belongs to a downstream customer. See credential ownership.
Generating secrets quickly
# 32-byte base64 (good for MFA, ADMIN_API_KEY, webhook secrets)
openssl rand -base64 32
# 48-byte base64 (good for LiveKit secrets)
openssl rand -base64 48
# RS256 keypair (JWT)
openssl genrsa -out jwt.pem 2048
openssl rsa -pubout -in jwt.pem -out jwt.pub
base64 -w0 jwt.pem # → JWT_PRIVATE_KEY_PEM_B64
base64 -w0 jwt.pub # → JWT_PUBLIC_KEY_PEM_B64Validation
On startup, every service validates its required env vars via zod schemas in apps/{service}/src/config/env.ts. A missing required var fails fast with a structured error like:
EnvError: missing required env var MFA_ENCRYPTION_KEY (refusing to start)Misconfiguration won't silently degrade — it'll refuse to boot, which is the right failure mode.
Rotation
JWT keys: rotate by adding the new key alongside the old in
JWT_PUBLIC_KEY_PEM_B64_NEXT; we accept both during overlap.MFA_ENCRYPTION_KEY: rotate by setting
MFA_ENCRYPTION_KEY_NEXT; run thepnpm cli reencrypt-byokjob; remove the old key.ADMIN_API_KEY: rotate by setting a new value and rolling pods; no overlap needed (revokes immediately).
See Backups for the full key-management runbook.