WarmblyDocs

Configuration reference

Every environment variable Warmbly reads, what it does, its default, and whether changing it needs a restart.

The environment is authoritative. Warmbly never lets a web form overwrite a setting your environment owns, so there is no precedence to reason about and no file that silently rewrites itself behind you.

That rule has three consequences worth stating before the tables:

  • Everything on this page is set in the environment of the running process. With Docker Compose that is the .env next to docker-compose.yml. With Kubernetes it is the pod spec or a secret. With a bare binary it is the shell or the systemd unit.
  • Most resolved values are visible, read only, in the admin panel under Instance > Configuration > Environment (http://localhost:5174/configuration?tab=environment on a stock install). Each row shows the variable name, the value Warmbly actually resolved, where it came from (env, default, derived or unset), and whether changing it needs a restart. That page is how you answer "is my variable actually being picked up", without reading source.
  • A handful of settings are stored in the database instead, because no environment variable owns them. They are listed in settings stored in the database and are the only settings editable from a browser.

Secrets are never returned by any API. The configuration page shows a sensitive key as set or unset plus a four character fingerprint of its value, which is enough to confirm that two services hold the same AUTH_SECRET without disclosing it to anyone.

The panel reads the backend, not the whole fleet

The configuration registry runs inside the backend process, so every value it shows is the value that process resolved. It does not reach into the realtime, tracking, consumer or worker containers, and it does not list variables only those services read: realtime service and tracking service are absent from the page entirely. When a value has to match across services, compare the fingerprints or read the other container's environment directly.

Seeing what is actually set

HowWhat you get
Instance > Configuration > Environment in the admin panelThe backend's entries with resolved value, source, group and restart requirement
make doctorThe health checks from a shell, including the configuration problems they detect
GET /admin/instance/configThe same list as JSON, behind the manage_settings admin permission

Anything flagged on Instance health links back to the section of this page that explains the fix.

An empty value in .env is not an empty value

docker-compose.yml reads this file as ${VAR:-default}, and Compose treats an empty assignment exactly like a missing one. KMS_LOCAL_MASTER_KEY= does not blank the key, it substitutes the published default. To leave something unset under Compose, comment the line out.

Deployment

VariableWhat it doesDefaultRestart needed
APP_ENVdev or prod. dev tolerates the published default secrets and turns on Gin debug logging. Set prod for anything other people can reachdevyes
DEPLOYMENT_MODEself_hosted or cloud. Picks the auth defaults in authentication; every one stays individually overridableself_hosted under composeyes
ALLOW_INSECURE_DEFAULTStrue lets the backend boot even when a secret still holds its published default. Only for a throwaway instanceunsetyes
GIN_MODEdebug or releasereleaseyes
ENV_LABELA label the admin panel shows next to the instance nameunsetyes (container start)
WARMBLY_CLOUD_URLThe Warmbly Cloud API a self-hosted instance links to for the hosted warmup pool (guide). Outbound HTTPS onlyhttps://api.warmbly.comno
INSTANCE_NAMEThe name shown on the Warmbly Cloud approval page when linking this instancethe hostnameno
WARMBLY_VERSIONVersion string reported to Warmbly Cloud on link requestsdevno
WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLStrue lets customer webhooks point at http:// and private addresses. Development only: it lets any workspace member make the backend reach into your internal networkfalseyes

prod does not mean cloud

APP_ENV=prod needs no cloud account. Error reporting and GeoIP lookups are used when configured and skipped with a logged note when they are not.

Secrets

Five values protect the whole instance. Compose ships a working default for each so a fresh clone boots with no configuration, and every one of those defaults is published in this repository, so they protect nothing.

VariableFormatWhat it protectsRestart needed
AUTH_SECRET32 characters or more, enforced at bootJWT and session signing. The realtime service reads the same value as JWT_SECRET and applies the same floor. Both refuse to start below it: a shorter HS256 key can be recovered offline from any token the service has issued. It also keys the warmbly-verify= values workspaces publish in _warmbly TXT records to prove a domain, so changing it changes every one of them: root redirects stop at their next check until the new value is publishedyes
INTERNAL_API_TOKENany random stringThe backend's /api/v1/internal/ routes, which workers and the tracking service authenticate againstyes
NODE_BROKER_TOKENany random stringThe routes that perform a privileged operation for the caller: opening a sealed data key, signing a blob operation, and minting a provider access token for a mailbox Warmbly Cloud manages. Optional, and falls back to INTERNAL_API_TOKEN. The same value has to be set on the control plane and on every node that calls those routes, or they answer 401. Worth setting in a split deployment, where the tracking and forms services are internet-facing and hold the shared tokenyes
SECRET_KEY_BASE64 characters or morePhoenix session signing in the realtime serviceyes
KMS_LOCAL_MASTER_KEYbase64, exactly 32 bytesThe root key that seals every per-organization data keyyes
CREDENTIALS_ENCRYPTION_KEYexactly 64 hex charactersMailbox credentials at rest: SMTP and IMAP passwords, and Gmail and Outlook OAuth access and refresh tokensyes

Generate real ones before anyone else can reach the instance:

cat >> .env <<EOF
AUTH_SECRET=$(openssl rand -base64 32)
INTERNAL_API_TOKEN=$(openssl rand -hex 24)
SECRET_KEY_BASE=$(openssl rand -base64 64 | tr -d '\n')
KMS_LOCAL_MASTER_KEY=$(openssl rand -base64 32)
CREDENTIALS_ENCRYPTION_KEY=$(openssl rand -hex 32)
APP_ENV=prod
EOF

make gen-key prints a single fresh KMS_LOCAL_MASTER_KEY if that is all you need.

APP_ENV=prod goes last. It is what turns a published default from a logged warning into a refusal to start, so an instance that gets prod before the other five will not boot.

Values that must be identical across services, because each service reads its own copy:

ValueRead byIf it drifts
AUTH_SECRET, seen by realtime as JWT_SECRETbackend, realtimeThe dashboard loads but never goes live: the websocket rejects every token
INTERNAL_API_TOKEN, seen by workers as ENCRYPTED_KEYS_WORKER_TOKENbackend, worker, trackingWorkers cannot fetch decryption keys and tracking cannot resolve click tickets. Both fail closed with 401
KMS_LOCAL_MASTER_KEYbackend, consumer, workerSealed data keys cannot be opened, so mailbox credentials stop decrypting
CREDENTIALS_ENCRYPTION_KEYbackend, workerStored SMTP and IMAP passwords and OAuth tokens stop decrypting, so no mailbox can send or sync

Only the backend refuses to boot on a published default

The secret check runs in the backend. The consumer and the workers start happily on a published default, so an instance can look healthy while one process is using a key anyone can read from GitHub. The secret_published_default check on Instance health is what catches it.

Back up the two encryption keys

KMS_LOCAL_MASTER_KEY and CREDENTIALS_ENCRYPTION_KEY seal every stored credential and every stored message body. Losing them is unrecoverable, and a database backup without them cannot be decrypted.

Addresses

Every emailed link (password reset, invitation, the first-run claim link) is built from APP_URL. Leave it unset and a self-hosted instance guesses the origin from CORS_ALLOW_ORIGINS or PUBLIC_HOST instead, and never falls back to the hosted service: a link nobody can open is a support ticket, while a working link to somebody else's dashboard carries a live reset token out of your deployment. Set it.

VariableWhat it doesDefaultRestart needed
APP_URLThe dashboard origin. The source of every emailed linkguessed from CORS_ALLOW_ORIGINS, then PUBLIC_HOSTno (read per request)
FRONTEND_BASE_URLAlternative name for the same value, read when APP_URL is unsetunsetno
API_PUBLIC_URLThe backend's public base. Frontends, blob URLs and the OIDC redirect derive from itderived from PUBLIC_HOST under composeyes
BACKEND_PUBLIC_URLThe backend base used in generated worker configurationfalls back to API_PUBLIC_URLyes
APP_ORIGINThe exact origin the mailbox OAuth callback page posts the authorization code back to. Only needed when the dashboard is served somewhere other than APP_URLderived from APP_URLyes
API_HOSTThe listen address0.0.0.0:8080yes
PUBLIC_HOSTCompose only. A hostname or LAN IP that every other URL derives fromlocalhostyes
CORS_ALLOW_ORIGINSComma separated origins allowed to call the API. Anything not listed gets 403 on preflightderived from PUBLIC_HOST under composeyes
WEBSOCKET_URLThe websocket URL the dashboard connects toderived under composeyes (container start)
PHX_HOSTThe realtime service's own hostnamelocalhostyes
TRACKING_DOMAINThe host that serves open pixels, click links and the unsubscribe pages for workspaces that verified their own domain against it, and the CNAME value those customers point their tracking subdomain at. Use a separate, neutral domain in production. Unset means campaign mail ships with no pixel and unwrapped links, no custom tracking domain can verify, and every opt-out is served from API_PUBLIC_URLlocalhost:3000 under compose, otherwise unsetno
TRACKING_SERVICE_URLWhere the backend reaches the tracking service internallyunsetyes
FORMS_DOMAINThe host hosted form pages and embeds are served on (forms.example.com, routed to the forms service). The backend builds share links and embed codes from it; unset leaves forms without a public URLunsetno (read per request)

Setting PUBLIC_HOST turns localhost off

Once PUBLIC_HOST is set, every derived URL uses it and http://localhost:5173 stops working, because a localhost origin is no longer in CORS_ALLOW_ORIGINS. To keep both, list them yourself in CORS_ALLOW_ORIGINS.

Network and proxy

VariableWhat it doesDefaultRestart needed
TRUSTED_PROXIESComma separated CIDRs allowed to set X-Forwarded-Forempty (trust nothing)yes

Empty is correct for a directly exposed backend. Behind a reverse proxy it is not: with no trusted CIDR, Warmbly records the proxy's address as the client address, and the per IP login limiter, session records, audit rows and API key IP allowlists all read the wrong address. Set it to the CIDR your proxy connects from:

TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12

Authentication

VariableWhat it doesDefaultRestart needed
AUTH_LOGIN_CODEalways, new_device or off. Whether a login also requires a code emailed to the accountoff on self-host, new_device on cloudyes
REQUIRE_EMAIL_VERIFICATIONWhether a signup must confirm an emailed code before the account existsfalse on self-hostyes
DISABLE_REGISTRATIONfalse, invite_only or true. See registration modesinvite_only on self-hostyes
DISABLE_PASSWORD_LOGINTurns off email and password entirely, for single sign-on only deploymentsfalseyes
SSO_AUTO_PROVISIONtrue lets a verified identity provider assertion create an account regardless of DISABLE_REGISTRATIONfalseyes
AUTH_IP_RATE_LIMITUnauthenticated auth requests allowed per source IP per 15 minutes60yes
CLI_AUTH_IP_RATE_LIMITCLI sign-in handshake requests allowed per source IP per 15 minutes. Its own budget, because one warmbly auth login polls around 200 times and must not exhaust the allowance above500yes
PASSKEY_IP_RATE_LIMITPasskey login challenges allowed per source IP per 15 minutes. Its own budget, because the sign-in page asks for a challenge on load rather than when someone acts, so opening that page must not spend the allowance above. Finishing the ceremony is an authentication attempt and counts against AUTH_IP_RATE_LIMIT300yes
PUBLIC_IP_RATE_LIMITRequests allowed per source IP per 15 minutes on the remaining public routes: unsubscribe, invitation preview, fleet enrolment and the analytics proxy. Each carries its authorization in a high-entropy token, so this bounds unmetered writes and relayed bandwidth rather than guessing. Counts reads as well as writes, unlike the auth limiter600yes
WARMBLY_BOOTSTRAP_EMAILFirst owner's address, read only while the users table is emptyunsetyes
WARMBLY_BOOTSTRAP_PASSWORD_HASHArgon2 PHC string for that owner. Preferred over the plaintext formunsetyes
WARMBLY_BOOTSTRAP_PASSWORDPlaintext convenience form. Held to the same rules as any password (length, breached list); one that fails them is refused and no owner is created. Warns at boot, and leaves a password in your process environmentunsetyes
WARMBLY_BOOTSTRAP_ORGName of the organization created with that owner. A value that breaks the naming rules is ignored with a warningderived from the nameyes
TWOFA_SECRETKey that encrypts stored TOTP secrets. Falls back to AUTH_SECRET, so existing deployments keep working; rotating it invalidates every enrolled TOTP secretAUTH_SECRETyes
WEBAUTHN_RP_IDPasskey relying party id. Derived from APP_URL when unset. Changing it invalidates every enrolled passkeyderivedyes
WEBAUTHN_RP_ORIGINSOrigins accepted for passkey ceremoniesderived from APP_URLyes
WEBAUTHN_RP_DISPLAY_NAMEThe name the passkey prompt showsWarmblyyes
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETSign in with Google in the browser. Both are required; unrelated to the BOX_GOOGLE_* mailbox client. The same client carries a Google Workspace administrator's sign-in that proves a domain for a whole-domain connect, asking only for openid and email; that sign-in signs nobody inunsetyes
GOOGLE_REDIRECT_URIRedirect URI registered at Google. Served by the API, not the dashboard. An administrator's domain proof returns here too and is handed back to the dashboard window that opened it, so no second URI is registeredAPI_PUBLIC_URL plus /v1/auth/google/callbackyes
GOOGLE_IOS_CLIENT_IDAdditional Google client id accepted from the iOS app. Native only: it does not enable the browser buttonunsetyes
APPLE_APP_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_KEY_SECRETSign in with Apple. APPLE_APP_ID is the Services IDunsetyes
APPLE_REDIRECT_URIReturn URL registered at Apple. Must be HTTPSAPI_PUBLIC_URL plus /v1/auth/apple/callbackyes
APPLE_IOS_BUNDLE_IDBundle id accepted from the iOS appcom.warmbly.appyes
OIDC_ISSUER_URLGeneric OpenID Connect issuer. Discovery runs at bootunsetyes
OIDC_CLIENT_ID, OIDC_CLIENT_SECRETThe client Warmbly authenticates asunsetyes
OIDC_REDIRECT_URLRedirect URI registered at the provider. Defaults to API_PUBLIC_URL plus /v1/auth/oidc/callbackderivedyes
OIDC_SCOPESScopes requested at the provideropenid,profile,emailyes
OIDC_ALLOWED_DOMAINSEmail domains allowed to sign in through the providerempty (any)yes
OIDC_DEFAULT_ORGOrganization uuid every single sign-on user joinsunsetyes
OIDC_PROVIDER_NAMEThe label on the sign-in buttonSingle sign-onyes

Full behavior, including what each registration mode does to the sign-up form, is on accounts and access.

Proxying PostHog

Set the UI host whenever you proxy

POSTHOG_HOST is where events are sent; POSTHOG_UI_HOST is where PostHog's own app lives. They are the same thing only when you are not proxying. Point the first at your proxy and leave the second at https://us.posthog.com (or https://eu.posthog.com), or the SDK builds toolbar and session-replay links against the proxy, which does not serve the app.

Content blockers drop requests to posthog.com, so analytics and error reporting quietly stop for a large share of visitors. The backend serves a reverse proxy at /ingest for this. Point each frontend at it:

TreeSettingValue
dashboard, adminPOSTHOG_HOSThttps://api.example.com/ingest
marketing sitePUBLIC_POSTHOG_HOSThttps://api.example.com/ingest

Nothing else changes: the key stays the same, and the proxy forwards the payload untouched. It is on the backend rather than in each frontend because there are three of them, and a proxy copied three times drifts three ways.

The proxy forwards only what PostHog needs to read the request. Cookies and Authorization stay on this side, so a session for your instance is never handed to a third party, and the caller's address goes in X-Forwarded-For so visitors are geolocated rather than your server.

Captcha

VariableWhat it doesDefaultRestart needed
CAPTCHA_PROVIDERnone or turnstilederived, see belowyes
TURNSTILE_SECRETCloudflare Turnstile secret, read by the backendunsetyes
WARMBLY_TURNSTILE_KEYThe Turnstile site key, read by the dashboard and admin panel at container starta test key under composeyes (container start)
WARMBLY_BETA_NOTICEOne sentence shown to everyone in the dashboard: once in a dialog on first visit, then as a Beta pill in the header that reopens it. Empty renders nothing, so a production deployment needs nothing set. Written into config.js when the container starts (or when the Pages build runs), so a change needs a restart or a rebuild rather than a reloadunsetyes
WARMBLY_CONFIG_OUTWhere the dashboard and admin panel write their runtime config.js. Defaults to the path nginx serves; a static host sets it to the build output so the same script renders the file at build time instead of container startthe nginx pathyes (build or container start)
TURNSTILE_BYPASS_TOKENA token that skips verification. Only honoured when APP_ENV=devunsetyes
TURNSTILE_SITE_KEYThe public Turnstile widget key. The backend hands it to the forms service, which renders it on hosted form pages that enable spam protection. The frontends carry their own copy in WARMBLY_TURNSTILE_KEYunsetno (read per request)

CAPTCHA_PROVIDER has no constant default. Unset, it resolves to turnstile when TURNSTILE_SECRET holds a value and to none when it does not, so configuring the secret is what turns captcha on and clearing it is what turns captcha off. The panel reports the resolved value with source derived.

Setting CAPTCHA_PROVIDER=turnstile explicitly while TURNSTILE_SECRET is empty is the one combination that breaks: every verification fails, which means nobody can sign in. Set the secret or set the provider back to none.

Platform mail

Platform mail is the product's own outbound: registration codes, password resets, team invitations, notification digests and login codes where those are enabled. It is separate from campaign mail, which leaves through the mailboxes you connect.

VariableWhat it doesDefaultRestart needed
MAIL_TRANSPORTsmtp, log or seslog under compose, ses for a bare binary with no SMTP_HOSTyes
EMAIL_NAMEDisplay name on platform mailWarmblyyes
EMAIL_ADDRESSFrom address on platform mailnone, and the backend refuses to start without ityes
SMTP_HOSTRelay hostnameunsetyes
SMTP_PORTRelay port. Follows SMTP_SECURITY when unsetderivedyes
SMTP_USERNAME, SMTP_PASSWORDRelay credentials. Never sent over an unencrypted connectionunsetyes
SMTP_SECURITYstarttls (587), tls (465) or none (25)starttlsyes
SMTP_AUTHauto, plain, login, cram-md5 or noneautoyes
SMTP_EHLO_NAMEEHLO name presented to the relaythe sender domainyes
SMTP_TLS_INSECURE_SKIP_VERIFYSkips certificate verification. Only for a relay with a private certificate authorityfalseyes
EMAIL_BRAND_NAMEThe product name in transactional subjects and the headerWarmblyyes
EMAIL_BRAND_LEGAL_ENTITY, EMAIL_BRAND_COMPANY_NUMBER, EMAIL_BRAND_PLACE_OF_REG, EMAIL_BRAND_ADDRESSThe identification line in the transactional footerunset on a self-host, which renders no lineyes
EMAIL_BRAND_WEBSITE_URL, EMAIL_BRAND_TERMS_URL, EMAIL_BRAND_PRIVACY_URL, EMAIL_BRAND_SUPPORT_EMAILThe footer's links and support addressunset on a self-host, which renders no link rowyes
NOTIFICATION_EMAIL_DAILY_CAPNotification emails per user per day. 0 means uncapped25yes
NOTIFICATION_PUSH_WINDOWHow long a notification waits before it is also pushed5hyes

A self-hosted instance never fills those in with ours. Warmbly's own company details and website are the hosted service's, not yours, so an unset EMAIL_BRAND_* renders nothing rather than sending your users a footer naming a company they have no relationship with. Set them to your own if your jurisdiction wants an identification line on business email.

Despite the prefix, these are not only about email. GET /v1/auth/config serves the public half of them (name, website, terms, privacy, support address) and every surface that used to hardcode warmbly.com reads it: the sign-in screen's wordmark and its Terms and Privacy links, the copyable API example on the API keys page, a shared stats card, and the "powered by" line at the foot of a hosted form page. Each renders nothing when the value is unset, so a stranger filling in one of your forms is never sent to a website with no relationship to it.

log is a real transport, not a broken one: it writes every message to the backend log and delivers nothing. It exists so a fresh install can complete its first sign-in with no relay. What it costs you is password resets, invitation delivery and digests, all of which have a workaround described on accounts and access.

Read a code out of the log:

docker compose -p warmbly logs backend | grep -B2 -A12 "MAIL_TRANSPORT=log"

The consumer only warns

The backend refuses to start without EMAIL_ADDRESS and EMAIL_NAME. The consumer logs a warning and silently disables all notification and digest email, so an instance can look healthy while sending nothing. Set both on every process.

Pre-send verification

Before a campaign sends to an address, the backend can check it: syntax, then MX, then an SMTP RCPT probe against the recipient's mail server. An address that comes back invalid is skipped rather than sent to, which turns a would-be hard bounce into a silent drop. The probe runs from the backend, never from a worker, because workers are your sending IPs.

VariableWhat it doesDefaultRestart needed
EMAIL_VERIFY_HELO_HOSTThe hostname the probe announces in EHLO/HELO. Must be a public, fully-qualified name that belongs to this instancethe host of APP_URLyes
EMAIL_VERIFY_MAIL_FROMThe envelope sender the probe uses in MAIL FROMverify@ plus the HELO hostyes
EMAIL_VERIFY_MILLIONVERIFIER_API_KEYAn instance-wide MillionVerifier key. Every workspace that has not connected its own key is checked through this one, spending its credits, instead of the built-in probeunsetyes

An unqualified HELO name gets the whole session rejected

Mail servers refuse a greeting that is not a real hostname (localhost, a bare name, or anything under a reserved suffix such as .local, .internal or .lan). Postfix in particular applies that rejection at RCPT time rather than at HELO, so it arrives looking exactly like 504 5.5.2 <localhost>: Helo command rejected: need fully-qualified hostname on the recipient's address. Warmbly reads a reply like that as a rejected probe, not as a dead mailbox, so it never marks the contact invalid. If neither EMAIL_VERIFY_HELO_HOST nor a usable APP_URL host is set, the probe is skipped entirely and every address stays unknown, which still sends.

Verdicts are re-checked after 90 days (30 for an inconclusive one), in passes of 200 contacts a minute that repeat while a backlog remains. A workspace that connected MillionVerifier under Integrations is checked through its own credits whether or not the instance-wide key is set.

EMAIL_VERIFY_HELO_HOST is not SMTP_EHLO_NAME. SMTP_EHLO_NAME is the greeting your platform mail relay sees in platform mail; this one is the greeting recipients' servers see from the verifier.

Encryption

VariableWhat it doesDefaultRestart needed
KMS_PROVIDERlocal (AES master key below), aws (AWS KMS), or brokered (hold no key material and ask the control plane to unwrap). A joining node is given brokered automatically wherever the instance runs aws, so no machine in the fleet needs a cloud credentiallocal under compose, aws for a bare binaryyes
KMS_LOCAL_MASTER_KEYbase64, exactly 32 bytes. The root of trust for every per-organization data keypublished default under composeyes
KMS_LOCAL_MASTER_KEY_FILEPath to a file holding that key instead. Mutually exclusive with the inline valueunsetyes
KMS_AWS_KEY_IDKey id or alias when KMS_PROVIDER=awsunsetyes
CREDENTIALS_ENCRYPTION_KEYexactly 64 hex characters. Seals mailbox SMTP and IMAP passwords at restpublished default under composeyes
ENCRYPTED_KEYS_PROVIDERpostgres for the backend and a consumer that has a DSN, http for workersthe caller's fallback, so set it explicitlyyes
ENCRYPTED_KEYS_BACKEND_URLWhere a worker reaches the backend's key endpointunsetyes
ENCRYPTED_KEYS_WORKER_TOKENThe worker's copy of INTERNAL_API_TOKENunsetyes

An empty CREDENTIALS_ENCRYPTION_KEY does not fail at boot. It disables sealing, so mailbox passwords are stored unsealed. Set it before you connect a single mailbox, and back it up.

Storage

VariableWhat it doesDefaultRestart needed
BLOB_PROVIDERfilesystem, s3, or brokered (hold no bucket credential and ask the control plane to sign each operation; the bytes still travel directly). A joining node is given brokered automatically wherever the instance runs s3filesystem under compose, s3 for a bare binaryyes
BLOB_FS_ROOTDirectory for stored bodies, attachments, avatars and email body images. The backend, the consumer and every worker on the host must share it/data/blobsyes
BLOB_BUCKETBucket name when BLOB_PROVIDER=s3unsetyes
BLOB_PUBLIC_BASE_URLPublic base for the backend's /public route. Images placed in an email body are served from here, so it has to be an address a recipient's mail client can reachderivedyes
AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEYCredentials for S3 or SESunsetyes
AWS_ENDPOINT_URL_S3Non-AWS S3 endpoint (MinIO, R2, B2)unsetyes
AWS_CONFIG_ENABLEDtrue reads secrets from AWS SSM or Secrets Managerfalseyes

On filesystem, a remote worker writes blobs to its own disk rather than a volume the backend can read. Use s3 with a bucket both sides reach when workers run off-host.

Event bus

VariableWhat it doesDefaultRestart needed
EVENTBUS_PROVIDERnats or kafka. Kafka needs the -kafka images, or a build with GO_TAGS=kafkanats under compose, kafka for a bare binaryyes
FLEET_IMAGE_VARIANTTag suffix appended to the version handed to fleet nodes, so they pull a build that can talk to this instance's event bus. Defaults to -kafka when EVENTBUS_PROVIDER=kafka and nothing otherwise; set it empty to turn it offderived from EVENTBUS_PROVIDERno
NATS_URLJetStream address. Credentials in the URL are honored by every service, including the Rust tracking publisher: nats://user:pass@host:4222 for a user, nats://token@host:4222 for a token, tls:// for TLSnats://nats:4222yes
NATS_CREDSPath to a NATS credentials file (user JWT plus nkey seed), for a bus that authenticates with JWT rather than a token. Synadia Cloud and any nsc-managed account issue oneunsetyes
NATS_CREDS_B64The same file, base64 encoded, as a single line. This is the form the fleet uses: a node receives environment variables rather than files, and the env file docker reads cannot express a multi-line value. Takes precedence over NATS_CREDSunsetyes
NATS_MAX_BYTESCeiling on the stream's size on disk. Accepts a plain byte count or a size (2GiB, 512MB, 1G). Unset leaves the stream bounded only by NATS_STREAM_MAX_AGE and the account's own quota, which a managed bus may refuse: Synadia's "Max Bytes Required" rejects any stream created without oneunset (unlimited)yes
NATS_STREAM_NAME, NATS_SUBJECT_PREFIXStream and subject namingwarmblyyes
KAFKA_BOOTSTRAP_SERVERSBroker list when EVENTBUS_PROVIDER=kafkaunsetyes
KAFKA_SASL_USERNAME, KAFKA_SASL_PASSWORDBroker credentialsunsetyes
SCHEMA_REGISTRY_URL, SCHEMA_REGISTRY_KEY, SCHEMA_REGISTRY_SECRETRegistry for the Avro codecunsetyes
CODEC_PROVIDERjson or avrojson under compose, avro for a bare binaryyes
EVENTBUS_HANDLER_TIMEOUTHow long one handler may take before the delivery is abandoned30syes
PUBSUB_ENABLEDfalse uses the Redis bridge for realtime fanout, true uses Google Pub/Subfalseyes
GCP_PROJECT_IDProject when PUBSUB_ENABLED=trueunsetyes

Topics are created by the bus, not by hand

A worker's command topic is named after the node id issued when it joined, so the set of topics is not knowable in advance. The Kafka backend creates what it uses, once per topic per process, rather than depending on the broker's auto.create.topics.enable, which is off by default on Confluent Cloud and configurable only on Dedicated clusters, not on Basic, Standard, Enterprise or Freight. The credential therefore needs CREATE on topics as well as read and write.

json needs nothing and is the default. avro resolves every event against SCHEMA_REGISTRY_URL and ships only in the -kafka images. Producers and consumers have to agree on one: there is no in-band marker, so a consumer on the other codec cannot read what is already on the bus. Change it by draining the bus, not in place. The envelope subjects need BACKWARD compatibility, because a new event type adds a union branch; a publisher that meets FORWARD or FULL sets its subject to BACKWARD itself. PUBSUB_ENABLED must agree across backend, consumer and realtime.

The tracking topic is read by two languages

KAFKA_TRACKING_TOPIC is read by the Rust publisher and the Go subscriber. Override it in one place only and opens and clicks stop being consumed, with no error anywhere.

Database

VariableWhat it doesDefaultRestart needed
PRIMARY_DBPostgreSQL connection string. Carries inline credentials, so it is never returned by any APIthe compose postgresyes
PGSSLROOTCERTCertificate bundle used to verify the database's TLS certificate, read by the Postgres driver. Amazon RDS chains to a root in no public trust store, so sslmode=verify-full against it needs AWS's bundle; the backend and consumer images ship it at /etc/ssl/rds/global-bundle.pem. Prefer sslrootcert= in the DSN so the setting travels with the connection it applies tounset (system trust store)yes
DB_MAX_CONNSMaximum pooled Postgres connections for one Go process (backend, consumer). The ceiling that matters is the database's max_connections, and every replica, consumer and warmblyctl run takes its own share of it. At boot a process asks the server for that ceiling and lowers its own pool to a quarter of the connections the server leaves unreserved. The quarter assumes four database clients, which is backend and consumer with a rolling deploy running two of each, so it is a per-process guard and not a fleet-wide allocation: more than four clients, or anything else sharing the server, still needs this set deliberately. The clamp only ever lowers the pool, and only when the probe succeeds; a probe that cannot reach the server or read the setting leaves the value below in force. A pool_max_conns in the DSN is honoured when this is unset25, lowered to the probed shareyes
DB_MIN_CONNSConnections each Go process keeps open while idle. Capped at DB_MAX_CONNS2yes
DATABASE_URLThe realtime service's own name for the same databasethe compose postgresyes
DATABASE_POOL_SIZEMaximum pooled connections for the realtime service only. The Go services use the driver default and do not read it10yes
DATABASE_SSLWhether the realtime service connects to Postgres over TLStrue, and false under composeyes
DATABASE_SSL_CA_FILECA bundle the realtime service verifies Postgres against. Unset verifies against the system store, which has no Amazon RDS root in it, so an RDS database needs this set to /etc/ssl/rds/global-bundle.pem (shipped in the image)unsetyes

Migrations are embedded in the backend binary and applied on boot. There is no separate migration step, and a standalone /app/migrate binary ships in the image for the cases where you want one.

Cache

VariableWhat it doesDefaultRestart needed
REDISRedis connection string. Carries inline credentials, so it is never returned by any APIthe compose redisyes
REDIS_URLThe realtime service's own name for the same instancethe compose redisyes

Redis holds rate limit counters, the organization key cache, the realtime bridge and the first-run setup token. Flushing it on an unclaimed instance destroys the claim link along with every pending auth session.

GeoIP

VariableWhat it doesDefaultRestart needed
GEODB_PATHPath to a GeoLite2 City databasenone, and the backend refuses to start without the variableyes
GEODB_URLWhere to download that database from when nothing is at GEODB_PATH yetunset, and only a file already there is usedyes
TRACKING_SCANNER_ASN_DBPath to a GeoLite2 ASN database, on the tracking serviceunset, and ASN matching is offyes
TRACKING_SCANNER_ASN_DB_URLWhere the tracking service downloads that database fromunsetyes

GEODB_PATH must be set on the backend in every environment. The file itself is optional: a missing file at that path means sessions and audit rows are recorded without a city, and nothing else changes. The consumer reads the same variable, optionally, to put a country and city on each email open and click; without it those records carry client and device only.

These are two different databases and two different files. GeoLite2-City answers where an address is; GeoLite2-ASN answers which network announces it, which is what makes the tracking service's asn: scanner entries match. Neither file contains the other's data, so a reader pointed at the wrong one resolves nothing. Both are free on one MaxMind account, as the GeoLite2-City and GeoLite2-ASN editions, and geoipupdate fetches either.

GEODB_PATH belongs on the backend and the consumer, TRACKING_SCANNER_ASN_DB on the tracking service, and each is ignored when its file is absent: a missing city database costs location labels, a missing ASN database costs asn: matching. Pointing TRACKING_SCANNER_ASN_DB at a City database is refused at boot with a line naming what it found, rather than matching nothing and saying nothing.

Getting the databases in

A file you can mount is the simplest answer and needs none of the variables below. Where there is nothing to mount from, which is most container hosts, set the matching _URL variable and the service fetches its own copy at startup.

# MaxMind's download endpoint, which always serves the current build of an
# edition, so a restart is how an instance picks up a newer one. Both halves of
# the credential come from the same page of your MaxMind account: the account
# id is the username and the licence key is the password.
GEODB_URL=https://ACCOUNT_ID:[email protected]/geoip/databases/GeoLite2-City/download?suffix=tar.gz
TRACKING_SCANNER_ASN_DB_URL=https://ACCOUNT_ID:[email protected]/geoip/databases/GeoLite2-ASN/download?suffix=tar.gz

The older permalink no longer works

MaxMind's app/geoip_download?license_key= URL is the form most guides still show, and a licence key issued today is refused by it with Invalid license key. Current keys end in _mmk and authenticate against the endpoint above, with the account id, so a URL that carries only the key fails whatever the key is.

Any URL serving a MaxMind-format database works, and the shape is read from the content rather than the file name, so a .tar.gz, a .mmdb.gz and a bare .mmdb are all accepted. A credential in the userinfo, as above, is sent as HTTP basic authentication. DB-IP's free Lite databases need no account at all and are published in the same format under dbip-city-lite and dbip-asn-lite; their country data is comparable and their city data is weaker.

Two things to know:

  • Nothing waits for it. The service starts with location lookups answering Unknown and picks the database up when it arrives, so a mirror that is slow or gone costs city labels rather than a deploy's health check.
  • A file that is already there is kept until it goes stale. A database less than seven days old is used as it is, with no request at all, so a mounted copy, a volume that survived a restart and a copy you dropped in by hand all win. Past seven days the file is revalidated with If-Modified-Since, which normally answers 304 Not Modified, costs nothing and does not count against MaxMind's daily allowance; only a genuinely newer database is downloaded. With no URL set nothing is ever requested and the file is yours alone.
  • A refused download is retried. Four attempts with a growing wait, obeying a Retry-After when the server sends one. A throttled or briefly unreachable mirror used to cost that container its geo data for the whole of its life, because the fetch ran once at boot and never again. A refusal that will repeat, such as a bad licence key or a name that does not resolve, is not retried.
  • A download that is not a database is thrown away, not installed. An expired licence key returns an error page, and storing that would disable location lookups permanently, because the next start would find a file and not retry. The bytes are opened before they are put in place, and a failure leaves the path empty and one line in the log.
  • Plain http is refused for a URL carrying a credential, in the query or the userinfo, because it would put the licence key on the wire in the clear. An http mirror with nothing secret in its URL is allowed, and a redirect from https down to http is refused either way.

Both URLs are secrets

These URLs carry the account's credential, in the userinfo above and in the query on an older-style link. The backend treats both variables as secrets: their values are never returned by any API, and the admin panel reports them as set rather than showing them. Nothing logs either one intact: the userinfo and the query are both stripped, including from the errors net/http and reqwest build, which embed the URL they were given.

The tracking service is the exception to the path: it holds its ASN database in memory and never writes it out, so TRACKING_SCANNER_ASN_DB_URL works on its own and TRACKING_SCANNER_ASN_DB can stay unset.

Mirror the databases rather than pulling from MaxMind

A GeoLite2 account is allowed 30 direct downloads per 24 hours and answers 429 past that; a paid GeoIP account gets 2,000. Each consumer boot fetches GeoLite2-City and each tracking boot fetches GeoLite2-ASN, so a release costs two downloads and a restart loop costs one per restart. That is a ceiling you can reach on a busy day, and reaching it is worse than a failed download: a container that starts into a 429 has no geo data until it is replaced.

Point the URLs at your own copy instead. .github/workflows/geoip-mirror.yml runs on Tuesdays and Fridays, which is MaxMind's own publish cadence, downloads both editions once and uploads them to a private bucket. The whole fleet then reads from there, and MaxMind sees two requests a week however often you deploy. An intra-region pull of a 60 MB file is also a second or two rather than however long MaxMind takes.

It needs these repository settings, and skips itself when MAXMIND_ACCOUNT_ID is unset so a fork never fails on it:

SettingKindMeaning
MAXMIND_ACCOUNT_IDvariableThe numeric account id
MAXMIND_LICENSE_KEYsecretA licence key for that account
GEOIP_MIRROR_BUCKETvariableBucket the copies are written to
GEOIP_MIRROR_REGIONvariableIts region
GEOIP_MIRROR_PREFIXvariableKey prefix, including the trailing slash
GEOIP_MIRROR_AWS_ACCESS_KEY_IDsecretCredentials that may write that prefix, and nothing else
GEOIP_MIRROR_AWS_SECRET_ACCESS_KEYsecret

Then set GEODB_URL and TRACKING_SCANNER_ASN_DB_URL to the mirrored objects. Both accept a credential in the userinfo or the query, so a presigned URL or an object under a non-guessable key works without the services holding bucket credentials. Only the mirror job holds the MaxMind licence key, which is the other reason to prefer this: the key stops being deployment configuration on several services and becomes one repository secret.

Keep the copies private. GeoLite2 may not be redistributed, which is also why nothing is ever baked into the published images. If you would rather have no account at all, DB-IP's Lite databases are CC-BY 4.0 and need none.

Workers

VariableWhat it doesDefaultRestart needed
WORKER_IDStable uuid for this worker. Leave unset when running scaled replicas, which share one environmentderived, then randomyes
WORKER_BIND_IPSource address to bind outbound connections to, and the seed for a derived WORKER_IDunsetyes
WORKER_PUBLIC_IPPublic IPv4 fallback reported by the worker. The control plane uses the address observed on the heartbeat when it is publicdetectedyes
WARMBLY_WORKER_CAPACITYAssigned-mailbox planning target for this worker. This guides placement and is not a mailbox-provider send limit100yes
WARMBLY_NODE_REGIONFree-form label for where this node egresses from, e.g. eu-central. Placement prefers a worker near where a mailbox's provider expects sign-ins; unset scores neutral. Written by the join script from --regionunsetyes
ENCRYPTED_KEYS_BACKEND_URLBackend base the worker fetches organization keys fromunsetyes
ENCRYPTED_KEYS_WORKER_TOKENThe worker's copy of INTERNAL_API_TOKENunsetyes
MAIL_TLS_INSECURESkips certificate verification on mailbox connectionsfalseyes

An unset key URL is silent

An empty ENCRYPTED_KEYS_BACKEND_URL or ENCRYPTED_KEYS_WORKER_TOKEN lets the worker start, subscribe and never register. There is no log line. The no_worker_heartbeat check on Instance health is what surfaces it.

Workers hold no database connection by design. Everything relational they need arrives over the backend's internal HTTP API.

What a joining node is given

warmbly join renders /etc/warmbly/node.env from the backend's own environment, so most of this page reaches a node without being set twice. Three things are decided rather than copied:

  • The crypto and blob providers are translated. An instance on KMS_PROVIDER=aws hands its nodes brokered, and one on BLOB_PROVIDER=s3 hands them brokered too, so no machine in the fleet carries a cloud credential. Providers that need no credential pass through unchanged.
  • A worker never receives PRIMARY_DB. A consumer does, because it opens Postgres itself. An instance that keeps its DSN in a secret manager rather than the environment has none to send, and the join script says so.
  • Addresses are inherited literally. NATS_URL, REDIS and ENCRYPTED_KEYS_BACKEND_URL are used exactly as the backend has them, and a value that only resolves inside your container network produces a node that enrols cleanly and reaches nothing.

Settings the control plane cannot know

node.env is rewritten on every join. Next to it, /etc/warmbly/node.local.env is created once and never written again, and the container reads it second, so a name repeated there wins.

That is where a per-machine value belongs: a DSN this instance does not hold, credentials for infrastructure of your own, or a deliberate override back to a direct provider.

printf 'PRIMARY_DB=%s\n' "postgres://..." >> /etc/warmbly/node.local.env
systemctl restart warmbly-consumer

Mailbox connections

Needed on the backend and every worker: the backend starts the OAuth flow, and each worker refreshes the token when it expires.

VariableWhat it doesDefault
BOX_GOOGLE_CLIENT_ID, BOX_GOOGLE_CLIENT_SECRETThe Google app used by Gmail mailboxes connected with per-mailbox Google sign-in: the ones already connected that way, and new ones once BOX_GOOGLE_OAUTH_CONNECT is on. That method is being retired, so keep these set while any mailbox still uses it. Also carries a Workspace administrator's domain proof when GOOGLE_CLIENT_ID is not set. Redirect URI is your API base plus /addresses/google/callbackunset
BOX_GOOGLE_OAUTH_CONNECTtrue lets a new Gmail mailbox connect with Google sign-in. Off, the connect dialog walks through an app password over IMAP and SMTP instead, which needs no Google app; mailboxes already on Google sign-in keep working and can be re-authorized either way. Announced to clients as gmail_oauth_connect on GET /auth/configfalse
BOX_OUTLOOK_CLIENT_ID, BOX_OUTLOOK_CLIENT_SECRETConnect Outlook and Microsoft 365 mailboxes. Redirect URI is your API base plus /addresses/outlook/callbackunset

Plain SMTP and IMAP mailboxes need none of this. If a worker is missing these values, the mailbox connects fine and then silently stops about an hour later, when its first access token expires.

Whole-domain connects

A workspace can connect every mailbox on a Google Workspace domain or Microsoft 365 organization through one administrator's grant (Add account > Google > Whole Workspace domain, Add account > Microsoft > Whole organization). These are read by the backend only: it mints every token for those mailboxes itself and hands them to workers per use, so workers need nothing here. The complete setup, step by step, is in whole-domain mailbox connects.

VariableWhat it doesDefault
GOOGLE_WORKSPACE_DELEGATION_KEYThe JSON key of a Google Cloud service account with domain-wide delegation. Its client_id is what a Workspace super admin authorizes. Unset, Google Workspace grants are off. A value that is not a service account key logs one line at boot and leaves them offunset
GOOGLE_WORKSPACE_DELEGATION_KEY_FILEA path to the same key as a file, for a mounted secret. Read when GOOGLE_WORKSPACE_DELEGATION_KEY is emptyunset

Google, once per instance:

  1. In the Google Cloud console, create a project or pick the one you will keep. Its project ID is permanent.
  2. Under APIs & Services > Library, enable the Gmail API and the Admin SDK API.
  3. Under IAM & Admin > Service accounts, create a service account (for example warmbly-workspace). Grant it no roles on the project.
  4. On its Keys tab, choose Add key > Create new key > JSON. A new Google Cloud organization blocks this by default with the Disable service account key creation policy, which an Organization Policy Administrator can lift for this project.
  5. Put the key's path in GOOGLE_WORKSPACE_DELEGATION_KEY_FILE (mounted read only) or its JSON in GOOGLE_WORKSPACE_DELEGATION_KEY, and restart the backend.

Each customer's authorization is tied to the service account's Client ID, so create the service account once and rotate its keys, never the account: a recreated one has a new Client ID and every domain would have to authorize it again. The key can mint a token for any address on any domain that has authorized it, so treat it like CREDENTIALS_ENCRYPTION_KEY: it is the one credential behind every such mailbox.

A Google grant is recorded only for the workspace that proves it controls the domain, and only when the directory lists the admin address as a super administrator. The proof is one of:

  • The administrator's Google sign-in, through the dashboard's own sign-in client (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) and its existing redirect URI (GOOGLE_REDIRECT_URI, or API_PUBLIC_URL plus /v1/auth/google/callback), with the openid and email scopes only. The callback recognizes the proof and hands it back to the dashboard window rather than signing anyone in, so no new redirect URI needs registering. When that client is not configured, the BOX_GOOGLE_* mailbox client is used instead, the same way. Neither depends on BOX_GOOGLE_OAUTH_CONNECT.
  • A TXT record at _warmbly.<domain> holding a warmbly-verify= value unique to the workspace and domain, always available and the only proof when neither client is set. The value is keyed by AUTH_SECRET.

Microsoft 365 organizations reuse the Outlook app, BOX_OUTLOOK_CLIENT_ID and BOX_OUTLOOK_CLIENT_SECRET, and are on whenever those are set. The app registration needs three additions for them:

  • Supported account types: accounts in any organizational directory (multitenant), so another organization can consent to it.
  • Redirect URI: the existing one, your API base plus /addresses/outlook/callback. Admin consent returns there too.
  • API permissions: the Microsoft Graph application permissions Mail.ReadWrite, Mail.Send and User.Read.All, next to the delegated ones single-mailbox sign-in already uses.

Do not grant admin consent for those in your own tenant unless you are testing with it. Consent is given per customer organization, by that organization's Global Administrator, through the dashboard. The organization a grant names is the one the administrator signed in to, read from the consent sign-in itself, never from the request. Publisher verification is optional.

Application permissions reach every mailbox in a consenting organization. We recommend that each organization scope the app to the mailboxes Warmbly should reach with an Exchange Online ApplicationAccessPolicy (or RBAC for Applications); a mailbox outside the policy is refused when it connects. The customer steps are in connect a whole Microsoft 365 organization.

GET /emails/grants/config reports what is still missing, as google_missing and microsoft_missing: the names of the settings above that are unset, and an empty list once a provider is ready.

Integrations

VariableWhat it doesDefault
<PROVIDER>_OAUTH_CLIENT_ID, <PROVIDER>_OAUTH_CLIENT_SECRETOAuth clients for the CRM and messaging integrationsunset
INTEGRATIONS_OAUTH_REDIRECT_URLShared redirect URI for those flowsderived from API_PUBLIC_URL
VariableWhat it doesDefault
AI_PROVIDERopenai, openrouter, groq, ollama, anthropic or custom. Omit every AI variable to run with AI off, in which case AI endpoints return a clean 503unset
AI_API_KEYProvider key. Not needed for ollamaunset
AI_MODEL, AI_MODEL_TRIAL, AI_MODEL_PAIDModel selection, optionally split by planprovider preset
AI_BASE_URLRequired for custom. Any OpenAI compatible endpointunset
AI_FREETreats AI usage as unchargedderived
SEARCH_PROVIDER, SEARCH_API_URL, SEARCH_API_KEYWeb search for the assistant (serper or searxng)unset
MAILHOST_ISPDBtrue lets mailbox server detection ask Mozilla's Thunderbird ISPDB (autoconfig.thunderbird.net) about a domain whose MX records and own autoconfig file name no known provider. Only the domain name is sent. See data controlfalse on a self-host, true on Warmbly Cloud
MAILVENDOR_SANDBOX_URLDevelopment only. Points every inbox vendor client at the sandbox's mock vendor API (<url>/inboxkit, <url>/mailforge) instead of the real vendors. Ignored unless APP_ENV is devunset
TYPESAFE_API_KEYTypeSafe API key. Turns on the typed judgments: reply classification, the copy judgment in the Advisor and the editor, warmup content lint, bounce classification, import column mapping and per-form triage. Inbox tagging needs the switch below as well. See data control for what each one sendsunset
INBOX_TAGGING_ENABLEDtrue enables automatic inbox tagging when TYPESAFE_API_KEY is also set. Message subject, current body, and the previous outbound body are sent to TypeSafefalse

An unset provider still uses a key

An empty AI_PROVIDER with a set AI_API_KEY falls back to api.openai.com, so the key goes to OpenAI. Set both or neither.

Model compatibility needs no configuration. Backends disagree about the request shape: newer OpenAI models want max_completion_tokens rather than max_tokens, refuse any temperature but the default, and some refuse function tools unless reasoning is switched off, while most OpenAI compatible backends only know the older shape. The provider learns this from the first rejection of each parameter and remembers it, so a model is walked to a working shape on its first call and every later call starts there.

Set these on the backend and the consumer.

Automatic inbox tagging is read by the backend, consumer, and warmblyctl. Both inbox-tagging variables are required; a key by itself does not start classification. See Automatic inbox tagging.

Tasks and billing

VariableWhat it doesDefault
TASKS_PROVIDERlocal (an in-process Postgres poller) or gcloud (Cloud Tasks)local
TASKS_LOCAL_POLL_INTERVALHow often the local poller looks for due work1s
BILLING_PROVIDERnone (every feature unlocked, no trial expiry; the dashboard reports the workspace as self-hosted rather than on a free tier and hides billing) or stripenone
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PUBLISHABLE_KEYRequired together when BILLING_PROVIDER=stripe. The backend exits at boot if any is missingunset

Stripe Tax also needs account-side setup. In Tax > Settings, confirm the head office address, use the business-use SaaS product tax code (txcd_10103001) as the default, and choose whether prices include tax or have tax added at checkout. Apply the same tax code and an explicit tax behavior to every subscription and credit-pack product and price. A price set to inclusive or exclusive cannot be changed later, so replacing that choice requires a new Price and an update to the matching plan or STRIPE_CREDIT_PACK_*_PRICE_ID value.

Add a Tax registration only after the business is registered with that tax authority. Automatic tax returns zero with a not_collecting reason in jurisdictions where no active registration applies. Enabling Stripe Tax is not a substitute for registering or filing.

Stripe Checkout always requests the billing address needed for tax and lets business buyers provide a legal name and business tax ID. The active default billing portal configuration must allow updates to payment methods, billing email, billing address, name, and tax ID, and must show invoice history. This lets a customer correct its tax location or registration before a renewal.

Point the webhook endpoint at /webhook/stripe using API version 2026-08-26.dahlia, matching stripe-go v86. Subscribe to checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.expired, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed, invoice.finalization_failed, payment_intent.succeeded and charge.refunded. If you replace the endpoint, update STRIPE_WEBHOOK_SECRET to the new endpoint's signing secret before disabling the old endpoint. The handler accepts the previous webhook shape during an endpoint upgrade, but new endpoints should use the matching version.

Turning on Stripe Tax does not update existing subscriptions. Review and migrate them from Tax > Migrations after setting the prices' tax behavior. Stripe applies the tax change from the next billing cycle without prorating it. The Dashboard migration does not operate on sandbox subscriptions, so update those individually when testing.

Delayed sends run through the local poller, so the backend must be running for scheduled work to fire.

Observability

Error reporting

Every runtime in Warmbly can report its errors, and none of them do unless you say where. Where is the operator's choice, not a requirement of the software, so the default install reports nowhere and contacts no host.

There are two backends and you can run either, both, or neither:

  • PostHog is the default. One project key carries error tracking for every service, and the same key carries the handful of product events described below, so there is one account to have rather than two.
  • Sentry is still fully supported. An instance that already reports to Sentry keeps working unchanged, and setting POSTHOG_ERROR_TRACKING=false keeps a PostHog key configured for analytics while sending no exceptions there.

Set both and every error goes to both. Each variable is read by one process, so pointing the backend at a project does not make the dashboard report too.

VariableRead byWhat it doesDefault
POSTHOG_KEYbackend, consumer, workerServer-side error tracking, and the server-side product events belowunset
WARMBLY_POSTHOG_UI_HOST / PUBLIC_POSTHOG_UI_HOSTWhere PostHog's own app is, used for toolbar and session-replay links. Only differs from the host above when you proxy, and it must when you dohttps://us.posthog.comyes (build or container start)
POSTHOG_PROXY_INGESTUpstream the /ingest reverse proxy forwards capture traffic to. The frontends are pointed at https://<api host>/ingest so content blockers, which drop requests to posthog.com, do not silently remove your analytics and error reportinghttps://us.i.posthog.comyes
POSTHOG_PROXY_ASSETSUpstream for /ingest/static/*. PostHog serves its browser bundle from a different host than it ingests on, and sending one to the other 404shttps://us-assets.i.posthog.comyes
POSTHOG_KEYforms serviceThe forms service's own errors. It reads its own environment, like trackingunset
POSTHOG_KEYtracking serviceRust errors and panics, posted to the capture endpoint directlyunset
POSTHOG_KEYrealtime serviceElixir exceptions, with the stack trace the rescue site caughtunset
POSTHOG_HOSTevery server-side serviceCapture host. Point it at a self-hosted PostHog if you run onehttps://us.i.posthog.com
POSTHOG_ERROR_TRACKINGevery server-side servicefalse keeps the key for product analytics and reports no exceptions to PostHogtrue
SENTRY_DSNbackend, consumer, workerServer-side error reporting. Optional in every environment, including produnset
SENTRY_DSNforms serviceThe forms service's own errorsunset
SENTRY_DSNtracking serviceRust errors and panics. An invalid DSN disables reporting with a log line rather than stopping the serviceunset
SENTRY_DSNrealtime serviceAn empty string is treated as unset on purpose, because the library rejects "" hard enough to take the node downunset
WARMBLY_POSTHOG_KEYweb, admin containersBrowser error tracking, read at container start like the other WARMBLY_* values. The same key carries the product analytics and session replay belowunset
WARMBLY_POSTHOG_KEYforms serviceStamped into the public form page for the form app's pageviews, web vitals and browser errors. Separate from the forms service's own POSTHOG_KEY: one is a Go process, the other is a page a stranger loadsunset
WARMBLY_POSTHOG_HOSTweb, admin containers, forms serviceCapture host for the browserhttps://us.i.posthog.com
WARMBLY_POSTHOG_ERROR_TRACKINGweb, admin containers, forms servicefalse reports no browser exceptions to PostHog anywhere, including the form pages the forms service stamps. Everything else the key turns on keeps runningtrue
WARMBLY_POSTHOG_SESSION_REPLAYweb, admin containersfalse records no sessions and keeps everything else. Form pages never record, whatever this saystrue
WARMBLY_COMPANY_LOGOSweb containeron shows a company logo on each contact whose address is on a company domain, fetched by the browser from DuckDuckGo's favicon service with only the domain sent; off shows initials. Unset is off on a self-host and on for Warmbly Cloud. See outbound callsunset
WARMBLY_SENTRY_DSNweb, admin containersThe Sentry half of the same thing. Unset means the SDK is never fetched and no host is contactedunset
WARMBLY_SENTRY_DSNforms serviceStamped into the public form page, along with the service's APP_ENV as the environmentunset
WARMBLY_SENTRY_ENVIRONMENTweb, admin containersThe environment label browser events carry, whichever backend receives them. Defaults to the build mode. Form pages do not read it: the forms service stamps its own APP_ENV into the page insteadunset
APP_ENVevery server-side serviceDoubles as the environment label on the events that service reports. Browser events take theirs from WARMBLY_SENTRY_ENVIRONMENT instead, because the container serving the bundle is not the process reporting the errordev
WARMBLY_RELEASEtracking, realtimeThe build events are tagged with. The published images set it from the release tag; the Go services and the frontends read the same value from their build stamp insteaddev

Configuring neither backend means nothing leaves the process and no host is contacted. The dashboard, the admin panel and form pages go further and never load an SDK at all, so there is not even a script; the Go, Rust and Elixir services still record errors, to their own logs.

A PostHog key can name PostHog Cloud (US or EU) or your own PostHog through POSTHOG_HOST. A Sentry DSN can point at Sentry Cloud, a self-hosted Sentry, or any Sentry-compatible server such as GlitchTip; nothing in Warmbly assumes sentry.io.

The distinct id on a server-side PostHog exception names the process that raised it (warmbly-backend, warmbly-worker), person profiles are switched off per event, and IP geolocation is disabled, since the address would be the server's. A browser exception belongs to the signed-in person the app identified, so it sits next to that person's session replay.

Exceptions still carry the context that makes them answerable, as searchable properties:

PropertyOnWhat it is for
organization_id, user_idbrowser and request-scoped server exceptions"Show me every error this workspace hit." Flat properties, so one filter finds them on the server side too, where no person is identified
request_idserver panics, and browser exceptions whose trail includes a failed callThe id the API already returns in its error envelope, so one incident is findable in PostHog and in the backend's logs
http_route, http_methodserver panicsThe route pattern, never the raw path: one issue per route instead of one per record, and a probe for /wp-admin cannot mint issues
service, environment, releaseeverythingWhich process, which deployment, which build

Browser exceptions also carry a trail of steps: the routes visited and the API calls that failed just before, each with its method, path, status and request id. In the dashboard and the admin panel the exception also sits next to the session replay it happened in, when WARMBLY_POSTHOG_SESSION_REPLAY is on; see the analytics section below for what those two surfaces record.

The admin panel and the dashboard also tag events with the build they were served from. That value is baked at image build time, because it has to match the release the source maps were uploaded under, so it cannot be changed by a container variable afterwards.

Source maps

A browser stack trace is minified without them. Uploading them is optional and off unless the build is given credentials, so a fork, a self-host build and a local pnpm build emit no source maps at all and the shipped bundle is identical.

Build variableBackendWhat it does
POSTHOG_CLI_API_KEY, POSTHOG_CLI_PROJECT_IDPostHogBoth set makes the build run pnpm sourcemaps:posthog after vite build, which injects a chunk id, uploads, and deletes the .map files it sent. The web, admin and forms images all do this, so a form page's stack trace is readable too
POSTHOG_CLI_HOSTPostHogThe instance to upload to, for PostHog EU or a self-hosted one
SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECTSentryAll three set puts the Sentry Vite plugin in the build

In this repository's release workflow the two project ids are repository variables and the two keys are repository secrets, passed to the image build as build secrets. Nothing in the app has to match a release name: PostHog pairs a stack frame with its map through the chunk id the CLI injected into the served file, so an event from any build resolves as long as that build's maps were uploaded.

A static host builds with pnpm build:pages instead of an image, and that runs the same upload when the same two variables are set in the host's build environment. Setting them there matters more than it looks: with no maps uploaded, PostHog falls back to fetching <bundle>.js.map from the site itself, a static host answers that with its SPA fallback, and every frame in every issue reads Invalid source map: bad json beside a minified function name.

Product analytics and session replay

These are the same two keys as the error tracking above, and they exist for the hosted service. A self-hosted instance that sets no key loads no analytics and sends no usage data: the installer never asks about them, and a build with none of them set contains no analytics script to block. An instance that does set a key gets everything below.

VariableRead byWhat it doesDefault
POSTHOG_KEYbackendServer-side product events (a completed signup, a started trial, a started subscription), under the account id of the person they happened to, with the workspace as a groupunset
POSTHOG_HOSTbackendCapture host. Point it at a self-hosted PostHog if you run onehttps://us.i.posthog.com
WARMBLY_POSTHOG_KEYweb, admin containersBrowser analytics and session replay, read at container start. Unset means the SDK chunk is never fetchedunset
WARMBLY_POSTHOG_HOSTweb, admin containersCapture host for the browserhttps://us.i.posthog.com
WARMBLY_POSTHOG_SESSION_REPLAYweb, admin containersfalse keeps the analytics and records no sessionstrue
PUBLIC_POSTHOG_KEYsite/ buildMarketing site analytics. Build-time: unset produces a site with no analytics code in it at allunset
PUBLIC_POSTHOG_HOSTsite/ buildCapture host for the sitehttps://us.i.posthog.com

What each surface sends, with a key set:

SurfaceIdentityCaptured
DashboardThe signed-in account (identify with email and name) and its workspace as an organization groupPageviews and pageleaves, autocapture of every click, heatmaps, rage and dead clicks, web vitals and network timing, session replay with console logs, surveys, exceptions, and the named events mailbox_connected and campaign_launched
Admin panelThe signed-in operatorThe same set, so a slow screen or a broken action in the operator surface is findable the same way
Marketing siteNobody. Cookieless: nothing is stored in the browser and no person is ever createdPageviews and pageleaves with scroll depth, autocapture, heatmaps, rage and dead clicks, web vitals, exceptions. No session replay: the SDK has no session in cookieless mode, and one would need a consent banner
Public form pagesNobody. Cookieless, for the stranger filling in a customer's formPageviews, autocapture, heatmaps, web vitals, exceptions and the funnel events form_viewed, form_started and form_submitted, each carrying the form's public id. Never recorded

The person in the dashboard and the person on a server-side event are the same account id, so a signup counted by the backend, the session that led to it and the subscription the Stripe webhook later reports are one funnel. The acquisition parameters the marketing site carries into the signup link land on that person write-once at signup. Countries come from PostHog's own GeoIP on the address the event arrived from; the /ingest proxy above forwards the visitor's address for exactly this, and the server-side events send the signup request's address along.

Session replay masks what is typed into a password field in the browser, before anything is sent. Every other input and every screen is recorded, which is what makes a replay answer "what exactly happened", and it is also why the hosted service's privacy page describes it in those words.

Push

VariableWhat it doesDefault
APNS_KEY or APNS_KEY_PATH, APNS_KEY_ID, APNS_TEAM_ID, APNS_TOPICMobile push on backend and consumer. Partial configuration disables push with a warning, never a crashunset

Updates

VariableWhat it doesDefaultRestart needed
UPDATE_CHECK_ENABLEDPolls GitHub Releases for a newer Warmbly and shows it in the admin panel's top bar and on Setup and healthtrueyes
UPDATE_CHECK_INTERVALHow often the release check runs. Minimum 5m30myes
UPDATE_CHANNELstable follows releases; dev also offers prereleasesstableyes
RELEASES_GITHUB_REPOThe owner/repo whose releases count as Warmbly versions. Point a fork's instance at the forkwarmbly/warmblyyes
RELEASES_GITHUB_TOKENOptional GitHub token; only raises the API rate limitunsetyes
UPDATER_URLThe host-side updater that applies an update (pull, rebuild, restart). Unset, or none, leaves the panel report-onlyhttp://updater:8095 under composeyes
UPDATER_TOKENThe bearer token the backend presents to the updaterINTERNAL_API_TOKENyes

The updater's own variables (UPDATER_MODE, UPDATER_COMMAND, UPDATER_REPO_DIR and the rest) are on Updates; it is a separate process and its environment is not on the configuration page.

Forms service

The public face of hosted forms (cmd/forms): it serves the React (TanStack) form app built from forms/, the per-form page shells, the embed loader and public submissions, on its own port so form traffic never touches the API origin. It reads its own environment; none of these appear in the admin panel except FORM_IP_RATE_LIMIT.

VariableWhat it doesDefault
FORMS_PORTListen port8090
FORMS_STATIC_DIRThe built forms app (pnpm build output). The service exits at boot when index.html is missing thereforms/dist
BACKEND_INTERNAL_URLWhere the service resolves forms and forwards submissions, the same variable the tracking service uses. Required: the service exits at boot without itnone
INTERNAL_API_TOKENBearer token for those calls, matching the backend's. Required: the service exits at boot on an empty valuenone
FORM_IP_RATE_LIMITPublic form submissions allowed per source IP per 10 minutes, per forms-service instance30
POSTHOG_KEY, POSTHOG_HOST, POSTHOG_ERROR_TRACKINGThe service's own error tracking. Unset means noneunset
SENTRY_DSNThe same through Sentry, alongside PostHog or instead of itunset
WARMBLY_POSTHOG_KEY, WARMBLY_POSTHOG_HOST, WARMBLY_POSTHOG_ERROR_TRACKINGStamped into the form page so the browser app reports too. Unset, or error tracking off, means the page loads no reporting SDK at allunset
WARMBLY_SENTRY_DSNThe Sentry half of the same stampingunset
TRUSTED_PROXIESCIDRs whose X-Forwarded-For the service believes, same convention as the backend. Empty trusts nothing and uses the socket peer; set it behind a reverse proxy or the submit limiter throttles the proxy's address instead of the visitor'sempty

Tracking service

The Rust open and click service. It reads its own environment, so these have to be set on that container, and none of them appear in the admin panel.

Run a single copy of it. Its event deduplication, its per-source rate limits and its click-ticket caches are per process and in memory, with no shared store behind them, so a second replica does not halve the work: it doubles the budgets a flooding source gets and lets the same open be published twice. One container handles the pixel and click load of a large instance comfortably; scale the workers instead. If you must run more than one, put a sticky-by-source-address hash in front of them, which keeps a given source on one process and restores both properties.

VariableWhat it doesDefault
TRACKING_HOST, TRACKING_PORTListen address0.0.0.0, 3000
BACKEND_INTERNAL_URLWhere tracking resolves opaque /c/<id> click tickets. Required: the service exits at boot without itnone
INTERNAL_API_TOKENBearer token for that lookup. Required: the service exits at boot on an empty valuenone
TRACKING_RATE_LIMIT_PER_MINCounted pixel and click requests per source per minute. Over budget, pixels are still served but not counted, and click redirects get 429300
TRACKING_PAGEHIT_RATE_LIMIT_PER_MINWebsite page views accepted per source per minute, on top of the shared budget above. Over budget, the snippet gets 42960
TRACKING_TRUSTED_PROXIESCIDRs the tracking service accepts a forwarded client address from. Empty trusts nothing and uses the socket peer, which is correct for a directly exposed service; set it behind a reverse proxy or the per-source rate limits and the location stored with page views are caller-controlled. Same convention as the backend's TRUSTED_PROXIESempty
TRACKING_IP_HASH_KEYSecret the source-address token in tracking events is keyed with. The token names one source for deduplication, rate limits and the click burst rule; keyed, it cannot be turned back into the address by enumerationINTERNAL_API_TOKEN
TRACKING_CLIENT_IP_HEADERThe one header a trusted proxy sets with the client address. No other header is read, so a caller cannot smuggle an address past a generic proxy in CF-Connecting-IP. For x-forwarded-for the proxy-appended last entry is used; set cf-connecting-ip behind Cloudflarex-forwarded-for
TRACKING_SCANNER_BUILTINSWhether the known-scanner catalogue shipped with Warmbly is loaded: Microsoft 365's Exchange Online Protection ranges and Barracuda's Email Gateway Defense blocks. false leaves only your own entriestrue
TRACKING_SCANNER_NETWORKSExtra scanner sources, comma separated, each a CIDR or asn:<number> with an optional label. For sources that never carry a person's own request, so their pixel fetches and their clicks are both treated as automatedempty
TRACKING_SCANNER_CLICK_NETWORKSThe same, for sources that also proxy a mail client's own image fetches. Only their click tickets are treated as automated, because their pixel fetches are genuine opensempty
TRACKING_SCANNER_ASN_DBPath to a MaxMind GeoLite2-ASN database, which is what makes asn: entries match on an instance whose edge writes no ASN header. A separate file from GEODB_PATH; unset or unreadable logs one line at boot and leaves ASN matching off. It removes the need for an edge header, not for TRACKING_TRUSTED_PROXIES: the lookup reads the client address, and behind a reverse proxy without that variable the client address is the proxyunset
TRACKING_SCANNER_ASN_DB_URLWhere to download that database from when no file is mounted at the path above. The result is held in memory and never written out, so this needs no writable path and no volume. Carries a licence key on a MaxMind permalink, so it is treated as a secret and never logged. See GeoIPunset
TRACKING_SCANNER_ASN_HEADERHeader a trusted proxy sets with the source ASN, an alternative to the database above and preferred over it where both are set. Read only from a proxy in TRACKING_TRUSTED_PROXIES. On Cloudflare, a transform rule writing ip.src.asnumempty
EVENTBUS_PROVIDERnats or kafka. Kafka needs the tracking:*-kafka image, or a build with CARGO_FEATURES=kafkanats
NATS_URL, NATS_SUBJECT_PREFIXJetStream address and subject prefix. The publish subject is <prefix>.<topic>nats://localhost:4222, warmbly
CODEC_PROVIDERjson or avro, and it has to match the consumer's. avro is refused at boot without SCHEMA_REGISTRY_URLjson
KAFKA_TRACKING_TOPICEvent topic, read by the Rust publisher and the Go subscribertracking-events
KAFKA_BOOTSTRAP_SERVERS, KAFKA_SASL_USERNAME, KAFKA_SASL_PASSWORDBroker transport when EVENTBUS_PROVIDER=kafkaunset
SCHEMA_REGISTRY_URL, SCHEMA_REGISTRY_KEY, SCHEMA_REGISTRY_SECRETRegistry for the Avro codecunset
AWS_CONFIG_ENABLEDtrue falls back to AWS SSM and Secrets Manager for any value missing from the environmentfalse
APP_ENVEnvironment label used in logs, and on reported errorsdev
POSTHOG_KEY, POSTHOG_HOST, POSTHOG_ERROR_TRACKINGError and panic reporting. Unset means noneunset
SENTRY_DSNThe same through Sentry. An invalid value logs and disables it rather than stopping the serviceunset
WARMBLY_RELEASEThe build reported errors are tagged withdev

A catalogue entry is <source> <scope> <certainty> <label>. Scope is which requests it may judge: all for a network that never carries a person's own request, clicks for one that also proxies a mail client's image fetches, where a pixel fetch is a genuine open. Certainty is how much the match settles, and defaults to certain: the source only ever filters mail, so a match is the whole verdict. probable says the source can also carry a person, which browser isolation makes true of the mail-security vendors, and a match then widens the consumer's machine window (tracking.machine_window_probable_seconds) instead of deciding. The two columns are independent. A probable entry moves events in one direction only, from counted-as-human to counted-as-automated, so it can never let a scan count as engagement; outside the window the event is judged exactly as an unrecognised one would be. The cost is the other side of that: a recipient who really does click inside the window is classified automated, and separately, a click from any recognised network is redirected without its identification ticket, because the edge has no dispatch time and cannot tell the delivery-time scan from the isolated human click the way the consumer can. So an isolated click the consumer later counts as a person's still leaves no website-visit attribution for that contact.

asn: entries need the source ASN, and there are two ways to have it. TRACKING_SCANNER_ASN_DB resolves it from the address itself and needs no ASN header and no transform rule, which is the option for an instance behind nginx, Caddy, a cloud load balancer, or exposed directly. TRACKING_SCANNER_ASN_HEADER takes it from a header your edge writes, which on Cloudflare is one transform rule. Where both are configured the header wins on any request that carries one, so adding the database never changes a verdict your edge has already given; a request the header does not cover falls through to the database.

Neither removes the need for TRACKING_TRUSTED_PROXIES behind a reverse proxy, for different reasons. The header is read only from a peer listed there, so a header with that variable empty resolves nothing at all. The database reads the client address, and with that variable empty the client address is the proxy's, so the lookup returns the proxy's network rather than the requester's. With no usable source at all, the ASN half of the catalogue is inert and the tracking service says so at boot, naming both.

Which entries ship on, and how, follows from that. Microsoft 365's Exchange Online Protection ranges and Barracuda's Email Gateway Defense blocks are the mail-filtering tier itself, where no recipient ever reads their mail, so they are certain and settle the verdict outright. Proofpoint, Mimecast and Cisco Secure Email are probable and matched by ASN: they are pure mail-security networks, but Proofpoint Isolation and Mimecast Browser Isolation render a clicked page in the vendor's own cloud and stream it to the recipient, so a click from there can have a person behind it. Isolation is usually scoped to uncategorised URLs, which is what a new outreach domain looks like. The probable window is what makes them safe to ship on: the scan runs within minutes of the send, and the isolated click runs whenever the person got to it.

The rest of Microsoft's and Google's networks stay commented out even as probable, because they are whole cloud allocations rather than mail-security networks. A recipient on Windows 365, Azure Virtual Desktop or a corporate NAT gateway sits inside them all day, so a match says almost nothing about who is asking. tracking/scanner-networks.txt documents every entry and is the place to change them.

Realtime service

The Elixir websocket service. Its runtime configuration is read only when the release boots in prod, which is how the shipped image runs. Like tracking, it reads its own environment and appears nowhere in the admin panel.

VariableWhat it doesDefault
JWT_SECRETMust equal the backend's AUTH_SECRET. Required: the service refuses to boot without itnone
SECRET_KEY_BASEPhoenix session signing. Requirednone
DATABASE_URLPostgres, used to validate API keys. Requirednone
REDIS_URLThe Redis bridge the backend publishes events ontoredis://localhost:6379/0
PHX_HOSTThe service's own hostnamelocalhost
PORTListen port4000
CHECK_ORIGIN_HOSTSOrigins a browser may open a socket from, comma separated, e.g. https://app.example.com,https://admin.example.com. Not this service's own hostunset
CHECK_ORIGINLegacy host-based form, consulted only when CHECK_ORIGIN_HOSTS is empty. true accepts an upgrade only from PHX_HOSTfalse
PUBSUB_ENABLEDtrue swaps the Redis bridge for Google Pub/Subfalse
GCP_PROJECT_IDRequired when PUBSUB_ENABLED=true; the service refuses to boot without itunset
MAX_CONNECTIONS_PER_USERConcurrent sockets one account may hold. The caller's plan limit applies too, whichever is lower10
MAX_CONNECTIONS_PER_IPConcurrent sockets from one address50
MAX_CONNECTIONS_GLOBALConcurrent sockets on this node100000
RATE_LIMIT_WS_MESSAGEWebsocket messages per minute120
RATE_LIMIT_WS_CONNECTSocket handshakes per minute30
RATE_LIMIT_WS_JOINChannel joins per minute, counted per phx_join on an open socket30
RATE_LIMIT_WS_EVENTClient events per minute, which is what bounds presence updates60
POSTHOG_KEY, POSTHOG_HOST, POSTHOG_ERROR_TRACKINGError reporting. Unset means noneunset
SENTRY_DSNThe same through Sentry. An empty string is treated as unset on purpose, because the library rejects "" hard enough to take the node downunset
WARMBLY_RELEASEThe build reported errors are tagged withdev

The origin check is off until you list your origins

With neither variable set, a websocket upgrade is accepted from any origin. A short-lived ticket is still required to open the socket, and channel membership is checked on every join, so an attacker needs a real credential either way. On a deployment reachable from the internet, set CHECK_ORIGIN_HOSTS to your dashboard and admin origins.

List the origins the browser connects from, not the websocket host. The Origin header on an upgrade names the page that opened the socket, which is the dashboard. Setting CHECK_ORIGIN=true and pointing PHX_HOST at the websocket hostname refuses every real connection, which is the trap this variable replaces. The installer sets CHECK_ORIGIN_HOSTS correctly for you.

Settings stored in the database

These are the only settings a browser can change, and no environment variable owns any of them. They live in the admin panel under Instance > Configuration > Settings (/configuration?tab=settings). Reads are cached for 30 seconds in each process, so a change takes effect everywhere within that window.

SettingTypeDefaultWhat it does
invitations.ttl_hoursinteger, 1 to 720168How long a new invitation stays valid. Read when the invitation row is written, so it applies to invitations created after the change, not to existing ones
invitations.links_enabledbooleantrueWhether the copyable invitation link is returned at all. Off makes GET /organization/invitations/:id/link return 404 with an explanation, so an invitation can only arrive by mail
access.allow_invited_signupbooleantrueWhether holding a live invitation lets someone create their own account under invite_only. Off means an administrator creates every account with warmblyctl user create
sync.backfill_daysinteger, 1 to 73090How far back a newly connected mailbox's initial import reaches, newest first
sync.backfill_messagesinteger, 1 to 1000005000The most messages that import stores per mailbox
sync.daily_messages_per_mailboxinteger, 1 to 1000002000New (live) messages one mailbox may store per UTC day. Over it, mail waits for the next day; replies to the mailbox's own sends have a separate budget of the same size
sync.daily_messages_per_orginteger, 1 to 200000025000New plus imported messages one organization may store per UTC day
retention.engagement_event_daysinteger, 1 to 3650365How long the per-event open and click logs (client, device, approximate location) are kept. Campaign progress keeps its own summary that outlives them, so counts, filters and branching never change
retention.form_event_daysinteger, 1 to 3650180How long form funnel events (views, starts, field-level drop-off) are kept. Funnel reports range up to 90 days, so anything shorter shortens the report too
retention.audit_log_daysinteger, 1 to 365090How long the audit trail is kept. It carries IP addresses, user agents and change payloads, so this is also how long that data is held
retention.warmup_mail_daysinteger, 3 to 365030How long warmup mail stays in each mailbox before Warmbly deletes it, wherever the mailbox's filing setting keeps it (Trash on Gmail), with the stored copy of its body. A mailbox can set its own window on the Warmup tab of its drawer or through warmup_retention_days on the API; this is what every other mailbox follows. The floor leaves room for the engagement and for a reply in the thread to finish
retention.warmup_event_daysinteger, 30 to 3650365How long the per-message warmup records (tokens, receipts, tampering events, spam reports) are kept. The pool health bands read the last 30 days, which is the floor. The daily sent and received counts behind the warmup analytics are separate and never pruned
tracking.machine_window_open_secondsinteger, 1 to 90060How soon after a send was dispatched an open is recorded as automated rather than a person's. The clock starts when the send is handed to a worker, so this window also covers the sending provider's queue and the transit to the recipient, not just reading time. Raise it when delivery-time scanners are being counted as opens; lower it when recipients who read immediately are being missed. A change applies within a minute and only to events recorded after it
tracking.machine_window_click_secondsinteger, 1 to 90030The same window for click tickets. Kept separate because the two mistakes cost different things: a misjudged open loses a metric, a misjudged click loses the automation behind an interested lead
tracking.machine_window_probable_secondsinteger, 1 to 86400600The window used instead of the two above when the tracking service recognised the source as a mail-security network that also renders clicked pages for people, which Proofpoint, Mimecast and Cisco do through browser isolation. Such a match cannot settle whether a person is behind the request, so it widens the window rather than deciding: inside it the event is the delivery-time scan, past it the recipient who got to the mail later. Never applied shorter than the window for the kind of event in hand, so naming a network can only ever catch more scans. Its ceiling reaches a day because how long a vendor takes to detonate a link is the vendor's property, not the instance's
deliverability.enforce_domain_authbooleantrueWhether a sending domain that fails SPF or DMARC stops cold campaign sending and warmup sending from every mailbox on it. Off keeps the check running and still shows the state and the Advisor card, it just never blocks
deliverability.auth_grace_hoursinteger, 1 to 72072How long a domain must stay failing before the gate applies. The clock starts when the background check first sees the failure, so this is also how much warning the owner gets
placement.tests_per_month_trialinteger, 1 to 1000003How many free placement tests a workspace on a free trial may run per calendar month on the instance seed panel. Only an instance running with DEPLOYMENT_MODE=cloud meters; a self-hosted instance never counts its own tests. Tests on a workspace's own seed inboxes are never counted
placement.tests_per_month_paidinteger, 1 to 10000040The same free allowance for a workspace on a paid plan. Past it, a test costs placement.credits_per_test credits
placement.seeds_per_testinteger, 1 to 10020The most seed inboxes one test sends to. Every copy is a send from the sending mailbox's daily limit, so this is also how much of that limit one test takes. A test is sized down to what the mailbox has left that day, and refused below five seeds
placement.spacing_secondsinteger, 5 to 60060The average gap between two copies of a test from one mailbox, varied by up to a third either way. A test started as quick uses the smaller of this and 8 seconds
placement.credits_per_testinteger, 0 to 1000025What a test on the instance panel costs in credits once a paid workspace has used its free tests for the month. The workspace agrees to the price before each paid test, and a test that delivers no copy is refunded. 0 turns paid tests off. Only an instance running with DEPLOYMENT_MODE=cloud meters, so a self-hosted instance never charges
placement.batch_senders_maxinteger, 1 to 10000010000The most sending mailboxes one placement batch may hold. An infrastructure safeguard on batch size only; how many run at once is the next setting
placement.batch_sender_concurrencyinteger, 1 to 50020How many of one workspace's batch mailboxes may be sending their copies at the same time, across all its batches. The next mailbox starts when one has sent every copy; each mailbox keeps its own daily limit and minimum gap
placement.batch_instance_concurrencyinteger, 1 to 5000200The same limit across every workspace together. It bounds how much batch mail the instance seed panel takes in at once, so a seed never receives enough in an hour to trip the sync flood rule, which deactivates a mailbox
placement.batch_starts_per_minuteinteger, 1 to 60010How many mailboxes one batch starts per minute, so a large batch ramps up instead of starting its whole concurrency at once

The four sync.* values are read by the backend when a mailbox is loaded onto a worker (on connect, on reassignment, and by the reconciler's periodic republish), so a change reaches every mailbox within 30 minutes without a restart (the republish gives each mailbox one turn per 30 minutes, spread out so no worker gets them all at once). The fixed pacing numbers around them (burst per five minutes, hourly, backfill pace, the flood threshold and the chronic-overage rule) are compiled constants listed under Instance > Configuration > Effective limits; see Mailboxes for how the budgets behave. The four sync.* values also have a companion read view on Operations > Sync, which shows each mailbox's backfill progress and fair-use throttle against them and can clear a throttle or restart a backfill.

The five retention.* values are read by the pruning sweeps on every pass, so shortening one takes effect on the next sweep rather than at the next restart. Deletion is permanent and there is no grace period: what already sits outside a shortened window goes on that sweep. The warmup mail window is the one that reaches into customers' mailboxes: the consumer retires each message and the worker holding the mailbox deletes it there, so shortening it empties the warmup folders of the whole instance down to the new window within a few hours. See data control for what each log holds and what a shorter window costs.

An unattended install can seed the whole document before anyone signs in, with WARMBLY_SETTINGS_BOOTSTRAP holding the same partial JSON the admin API takes:

WARMBLY_SETTINGS_BOOTSTRAP={"sync":{"backfill_days":30},"retention":{"audit_log_days":30}}

It is applied only while the settings row has never been written, so from the first save in the panel onwards the panel is authoritative and the variable is a no-op. That is what install.sh --wizard writes when you answer its retention questions.

The two deliverability.* values are read on every scheduling pass and every warmup send, so turning the gate off releases blocked mailboxes within the 30 second cache window. Turning it back on does not stop anything retroactively: a domain still has to spend its whole grace window failing first. Only a sustained failure gates, so a domain reading unknown (never checked, DNS could not answer, or a special-use domain that cannot resolve) always sends. Manual sends and unibox replies are never gated; see domain authentication.

The placement.* allowance, seed and spacing values are read when a test starts, so a change applies to the next test and never to one already sending. The four placement.batch_* values are read on every pass of the batch runner, about every 30 seconds, so a change reaches running batches within a minute; lowering the concurrency never stops a mailbox already sending, it only delays the next start. A value of zero or below resolves to the default, and one above the range to its ceiling. Which mailboxes form the instance seed panel is not a setting: an administrator marks them from the admin panel.

Changing them is audited, and every value is validated and clamped server side on write as well as on read, so a row written by an older version still resolves.

Variables that do not do what their name suggests

VariableWhat actually happens
KAFKA_CLUSTERNothing. A loader exists but no caller does. Remove it
SENTRY_DSN_APINothing, for the same reason. Superseded by SENTRY_DSN
CAPTCHA_PROVIDERRead, but derived when unset rather than defaulting to a constant. See captcha

See also

On this page