API Docs

Environment variables

AdminUpdated Sep 19, 2026

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

DATABASE_URL

pg URL

Pooler URL (pgbouncer) on prod. Direct connection on dev.

REDIS_URL

redis URL

Include ?family=6 for IPv6 hosts.

JWT_PRIVATE_KEY_PEM_B64

base64 PEM

RS256 private key. 2048-bit minimum.

JWT_PUBLIC_KEY_PEM_B64

base64 PEM

Matching public key. Used to verify tokens across services.

MFA_ENCRYPTION_KEY

base64 32B

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, at http://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 an AI_HOST_FQDN and 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 entirely

Set 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 URLs

If 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 up

Warning — Scope the backup key to the backup bucket

PGBACKREST_S3_KEY ends up in a file on the database host ( /etc/pgbackrest/pgbackrest.conf, written 0600 and owned by postgres). 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 traces

Optional — the AI kill switch

AI_ENABLED=false                    # set on the `ai` AND `worker` services

The 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

HELP_CENTER_PUBLIC_URL

worker (survey sends, send_form), help-center

Survey and form links are built against https://help.chatlychat.com — Chatly's own hosted help centre, not yours. The link resolves, serves someone else's site, and the response never reaches your database. The help-center app itself falls back to http://localhost:5175.

MARKETING_URL

worker (GDPR export email)

The one-time export download link is built against http://localhost:5176. The subject line still says the export is ready; the link is dead from the recipient's machine.

MARKETING_PUBLIC_URL

worker (status notifications)

Second choice after STATUS_PUBLIC_URL for the status-page origin.

STATUS_PUBLIC_URL

worker (status notifications)

Unsubscribe links fall through to MARKETING_PUBLIC_URL, then to https://chatly.com. Recipients of your incident emails get an unsubscribe link pointing at Chatly's site.

CHATLY_API_URL

marketing app, /status page (server render)

Defaults to http://localhost:4000, which inside the marketing container is the marketing container. The status page renders its "status unavailable" state permanently.

VOICE_PUBLIC_BASE_URL

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 Host header, which is correct behind a trusted proxy and wrong otherwise.

API_PUBLIC_BASE_URL

API (outbound voice)

Second choice after VOICE_PUBLIC_BASE_URL; final fallback is http://localhost:3010.

NEXT_PUBLIC_API_HOST

customer portal (server-side proxy, login, KB search)

Defaults to http://localhost:4000, which inside the portal container is the portal container. Portal login, the /api/* proxy and portal KB search all fail. Note: docker-compose.prod.yml currently passes the portal NEXT_PUBLIC_API_URL, which no portal code reads — set NEXT_PUBLIC_API_HOST explicitly.

NEXT_PUBLIC_PORTAL_URL

customer portal (sign-out redirect)

Sign-out redirects to http://localhost:5176/login.

NEXT_PUBLIC_KB_PUBLIC_ID

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

R2_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/callback

The 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:4002

Read 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=9876543210987654

All 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

META_APP_ID

string

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.

META_APP_SECRET

secret

The matching app secret. Required whenever META_APP_ID is set — boot fails otherwise. No default. Also the key that verifies X-Hub-Signature-256 on webhooks for channels created through embedded signup.

META_VERIFY_TOKEN

secret

Any long random string, echoed back during Meta's webhook handshake. Optional. No default. Used only as a fallback when a channel has no webhookVerifyToken of its own; embedded-signup channels always mint their own, so this matters only for hand-configured ones.

META_WHATSAPP_CONFIG_ID

string

Facebook Login for Business configuration id (not the app id). Optional. No default. Unset: GET /v1/channels/whatsapp/embedded-signup/config answers available: false and names it, the dashboard renders no one-click button, and POST /v1/channels/whatsapp/embedded-signup refuses with a 409 naming it.

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=true

API_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-IPCountry

Unset 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

CF-IPCountry (default)

AWS CloudFront

CloudFront-Viewer-Country

Fastly

Fastly-Client-Country

Google App Engine / IAP

X-AppEngine-Country

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-IPCountry on 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

TWILIO_*

BYOK

Customer's Twilio account. Stored on the channel row.

OPENAI_API_KEY / ANTHROPIC_API_KEY

BYOK

Customer's LLM keys. Stored in integrations.

WHATSAPP_*, MESSENGER_*, INSTAGRAM_*

BYOK

Customer's Meta apps + Page tokens. The META_* vars above are the one deliberate exception, and only for the optional embedded-signup path — a customer's own WABA token is still stored per workspace, encrypted, on the channel row.

POSTMARK_*, SENDGRID_*, MAILGUN_*, SES_*

BYOK

Customer's email provider.

FCM_*, APNS_*

BYOK

Customer's mobile-app project credentials.

SLACK_*, HUBSPOT_*, SALESFORCE_*, ZAPIER_*

BYOK

Customer's CRM / connector OAuth tokens.

SAML / OIDC credentials

BYOK

Customer's IdP. Stored on the SSO connection row.

SCIM bearer tokens

BYOK

Hashed at rest. Customer's IdP holds the plaintext.

Stripe Connect account IDs

BYOK

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 .env never 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_B64

Validation

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 the pnpm cli reencrypt-byok job; 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.

Was this page helpful?