Configuration reference
Every environment variable SenditCold reads, what it does, its default, and whether changing it needs a restart.
The environment is authoritative. SenditCold 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
.envnext todocker-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=environmenton a stock install). Each row shows the variable name, the value SenditCold actually resolved, where it came from (env,default,derivedorunset), 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
| How | What you get |
|---|---|
| Instance > Configuration > Environment in the admin panel | The backend's entries with resolved value, source, group and restart requirement |
make doctor | The health checks from a shell, including the configuration problems they detect |
GET /admin/instance/config | The 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
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
APP_ENV | dev or prod. dev tolerates the published default secrets and turns on Gin debug logging. Set prod for anything other people can reach | dev | yes |
DEPLOYMENT_MODE | self_hosted or cloud. Picks the auth defaults in authentication; every one stays individually overridable. A joining node receives the mode its control plane resolved | self_hosted under compose | yes |
ALLOW_INSECURE_DEFAULTS | true lets the backend boot even when a secret still holds its published default. Only for a throwaway instance | unset | yes |
GIN_MODE | debug or release | release | yes |
ENV_LABEL | A label the admin panel shows next to the instance name | unset | yes (container start) |
WARMBLY_CLOUD_URL | The SenditCold Cloud API an already linked self-hosted instance talks to for the hosted warmup pool (guide). SenditCold Cloud links no new instances. Outbound HTTPS only | https://api.senditcold.com | no |
INSTANCE_NAME | The name an instance gave SenditCold Cloud when it linked. SenditCold Cloud no longer links new instances, so it has no effect on one that is not linked | the hostname | no |
WARMBLY_VERSION | Version string reported to SenditCold Cloud on link requests | dev | no |
WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS | true lets customer webhooks point at http:// and private addresses. Honoured only on a self-hosted (DEPLOYMENT_MODE=self_hosted) or development (APP_ENV=dev) instance; a hosted production instance ignores it. It lets any workspace member make the backend reach into your internal network, so only set it where you control that network | false | yes |
APP_VERSION | Release name written into a workspace export archive's manifest (source_app_version). Informational only: an import never keys off it | unset | yes |
In Gin release mode, access logs omit successful health probes and internal GET lookups, including an expected missing email-message mapping. Authentication failures, server errors, other missing resources and all mutations remain logged. Gin debug mode keeps every access log. Query strings and credential path parameters remain redacted in both modes; organization audit events are unaffected.
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.
| Variable | Format | What it protects | Restart needed |
|---|---|---|---|
AUTH_SECRET | 32 characters or more, enforced at boot | JWT 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. By default it also supplies the MFA sealing, unsubscribe signing and DNS ownership-proof keys. Configure the separate purpose keys below before rotating sessions when those values must remain stable | yes |
INTERNAL_API_TOKEN | any random string | The backend's /api/v1/internal/ routes the tracking and forms services call (click tickets, root redirects, page views, hosted forms). Fleet nodes authenticate with it too when NODE_BROKER_TOKEN is unset | yes |
NODE_BROKER_TOKEN | any random string | Every internal route only a fleet node calls: organization data keys (read, store and open), blob signing, provider access tokens for a mailbox SenditCold Cloud manages, the message map and sync lookups, worker config and the node heartbeat. Optional, and falls back to INTERNAL_API_TOKEN. The same value has to be set on the control plane and on every node, or those routes answer 401; a node joined after it is set receives it in node.env in place of INTERNAL_API_TOKEN, and a node joined before has to join again. The installer generates one for a new install; an existing install keeps the shared token until you set one. Each node also sends its own NODE_CREDENTIAL, and with NODE_REQUIRE_NODE_CREDENTIAL=true only that is accepted | yes |
NODE_ACCEPT_INTERNAL_TOKEN | true or unset | While true, the node-only routes other than the key and blob brokers also accept INTERNAL_API_TOKEN. An upgrade does not need it: a heartbeat sent with INTERNAL_API_TOKEN is always answered with the node's version and nothing is recorded, so an older node updates itself. Set it only to keep older nodes syncing until they have updated, then remove it: while it is on, the tracking and forms services' token reaches those routes | no |
NODE_LOG_TOKEN | enrollment-issued 43-character base64url secret, different per node | Only node-specific log ingestion. Issued in the protected fleet join environment, hashed in primary Redis, never derived/exchanged from the fleet-wide broker/internal credential. Rejoin rotates only this evidence credential; deletion revokes it. Deploy backend before upgrading nodes, then rerun authenticated join using the same config directory and UUID, keeping local overrides. No fleet deletion, mailbox reassignment or manual secret copying is required; the script restarts the service. Upgrade alone does not fill unavailable coverage and no historical logs are backfilled. Never share one value fleet-wide | yes |
NODE_CREDENTIAL | join-issued 43-character base64url secret, different per node | Proves which node is asking. A node sends it with its id (X-Warmbly-Node-Id and X-Warmbly-Node-Credential) on every internal route only a node calls: the key and blob brokers, data keys, the message map, sync lookups, worker config, the heartbeat and its env. The backend checks it against the node's enrolled row, names that node in the broker access log, and lets a node heartbeat and fetch an env only as itself. A worker admitted this way acts only on what it is assigned: its own worker id, and the mailboxes, organizations, data keys and mailbox or campaign blobs of the mailboxes placed on it, plus those moved off it within the last 30 minutes so in-flight work finishes (architecture). A consumer serves every organization. Issued in the protected fleet join environment; only its SHA-256 is stored, in the fleet_nodes table, so nothing a node can reach can rewrite it. A rejoin replaces it and deleting the node revokes it. A node joined before this setting existed holds none and falls back to the fleet token until it joins again (same config directory, so it keeps its id and node.local.env); a consumer's env is handed out only against it. Kept across an env refresh; never copy it to another machine | yes |
NODE_REQUIRE_NODE_CREDENTIAL | true or unset | Backend only. While true, the node-only internal routes accept only a node's own NODE_CREDENTIAL: NODE_BROKER_TOKEN, INTERNAL_API_TOKEN and the update-only heartbeat are refused with 401. Unset keeps today's behaviour, where a node without a credential is still admitted on the fleet token. Turn it on only after every node runs a release that sends its credential and none was joined before node credentials existed: the backend logs node heartbeats without its own node credential once per node still on the fleet token, so an empty log after a full beat interval is the signal. Turning it off again restores fleet-token access at once | no |
SECRET_KEY_BASE | 64 characters or more | Phoenix session signing in the realtime service | yes |
KMS_LOCAL_MASTER_KEY | base64, exactly 32 bytes | The root key that seals every per-organization data key | yes |
CREDENTIALS_ENCRYPTION_KEY | exactly 64 hex characters | Mailbox credentials at rest: SMTP and IMAP passwords, and Gmail and Outlook OAuth access and refresh tokens | yes |
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
EOFmake 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:
| Value | Read by | If it drifts |
|---|---|---|
AUTH_SECRET, seen by realtime as JWT_SECRET | backend, realtime | The dashboard loads but never goes live: the websocket rejects every token |
INTERNAL_API_TOKEN, seen by workers as ENCRYPTED_KEYS_WORKER_TOKEN | backend, worker, tracking | Workers cannot fetch decryption keys and tracking cannot resolve click tickets. Both fail closed with 401 |
KMS_LOCAL_MASTER_KEY | backend, consumer, and a worker run outside the fleet (a joined worker opens keys through the backend instead) | Sealed data keys cannot be opened, so mailbox credentials stop decrypting |
CREDENTIALS_ENCRYPTION_KEY | backend, consumer | Stored 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.
Rotate instance keys
These settings are optional. An existing self-host keeps its current keys and behavior when they are unset. All list values are JSON arrays with at most four nonempty strings; [] means no additional keys. Keep them in your secret store or private .env.
| Variable | Purpose | Default |
|---|---|---|
CREDENTIALS_ENCRYPTION_READ_KEYS | Additional 64-character hex keys that can decrypt instance credential ciphertext. New writes use only CREDENTIALS_ENCRYPTION_KEY | unset |
TWOFA_READ_SECRETS | Additional secrets that can open existing TOTP enrollments. New enrollments use TWOFA_SECRET | unset |
UNSUBSCRIBE_SIGNING_SECRET | Signs recipient unsubscribe links independently of sessions. At least 32 characters | AUTH_SECRET |
UNSUBSCRIBE_SIGNING_READ_SECRETS | Previous secrets accepted only for unsubscribe signatures, with the cutoff below | unset |
UNSUBSCRIBE_SIGNING_READ_UNTIL | RFC3339 cutoff required with unsubscribe read secrets. Both the verification time and the signed link expiry must be within this window | unset |
DOMAIN_PROOF_SECRET | Keys _warmbly DNS ownership proofs independently of sessions. At least 32 characters | AUTH_SECRET |
INTERNAL_API_PREVIOUS_TOKENS | Previous bearer tokens for the tracking and forms routes, accepted only before the cutoff below | unset |
INTERNAL_API_PREVIOUS_UNTIL | RFC3339 cutoff for previous internal tokens. Missing, malformed or elapsed deadlines reject previous tokens | unset |
NODE_BROKER_PREVIOUS_TOKENS | Previous bearer tokens for fleet broker routes, accepted only before the cutoff below. These retain broker privileges during the window | unset |
NODE_BROKER_PREVIOUS_UNTIL | RFC3339 cutoff for previous broker tokens. Missing, malformed or elapsed deadlines reject previous tokens | unset |
PORTAL_EDGE_PREVIOUS_TOKENS | Previous bearer tokens for the portal service's routes, accepted only before the cutoff below | unset |
PORTAL_EDGE_PREVIOUS_UNTIL | RFC3339 cutoff for previous portal service tokens. Missing, malformed or elapsed deadlines reject previous tokens | unset |
Changing these settings requires restarting the affected service. Internal-token deadlines are checked on every request, so old tokens stop working at the cutoff without another restart. Tracking and forms overlap does not grant fleet broker access, and portal service overlap opens only the portal service's routes. JWT verification uses only AUTH_SECRET; encryption and unsubscribe read keys never authorize sessions.
For encryption rotation, first deploy a compatible release everywhere with the existing primary key and the replacement included in its read-key list. Confirm that all readers use that baseline before a later deployment activates the replacement primary and retains the old key for reads. Then reseal stored values with warmblyctl keys reseal: run it once without --apply to see the counts, then with --apply, and repeat until warmblyctl keys status shows nothing left under a read key. It is resumable and never overwrites a value the backend changed meanwhile. warmblyctl keys generate prints replacement values in the right format. The supported rollback floor during rotation is that compatible baseline release, using keys that read both generations. A binary rollback keeps the current compatible key configuration; reverting a primary key alone does not migrate data.
Retain old keys until stored ciphertext, retained events, pending jobs and the supported rollback window no longer need them. Keep the keys required by older backups for as long as those backups are retained. Organization DEKs and their KMS wrapping keys have a separate lifecycle; changing the instance credential key does not rotate them.
To rotate sessions while preserving existing MFA enrollments, unsubscribe links and published DNS proofs, first pin TWOFA_SECRET, UNSUBSCRIBE_SIGNING_SECRET and DOMAIN_PROOF_SECRET to their existing resolved values. Then change AUTH_SECRET and realtime's matching JWT_SECRET together. Users sign in again. Rotate the independent purpose keys separately; issued unsubscribe links remain valid for one year, so their read cutoff must cover the last previously issued link's expiry.
Consumer nodes hold CREDENTIALS_ENCRYPTION_KEY and CREDENTIALS_ENCRYPTION_READ_KEYS, and every node holds INTERNAL_API_TOKEN or NODE_BROKER_TOKEN, in node.env. Workers are never sent the credential keys. A node that keeps its env current receives each step of a rotation on its own; wait for warmblyctl fleet env-status to report every node current before activating a new credential key, and again before retiring an old key or token. A node still on the old values keeps sealing and authenticating with them. The Keys tab under Configuration in the admin panel shows the same three answers in one place: values still under a read key, open overlap windows and nodes that have not applied the current env. On AWS, the rotation workflow runs the whole procedure; see Rotate keys.
Seal mailbox credentials under workspace keys
MAILBOX_CREDENTIALS_SEALING chooses the key that seals newly stored mailbox credentials: SMTP and IMAP hosts, usernames and passwords, and OAuth access and refresh tokens.
| Value | Stored credentials | Credentials sent to workers |
|---|---|---|
instance (default) | Sealed with CREDENTIALS_ENCRYPTION_KEY | In the worker command, protected by the event bus |
organization | Sealed with the workspace's KMS-protected data key | Sealed with the same workspace key, opened only on the worker that runs the mailbox |
Every release that reads this setting opens both formats, so the setting only decides what new writes use. With organization, the consumer also moves credentials already stored under the instance key onto workspace keys, in small resumable batches, and skips a mailbox whose stored value no key opens. With instance, the same consumer job seals any OAuth token still stored in plaintext under CREDENTIALS_ENCRYPTION_KEY. Workspace keys are cached in each process for five minutes and in Redis for fifteen, so a busy workspace costs one KMS decrypt per cache period, not one per mailbox.
Set it on the backend and the consumer, and only after every backend, consumer and worker runs a release that reads it. That release becomes the rollback floor: an older release cannot open a workspace-sealed credential. Keep CREDENTIALS_ENCRYPTION_KEY configured, because personal mailboxes with no workspace, mailboxes not yet moved and older backups still use it.
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.
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
APP_URL | The dashboard origin. The source of every emailed link | guessed from CORS_ALLOW_ORIGINS, then PUBLIC_HOST | no (read per request) |
FRONTEND_BASE_URL | Alternative name for the same value, read when APP_URL is unset | unset | no |
API_PUBLIC_URL | The backend's public base. Frontends, blob URLs and the OIDC redirect derive from it | derived from PUBLIC_HOST under compose | yes |
BACKEND_PUBLIC_URL | The backend's public URL as third parties call it: generated worker configuration, the integration OAuth redirect, the Slack app request URLs and the Microsoft Graph notification URL for realtime mailbox sync. Only needed when that differs from API_PUBLIC_URL | falls back to API_PUBLIC_URL | yes |
APP_ORIGIN | Default origin the mailbox and integration OAuth callback pages post back to. Only needed when the dashboard is served somewhere other than APP_URL. Google per-mailbox flows can instead return to their allowlisted initiating dashboard, as described below | derived from APP_URL | yes |
API_HOST | The listen address | 0.0.0.0:8080 | yes |
PUBLIC_HOST | Compose only. A hostname or LAN IP that every other URL derives from | localhost | yes |
CORS_ALLOW_ORIGINS | Comma separated origins allowed to call the API. Anything not listed gets 403 on preflight | derived from PUBLIC_HOST under compose | yes |
WEBSOCKET_URL | The websocket URL the dashboard connects to | derived under compose | yes (container start) |
PHX_HOST | The realtime service's own hostname | localhost | yes |
TRACKING_DOMAIN | The 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_URL | localhost:3000 under compose, otherwise unset | no |
TRACKING_SERVICE_URL | Where the backend reaches the tracking service internally | unset | yes |
FORMS_DOMAIN | The 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 URL | unset | no (read per request) |
PORTAL_DOMAIN | The shared address of the portal service (portal.senditcold.com on SenditCold Cloud), with a port if it has one. Every connect link and shared report of a workspace without a verified agency portal domain is built on it, and agency portal domains CNAME to its host. Set it on the backend and the portal service. Unset, connect links and report links are refused (client_links_unavailable, report_links_unavailable) and no portal domain can verify; forms and everything else are unaffected | unset | no (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.
Customer domains behind Cloudflare for SaaS
Optional, and only for an instance whose TRACKING_DOMAIN is on a Cloudflare for SaaS zone. Cloudflare refuses a customer domain pointed at that host (error 1000 at a root, 1014 at a CNAME) until the domain is one of the zone's custom hostnames. With both values set, the backend registers a root redirect (and its www) as a custom hostname the moment it verifies and a verified tracking host within two minutes, rechecks a pending one every two minutes, and removes one a day after nothing on the instance serves it any more. While Cloudflare still needs proof of a domain, the redirect lists its _cf-custom-hostname record. The root record a redirect asks for also changes from the tracking host's addresses, which are Cloudflare's shared ones, to a CNAME to the tracking host. Unset, nothing is registered and the records are unchanged.
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
CLOUDFLARE_SAAS_ZONE_ID | The Cloudflare zone TRACKING_DOMAIN is on | unset | yes |
CLOUDFLARE_SAAS_API_TOKEN | A token with SSL and Certificates: Edit on that zone. Treat it as a secret | unset | yes |
CLOUDFLARE_SAAS_ORIGIN | Origin server for the hostnames the backend creates, when it differs from the zone's fallback origin | the zone's fallback origin | yes |
The origin has to route a request for any registered name to the tracking service with its Host header intact. On the AWS Fargate deployment that is tracking_serves_unmatched_hosts; see AWS Fargate.
Agency portal domains behind Cloudflare for SaaS
Optional, and only for an instance that serves agency portal domains from a Cloudflare for SaaS zone of their own, usually the zone PORTAL_DOMAIN is on, as SenditCold Cloud does. Agency portal domains CNAME to PORTAL_DOMAIN, not to the tracking host, so they get their own zone and their own settings. With both values set, the backend registers a portal domain as a custom hostname on that zone the moment it verifies (and every verified one within two minutes), rechecks a pending one every two minutes, and removes one a day after it stops being verified. While Cloudflare still needs proof of a domain, the agency brand's portal_domain lists the _cf-custom-hostname TXT record in edge_records. Workspace forms domains are never registered on this zone; they keep pointing at FORMS_DOMAIN. The tracking zone above is unaffected. Unset, no portal domain is registered anywhere, which is the self-host default: the instance issues their certificates on demand and its own portal service serves them.
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
CLOUDFLARE_SAAS_PORTAL_ZONE_ID | The Cloudflare zone PORTAL_DOMAIN is on | unset | yes |
CLOUDFLARE_SAAS_PORTAL_API_TOKEN | A token with SSL and Certificates: Edit on that zone. Treat it as a secret | unset | yes |
CLOUDFLARE_SAAS_PORTAL_ORIGIN | Origin server for the hostnames the backend creates, when it differs from the zone's fallback origin | the zone's fallback origin | yes |
CLOUDFLARE_SAAS_PORTAL_WORKER | Name of the portal Worker on that zone. Each portal domain then gets its own Worker route (<domain>/*) when it is registered, removed with it. The token also needs Workers Routes: Edit on the zone | unset: the zone routes its custom hostnames itself | yes |
Set CLOUDFLARE_SAAS_PORTAL_WORKER when the zone also holds other customer hostnames, such as tracking or redirect domains: one route for every custom hostname would send those to the portal too, and a route for each portal domain touches nothing else. A portal domain reads as registered only once its route to that Worker exists. A domain whose pattern already goes to another Worker is refused, and that route is never changed or removed. On a zone that holds portal domains only, leave it unset and route every custom hostname to the Worker once.
Moving a zone that already serves portal domains from one route for every hostname to a route for each: set the variable first and keep the old route for a day. A domain that is already active gets its own route at its next daily check, not at once, so removing the old route sooner would leave it unanswered until then.
On SenditCold Cloud the portal service is a Cloudflare Worker on that zone, configured by deploy/cloudflare/portal/wrangler.jsonc; its deployment is in deploy/cloudflare/README.md.
Network and proxy
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
TRUSTED_PROXIES | Comma separated CIDRs allowed to set X-Forwarded-For | empty (trust nothing) | yes |
Empty is correct for a directly exposed backend. Behind a reverse proxy it is not: with no trusted CIDR, SenditCold 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/12TLS listeners
The backend, forms, tracking and realtime services listen on plain HTTP, and that stays the default everywhere: Compose, the installer and local development need nothing from this table. Set SERVE_TLS on a service and it also listens with TLS 1.2 or later on SERVE_TLS_PORT, for a load balancer or an internal caller that should reach it encrypted. The plain listener stays up beside it, so the container health check and any caller not yet moved keep working.
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
SERVE_TLS | self-signed generates an ECDSA P-256 certificate in memory at boot, for a load balancer that does not validate its targets (an AWS Application Load Balancer does not). supplied serves the certificate and key below, for a caller that verifies the server. Unset or off adds no TLS listener | unset | yes |
SERVE_TLS_PORT | The TLS listener's port. Required with SERVE_TLS | none | yes |
SERVE_TLS_CERT, SERVE_TLS_CERT_FILE | With supplied, the PEM certificate, given inline or as a file path. Set exactly one | none | yes |
SERVE_TLS_KEY, SERVE_TLS_KEY_FILE | With supplied, the PEM private key, given inline or as a file path. Set exactly one, and keep it with your other secrets | none | yes |
SERVE_HTTP_LOOPBACK_ONLY | true binds the plain listener to 127.0.0.1, so from outside the container the TLS port is the only way in while the health check still reaches the plain one. Needs SERVE_TLS | false | yes |
A service refuses to start on an incomplete combination rather than serving something other than what was asked for. The backend logs a line at boot when the certificate it serves expires within 30 days.
The forms and tracking services verify the backend's certificate whenever BACKEND_INTERNAL_URL is https://. BACKEND_INTERNAL_CA (PEM) or BACKEND_INTERNAL_CA_FILE replaces the system roots with exactly the certificates given, which is how they trust a backend serving a private certificate through SERVE_TLS=supplied. That certificate has to name the host in BACKEND_INTERNAL_URL.
Authentication
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
AUTH_LOGIN_CODE | always, new_device or off. Whether a login also requires a code emailed to the account | off on self-host, new_device on cloud | yes |
REQUIRE_EMAIL_VERIFICATION | Whether a signup must confirm an emailed code before the account exists | false on self-host | yes |
DISABLE_REGISTRATION | false, invite_only or true. See registration modes | invite_only on self-host | yes |
DISABLE_PASSWORD_LOGIN | Turns off email and password entirely, for single sign-on only deployments | false | yes |
SSO_AUTO_PROVISION | true lets a verified identity provider assertion create an account regardless of DISABLE_REGISTRATION | false | yes |
AUTH_IP_RATE_LIMIT | Unauthenticated auth requests allowed per source IP per 15 minutes | 60 | yes |
CLI_AUTH_IP_RATE_LIMIT | CLI 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 above | 500 | yes |
PASSKEY_IP_RATE_LIMIT | Passkey 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_LIMIT | 300 | yes |
APP_ATTEST_IP_RATE_LIMIT | Native app App Attest challenges and key registrations allowed per source IP per 15 minutes. Its own budget, because the app asks for a challenge before every protected call; the call the challenge is spent on counts against AUTH_IP_RATE_LIMIT | 300 | yes |
PUBLIC_IP_RATE_LIMIT | Requests 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 limiter | 600 | yes |
WARMBLY_BOOTSTRAP_EMAIL | First owner's address, read only while the users table is empty | unset | yes |
WARMBLY_BOOTSTRAP_PASSWORD_HASH | Argon2id PHC string for that owner, as warmblyctl hash-password prints it. Preferred over the plaintext form. A malformed hash, or one whose cost parameters are below m=65536,t=3,p=2 with a 16-byte salt and 32-byte key, is refused and no owner is created | unset | yes |
WARMBLY_BOOTSTRAP_PASSWORD | Plaintext 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 environment | unset | yes |
WARMBLY_BOOTSTRAP_ORG | Name of the organization created with that owner. A value that breaks the naming rules is ignored with a warning | derived from the name | yes |
TWOFA_SECRET | Key that encrypts stored TOTP secrets. Falls back to AUTH_SECRET. Keep matching TWOFA_READ_SECRETS while existing enrollments are resealed under a replacement key. Use at least 32 characters: a shorter value logs a warning and raises an instance health finding on a self-hosted or development instance, and hosted production refuses to start with one | AUTH_SECRET | yes |
WEBAUTHN_RP_ID | Passkey relying party id. Derived from APP_URL when unset. Changing it invalidates every enrolled passkey | derived | yes |
WEBAUTHN_RP_ORIGINS | Origins accepted for passkey ceremonies | derived from APP_URL | yes |
WEBAUTHN_RP_DISPLAY_NAME | The name the passkey prompt shows | SenditCold | yes |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Sign 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 in | unset | yes |
GOOGLE_REDIRECT_URI | Redirect 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 registered | API_PUBLIC_URL plus /v1/auth/google/callback | yes |
GOOGLE_IOS_CLIENT_ID | Additional Google client id accepted from the iOS app. Native only: it does not enable the browser button | unset | yes |
APPLE_APP_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_KEY_SECRET | Sign in with Apple. APPLE_APP_ID is the Services ID; APPLE_KEY_SECRET is the .p8 file as PEM or base64 (base64 -i AuthKey_XXXX.p8 keeps it on one line) | unset | yes |
APPLE_REDIRECT_URI | Return URL registered at Apple. Must be HTTPS | API_PUBLIC_URL plus /v1/auth/apple/callback | yes |
APPLE_IOS_BUNDLE_ID | Bundle id accepted from the iOS app | com.warmbly.app | yes |
OIDC_ISSUER_URL | Generic OpenID Connect issuer. Discovery runs at boot | unset | yes |
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET | The client SenditCold authenticates as | unset | yes |
OIDC_REDIRECT_URL | Redirect URI registered at the provider. Defaults to API_PUBLIC_URL plus /v1/auth/oidc/callback | derived | yes |
OIDC_SCOPES | Scopes requested at the provider | openid,profile,email | yes |
OIDC_ALLOWED_DOMAINS | Email domains allowed to sign in through the provider | empty (any) | yes |
OIDC_DEFAULT_ORG | Organization uuid every single sign-on user joins | unset | yes |
OIDC_PROVIDER_NAME | The label on the sign-in button | Single sign-on | yes |
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:
| Tree | Setting | Value |
|---|---|---|
| dashboard, admin | POSTHOG_HOST | https://api.example.com/ingest |
| marketing site | PUBLIC_POSTHOG_HOST | https://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.
Advertising conversion measurement
Advertising measurement is hosted-only and off by default. A self-host needs no Meta account, Google Ads account, tag, token, or Google Cloud credential. The marketing site and /auth/register show a compact accept or reject control only when a public advertising ID is configured. They load no advertising script before acceptance, and authenticated /app/ routes never load the advertising tags.
| Variable | What it does | Default |
|---|---|---|
PUBLIC_META_PIXEL_ID, PUBLIC_GOOGLE_ADS_ID | Build-time Meta Pixel and Google tag IDs for the marketing site | unset |
WARMBLY_META_PIXEL_ID, WARMBLY_GOOGLE_ADS_ID | Runtime IDs for the registration page | unset |
AD_META_PIXEL_ID, AD_META_ACCESS_TOKEN | Meta Conversions API destination and secret | unset |
AD_META_TEST_EVENT_CODE | Optional Meta test-events code for local or staging validation | unset |
AD_GOOGLE_ADS_ACCOUNT_ID | Google Ads operating account for Data Manager API uploads | unset |
AD_GOOGLE_ADS_LOGIN_ACCOUNT_ID | Optional manager account used to access the operating account | unset |
AD_GOOGLE_COMPLETE_REGISTRATION_ACTION_ID | Conversion action for a successfully created account and workspace | unset |
AD_GOOGLE_INITIATE_CHECKOUT_ACTION_ID | Conversion action for a successfully created Stripe Checkout Session | unset |
AD_GOOGLE_PURCHASE_ACTION_ID | Conversion action for a successful, non-zero subscription invoice | unset |
AD_GOOGLE_APPLICATION_CREDENTIALS_JSON | Optional service-account JSON. When unset, Application Default Credentials are used | unset |
AD_GOOGLE_VALIDATE_ONLY | Ask Google to validate uploads without applying them | false |
The browser sends PageView on every marketing page and on /auth/register after consent. Meta also receives ViewContent on pricing, product, feature, use-case, and blog pages. The backend records CompleteRegistration, InitiateCheckout, and Purchase with stable source IDs. It writes them to an instance-local outbox and retries provider failures, so provider availability cannot fail registration, Checkout creation, or a Stripe webhook. Workspace exports exclude the outbox because it uses the source instance's operator credentials and must not replay on another instance.
Google conversions use the Data Manager API. Create one conversion action for each configured event, grant the credential access to the Ads account, and enable the Data Manager API in its Google Cloud project. Meta and Google credentials are independent. Google Tag Manager is not used.
Captcha
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
CAPTCHA_PROVIDER | none or turnstile | derived, see below | yes |
TURNSTILE_SECRET | Cloudflare Turnstile secret, read by the backend | unset | yes |
WARMBLY_TURNSTILE_KEY | The Turnstile site key, read by the dashboard and admin panel at container start | a test key under compose | yes (container start) |
WARMBLY_BETA_NOTICE | One 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 reload | unset | yes |
WARMBLY_CONFIG_OUT | Where 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 start | the nginx path | yes (build or container start) |
WARMBLY_CSP_CONNECT_ORIGINS | Additional dashboard CSP connection origins, separated by spaces. Realtime is discovered automatically from the backend; use this as an offline fallback or for other connection destinations. Use exact HTTP(S) or WS(S) URLs, not wildcard hosts or CSP directives. VITE_CSP_CONNECT_ORIGINS supplies the build-time/development fallback | unset | yes (build or container start) |
TURNSTILE_BYPASS_TOKEN | A token that skips verification. Only honoured when APP_ENV=dev | unset | yes |
APP_ATTEST_APP_IDS | Comma-separated <team id>.<bundle id> App IDs whose App Attest proofs the backend accepts in place of a captcha. Unset with no APPLE_TEAM_ID, App Attest is off | APPLE_TEAM_ID + . + APPLE_IOS_BUNDLE_ID when APPLE_TEAM_ID is set | yes |
APP_ATTEST_ALLOW_DEVELOPMENT | Accept App Attest proofs from development-signed builds (an Xcode install on a device). Leave it off in production | false | yes |
TURNSTILE_SITE_KEY | The public Turnstile widget key. The backend hands it to the forms service, which renders it on hosted form pages that enable spam protection, and renders it itself in the security check the dashboard on an agency portal domain embeds (see below). The frontends carry their own copy in WARMBLY_TURNSTILE_KEY | unset | no (read per request) |
CAPTCHA_PROVIDER has no constant default. Unset, it resolves to turnstile when TURNSTILE_SECRET holds a value, whether from the environment or from AWS Secrets Manager, 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.
A Turnstile widget only renders on the hostnames listed for its site key in Cloudflare, and an agency's portal domain cannot be listed in advance. The dashboard on a portal domain therefore embeds a small page from the API's own host (/v1/auth/captcha-frame), which the API lets only a verified portal domain frame, and that page hands the token back to the portal page. The token is checked by the backend exactly as on the main address. For it to work, TURNSTILE_SITE_KEY must be set on the backend and the API's hostname must be on the site key's hostname list. Without them the portal sign-in asks people to sign in at the main address.
The native iOS app never renders Turnstile. It proves it is a genuine copy of the app with Apple App Attest, which stands in for the captcha on sign-in, registration and password reset. With CAPTCHA_PROVIDER=none the app sends nothing. With Turnstile on, the app offers email and password sign-in only when APP_ATTEST_APP_IDS names its App ID; otherwise it offers Sign in with Apple, Google and passkeys, which never need a captcha. A self-hosted instance that runs Turnstile and wants the iOS app to sign in with a password sets APP_ATTEST_APP_IDS to the iOS app's App ID. Verification needs no Apple account and makes no outbound call: it checks against Apple's App Attest root certificate, which ships in the binary. Each install's key is stored in the app_attest_keys table, which belongs to the instance and is not part of a workspace export. The iOS simulator cannot use App Attest, so local development keeps using TURNSTILE_BYPASS_TOKEN.
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.
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
MAIL_TRANSPORT | smtp, log or ses | log under compose, ses for a bare binary with no SMTP_HOST | yes |
EMAIL_NAME | Display name on platform mail | SenditCold | yes |
EMAIL_ADDRESS | From address on platform mail | none, and the backend refuses to start without it | yes |
SMTP_HOST | Relay hostname | unset | yes |
SMTP_PORT | Relay port. Follows SMTP_SECURITY when unset | derived | yes |
SMTP_USERNAME, SMTP_PASSWORD | Relay credentials. Never sent over an unencrypted connection | unset | yes |
SMTP_SECURITY | starttls (587), tls (465) or none (25) | starttls | yes |
SMTP_AUTH | auto, plain, login, cram-md5 or none. Hosted production permits LOGIN and PLAIN over TLS; cram-md5 is available only on self-hosted or development instances | auto | yes |
SMTP_EHLO_NAME | EHLO name presented to the relay | the sender domain | yes |
SMTP_TLS_INSECURE_SKIP_VERIFY | Skips certificate verification. Only for a relay with a private certificate authority. Honoured on a self-hosted or development instance and ignored on hosted production | false | yes |
EMAIL_BRAND_NAME | The product name in transactional subjects and the header | SenditCold | yes |
EMAIL_BRAND_LEGAL_ENTITY, EMAIL_BRAND_COMPANY_NUMBER, EMAIL_BRAND_PLACE_OF_REG, EMAIL_BRAND_ADDRESS | The identification line in the transactional footer | hosted: SenditCold Company, 13589, Berlin Germany., Radelandstrasse 38, 13589 Berlin Germany; unset on a self-host, which renders no line | yes |
EMAIL_BRAND_WEBSITE_URL, EMAIL_BRAND_TERMS_URL, EMAIL_BRAND_PRIVACY_URL, EMAIL_BRAND_SUPPORT_EMAIL | The footer's links and support address | hosted: senditcold.com links and [email protected]; unset on a self-host, which renders no link row | yes |
NOTIFICATION_EMAIL_DAILY_CAP | Notification emails per user per day. 0 means uncapped | 25 | yes |
NOTIFICATION_PUSH_WINDOW | How long a notification waits before it is also pushed | 5h | yes |
A self-hosted instance never fills those in with ours. SenditCold'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 senditcold.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. Unless APP_ENV is dev (also development or local), the logged copy has the query string and fragment of every link replaced with ?[redacted], because that is where reset and invitation tokens travel; the rest of the message is logged as written. A transport that does not deliver never gates a sign-in or a signup on an emailed code, so no such code reaches the log.
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.
Automatic campaign send recovery
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
CAMPAIGN_SEND_ISOLATION_ENABLED | Automatic isolation of authorized campaign sends with unknown outcomes after ten minutes. On unless set to false; set it on backends and consumers alike. See activation and rollback | true | yes |
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.
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
EMAIL_VERIFY_HELO_HOST | The hostname the probe announces in EHLO/HELO. Must be a public, fully-qualified name that belongs to this instance | the host of APP_URL | yes |
EMAIL_VERIFY_MAIL_FROM | The envelope sender the probe uses in MAIL FROM | verify@ plus the HELO host | yes |
EMAIL_VERIFY_MILLIONVERIFIER_API_KEY | An 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 probe | unset | yes |
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. SenditCold 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
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
KMS_PROVIDER | local (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 credential. A joining worker is given brokered on local too, so the master key stays on the backend and consumers | local under compose, aws for a bare binary | yes |
KMS_LOCAL_MASTER_KEY | base64, exactly 32 bytes. The root of trust for every per-organization data key | published default under compose | yes |
KMS_LOCAL_MASTER_KEY_FILE | Path to a file holding that key instead. Mutually exclusive with the inline value | unset | yes |
KMS_AWS_KEY_ID | Key id or alias when KMS_PROVIDER=aws | unset | yes |
CREDENTIALS_ENCRYPTION_KEY | exactly 64 hex characters. Seals mailbox SMTP and IMAP passwords at rest | published default under compose | yes |
ENCRYPTED_KEYS_PROVIDER | postgres for the backend and a consumer that has a DSN, http for workers | the caller's fallback, so set it explicitly | yes |
ENCRYPTED_KEYS_BACKEND_URL | Where a worker reaches the backend's key endpoint | unset | yes |
ENCRYPTED_KEYS_WORKER_TOKEN | The worker's copy of INTERNAL_API_TOKEN | unset | yes |
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
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
BLOB_PROVIDER | filesystem, 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 s3 | filesystem under compose, s3 for a bare binary | yes |
BLOB_FS_ROOT | Directory for stored bodies, attachments, avatars and email body images. The backend, the consumer and every worker on the host must share it | /data/blobs | yes |
BLOB_BUCKET | Bucket name when BLOB_PROVIDER=s3 | unset | yes |
BLOB_PUBLIC_BASE_URL | Public 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 reach | derived | yes |
CAMPAIGN_ATTACHMENT_DOWNLOAD_PROXY | true serves private campaign downloads through the API with nosniff, attachment disposition and a 15-minute file-scoped capability. Upgrade every backend before enabling it. Hosted operators can use the scoped backend configuration workflow | false | yes |
AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | Credentials for S3 or SES | unset | yes |
AWS_ENDPOINT_URL_S3 | Non-AWS S3 endpoint (MinIO, R2, B2) | unset | yes |
AWS_CONFIG_ENABLED | true reads secrets from AWS SSM or Secrets Manager | false | yes |
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.
The attachment download proxy works with filesystem and S3-compatible stores and requires no cloud account. Keep it disabled during a rolling upgrade from v0.6.72 until every backend supports the download route. Before rolling back to v0.6.72, disable it and allow the 15-minute download-link lifetime to elapse. Attachment rows, stored bytes and worker messages retain their existing format in both versions.
Event bus
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
EVENTBUS_PROVIDER | nats or kafka. Kafka needs the -kafka images, or a build with GO_TAGS=kafka | nats under compose, kafka for a bare binary | yes |
EVENTBUS_READ_BOTH | Migration setting for Go services: consume both Kafka and NATS, publishing only to EVENTBUS_PROVIDER. Requires Kafka builds and both connections. Keep enabled through backlog draining and the rollback window. See AWS NATS migration | false | yes |
EVENTBUS_HOLD_READS | Migration drain barrier: hold consumption on nats or kafka while the other transport drains. Requires EVENTBUS_READ_BOTH=true. Publishing continues to the selected provider; held work remains queued. Clear only after all old publishers are fenced and their consumer groups have drained | unset | yes |
FLEET_IMAGE_VARIANT | Tag 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, EVENTBUS_READ_BOTH=true or CODEC_READ_BOTH=true, and nothing otherwise; set it empty to turn it off | derived from EVENTBUS_PROVIDER, EVENTBUS_READ_BOTH and CODEC_READ_BOTH | no |
NATS_URL | JetStream 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 TLS | nats://nats:4222 | yes |
NATS_CREDS | Path 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 one | unset | yes |
NATS_CREDS_B64 | The 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_CREDS | unset | yes |
NATS_MAX_BYTES | Ceiling 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 one | unset (unlimited) | yes |
NATS_STREAM_NAME, NATS_SUBJECT_PREFIX | Stream and subject naming | warmbly | yes |
NATS_REPLICAS | Copies of the JetStream stream, from 1 to 5. Set 3 on a three-node hosted cluster. Unset preserves an existing stream's replication; a new stream defaults to one copy | unset | yes |
NATS_DISCARD_NEW | Reject new publications when the stream reaches its size limit, preserving existing queued work. Producers must surface or retry that error. Time-based retention still applies | false; preserves an existing stream's policy | yes |
NATS_TLS_CA_B64 | Base64 PEM certificate authority for a private NATS deployment. Go adds it to system trust; tracking uses it as the explicit NATS trust bundle. Certificate and hostname verification remain enabled | unset | yes |
KAFKA_BOOTSTRAP_SERVERS | Broker list when EVENTBUS_PROVIDER=kafka | unset | yes |
KAFKA_SASL_USERNAME, KAFKA_SASL_PASSWORD | Broker credentials | unset | yes |
SCHEMA_REGISTRY_URL, SCHEMA_REGISTRY_KEY, SCHEMA_REGISTRY_SECRET | Registry for the Avro codec | unset | yes |
CODEC_PROVIDER | json or avro | json under compose, avro for a bare binary | yes |
CODEC_READ_BOTH | Migration setting for Go services: read JSON and Confluent-framed Avro while writing only CODEC_PROVIDER. Requires Kafka builds and the old Schema Registry until all retained Avro has drained | false | yes |
EVENTBUS_HANDLER_TIMEOUT | How long one handler may take before the delivery is abandoned. A subscription that needs longer carries its own budget, never shorter than this one: a worker's command subscription runs under 195s, because a send is given 120s (the SMTP client's 90s conversation plus the checks before it) and then records its result. On NATS the ack wait is the subscription's budget plus 5s | 30s | yes |
CONSUMER_EVENT_CONCURRENCY | How many mailboxes' worker events one consumer handles at once, from 1 to 64, on both Kafka and NATS. Each mailbox's events stay in order, and on NATS a slow mailbox holds only its own lane. Unset, it follows the consumer's database pool (DB_MAX_CONNS), so raising the pool raises it too. Invalid values use the default | one per pooled database connection, at most 64 | yes |
CONSUMER_METRICS_CLOUDWATCH | Read by the consumer: the name it publishes the Warmbly/Consumer metric WorkerEventBacklog under (dimension Consumer) every minute, the worker events no consumer has taken yet, which the AWS consumer scaling reads. The AWS runtime root sets it when the consumer scales. Uses the AWS default credential chain, so the consumer's role needs cloudwatch:PutMetricData. Leave it unset on any other deployment | unset | yes |
PUBSUB_ENABLED | false uses the Redis bridge for realtime fanout, true uses Google Pub/Sub | false | yes |
GCP_PROJECT_ID | Project when PUBSUB_ENABLED=true | unset | yes |
Kafka event publication waits for the broker's delivery report with all in-sync replica acknowledgements, bounded by the caller's deadline and EVENTBUS_HANDLER_TIMEOUT. Local queue admission is not evidence of publication. A rejected or timed-out result keeps the original command unacknowledged for replay; a timeout does not prove the event was never delivered, so consumers still deduplicate replayed outcomes. Generic producer events are drained separately through shutdown, without consuming a publication's independent delivery report.
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. Without CODEC_READ_BOTH=true, producers and consumers have to agree on one codec. Change it by draining the bus, or enable CODEC_READ_BOTH on every Go reader before a staged migration. 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.
Go services limit each decoded Avro array or map to 65,536 entries, counted across blocks, and each string or byte field to 1 MiB. Payloads exceeding these limits are rejected. The maintained encoder uses the existing schemas and Confluent framing. Queued messages within these limits remain readable without draining the bus or re-registering subjects. Keep the publishing codec unchanged until all readers support the queued formats.
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
Existing self-hosts keep their current database configuration and startup migrations. A separate direct connection, transaction pooling and a deployment migration job are optional. They do not require cloud services or change the one-command installer defaults.
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
PRIMARY_DB | PostgreSQL connection string. Carries inline credentials, so it is never returned by any API | the compose postgres | yes |
PRIMARY_DB_DIRECT | Direct PostgreSQL connection for migrations, backups, session advisory locks and the webhook delivery listener. Behind a transaction pooler each consumer also keeps up to 4 direct connections for the locks that serialize warmup receipts, so a held lock never occupies one of the pooler's server connections. Required when DB_POOL_MODE=transaction. It must address the same database as PRIMARY_DB; its credentials can be different. With AWS configuration loading, the secret path is postgres/primary_direct | falls back to PRIMARY_DB in direct mode | yes |
DB_POOL_MODE | direct or transaction for the Go services. Transaction mode uses PRIMARY_DB for ordinary queries and PRIMARY_DB_DIRECT for session operations. It verifies server-side role defaults of nonzero statement_timeout at most 60 seconds and idle_in_transaction_session_timeout at most 5 minutes on every new pooled client connection. Configure those role defaults before connecting | direct | yes |
DB_MIGRATIONS_ON_START | Whether the backend applies embedded migrations at startup. Set false when a deployment job runs /app/migrate first. The backend still waits for a clean schema at the version its image requires. AWS configuration path: postgres/migrations_on_start | true for self-host compatibility; false in the AWS runtime root | yes |
PGSSLROOTCERT | Certificate 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 to | unset (system trust store) | yes |
DB_MAX_CONNS | Maximum client connections for one Go process (backend, consumer). In direct mode, a successful server-capacity probe lowers the pool to a quarter of unreserved server connections, allowing for backend and consumer deployment overlap. Transaction mode keeps the configured client ceiling and relies on separately configured pooler server limits. Size the aggregate server budget across all roles and direct clients. A pool_max_conns in the DSN is honoured when this is unset | 25, lowered to the probed share in direct mode | yes |
DB_MIN_CONNS | Connections each Go process keeps open while idle. Capped at DB_MAX_CONNS | 2 | yes |
DATABASE_URL | The realtime service's own name for the same database | the compose postgres | yes |
DATABASE_POOL_SIZE | Maximum client connections for the realtime service only. The Go services use DB_MAX_CONNS | 10 | yes |
DATABASE_SSL | Whether the realtime service connects to Postgres over TLS | true, and false under compose | yes |
DATABASE_SSL_CA_FILE | CA 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) | unset | yes |
Migrations are embedded in the backend image. Self-hosts apply them on boot by
default. Hosted deployments run the same image's /app/migrate binary once
before rolling services, with DB_MIGRATIONS_ON_START=false on the backend.
The migration binary uses PRIMARY_DB_DIRECT when configured. A failed migration
blocks deployment; operators inspect its logs and schema state before retrying.
Services do not automatically reverse a migration during application rollback.
Use additive schema changes that remain usable by the previous release during deployment and rollback. Stage renames, removals, data transformations and event format changes across releases. Backfill large datasets with a resumable job after compatible readers and writers are deployed.
Realtime upgrade compatibility
Existing WEBSOCKET_URL values ending at the host, /socket, or /socket/websocket remain supported, including custom path prefixes, ports and query parameters. HTTP(S) forms are converted to WS(S). Both discovery and ticket responses use the same normalization.
The dashboard can connect through /v1/realtime/socket/websocket on the API origin. No new environment variable, CSP allowlist entry or data migration is required. The API forwards only to the configured realtime gateway and keeps the browser Origin intact. Legacy Docker installs advertising a loopback gateway can fall back to the existing realtime service on the same port when the API container cannot reach its own loopback address.
Upgrade the backend, realtime and dashboard images together to the same release; the realtime update preserves per-client IP limits for API-proxied connections. Existing direct clients and older dashboard/backend combinations retain the direct URL. A custom reverse proxy should allow WebSocket upgrades on the API route as well as the direct realtime route; direct connections remain a retry fallback. Keep CHECK_ORIGIN_HOSTS and the shared AUTH_SECRET/JWT_SECRET consistent. A retry label does not prove recovery: Live means the socket is open, while Reconnecting means it is still retrying.
Cache
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
REDIS | Redis connection string. Carries inline credentials, so it is never returned by any API | the compose redis | yes |
REDIS_URL | The realtime service's own name for the same instance | the compose redis | yes |
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
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
GEODB_PATH | Path to a GeoLite2 City database | none, and the backend refuses to start without the variable | yes |
GEODB_URL | Where to download that database from when nothing is at GEODB_PATH yet | unset, and only a file already there is used | yes |
TRACKING_SCANNER_ASN_DB | Path to a GeoLite2 ASN database, on the tracking service | unset, and ASN matching is off | yes |
TRACKING_SCANNER_ASN_DB_URL | Where the tracking service downloads that database from | unset | yes |
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.gzThe 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
Unknownand 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 answers304 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-Afterwhen 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
httpis 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. Anhttpmirror with nothing secret in its URL is allowed, and a redirect fromhttpsdown tohttpis 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:
| Setting | Kind | Meaning |
|---|---|---|
MAXMIND_ACCOUNT_ID | variable | The numeric account id |
MAXMIND_LICENSE_KEY | secret | A licence key for that account |
GEOIP_MIRROR_BUCKET | variable | Bucket the copies are written to |
GEOIP_MIRROR_REGION | variable | Its region |
GEOIP_MIRROR_PREFIX | variable | Key prefix, including the trailing slash |
GEOIP_MIRROR_AWS_ACCESS_KEY_ID | secret | Credentials that may write that prefix, and nothing else |
GEOIP_MIRROR_AWS_SECRET_ACCESS_KEY | secret |
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
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
WORKER_ID | Stable uuid for this worker. Leave unset when running scaled replicas, which share one environment | derived, then random | yes |
WORKER_BIND_IP | Source address to bind outbound connections to, and the seed for a derived WORKER_ID | unset | yes |
WORKER_PUBLIC_IP | Public IPv4 reported by the worker, never replaced by the backend-observed address, which may be a reverse proxy. Otherwise the node discovers HTTPS egress IPv4 using api.ipify.org every ten minutes. Set this explicitly when mail and HTTPS have different egress routes | detected | yes |
WARMBLY_WORKER_CAPACITY | Assigned-mailbox planning target for this worker. This guides placement and is not a mailbox-provider send limit. auto sizes it on every heartbeat from the machine's memory: 70% of it, less the process's own footprint, divided by what each mailbox the worker holds has been measured to use (1 MB until 50 are loaded) | 100 | yes |
WORKER_COMMAND_CONCURRENCY | Worker command lanes, from 1 to 64, on both Kafka and NATS. Commands are grouped by the mailbox in the payload, including older task-keyed sends. Each mailbox stays in order; independent lanes run concurrently, so one slow mail server holds only its own lane. On NATS a command waiting in a lane is kept from being redelivered while it waits. Invalid values use the default | 8 | yes |
WARMBLY_NODE_REGION | Free-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 --region | unset | yes |
ENCRYPTED_KEYS_BACKEND_URL | Backend base the worker fetches organization keys from, and the address a joining node is told to call back on. On hosted production it should be https, or a private-network address nodes reach without crossing the internet; a plain http address that does not name this machine is logged as a warning on every join and env refresh. A self-host may use plain http on its own network | unset | yes |
ENCRYPTED_KEYS_WORKER_TOKEN | The worker's copy of INTERNAL_API_TOKEN | unset | yes |
MAIL_TLS_INSECURE | Skips certificate verification on mailbox connections and lets SMTP continue on a server that offers no STARTTLS. A password is still only sent over TLS or to this machine. Honoured on a self-hosted or development instance and ignored on hosted production, where a server with a self-signed or private certificate is trusted through its mailbox's own certificate choice instead. A joining node receives the value its control plane resolved | false | yes |
MAIL_ALLOW_PRIVATE_HOSTS | Whether a mailbox's SMTP or IMAP server may resolve to a loopback, private, link-local, CGNAT, multicast or metadata address. Checked on every connection, against the address actually dialled. A hosted production instance always refuses them; a self-hosted or development instance allows them unless this is false. A joining node receives the value its control plane resolved | true on a self-host or in development, refused on hosted production | yes |
WARMBLY_BACKEND_URL | Backend base a joined node calls for its heartbeat, and the brokered key and blob providers, when ENCRYPTED_KEYS_BACKEND_URL is unset. Written by the join script | unset | yes |
WARMBLY_NODE_NAME | Display name the node reports to the fleet list and admin panel. Written by the join script from --name, else the hostname | unset | yes |
WARMBLY_TARGET_VERSION_PATH | File the node writes the control plane's desired_version to, for the warmbly-node-update timer to apply. Unset, the node never writes one and does not update itself. Written by the join script | unset | yes |
WARMBLY_ENV_REFRESH | Read by the host updater, not the node. true lets it replace node.env with what the control plane renders now whenever the heartbeat names a different env revision, then restart the node. node.local.env is never touched. Written by the join script on a new join and by --adopt --env-refresh; a node without it keeps its env until it rejoins | unset | no (read by the updater) |
APP_INTERNAL_URL | Read by the backend: the address a joining node is told to call back on when ENCRYPTED_KEYS_BACKEND_URL is unset on the backend. Instance health checks whichever of the two is set | unset | no (read per join) |
FLEET_METRICS_CLOUDWATCH | Read by the backend: the fleet name it publishes Warmbly/Fleet metrics under (dimension Fleet) every minute from the workers' heartbeats: WorkerMemoryPercent and WorkerMemoryMaxPercent, which the AWS worker fleet scales on, and WorkerUtilizationPercent for reading only. Set it to the workers root's name. Uses the AWS default credential chain, so the backend's role needs cloudwatch:PutMetricData. Leave it unset on any other deployment | unset | yes |
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. Several things are decided rather than copied:
- The crypto and blob providers are translated. An instance on
KMS_PROVIDER=awshands its nodesbrokered, and one onBLOB_PROVIDER=s3hands thembrokeredtoo, so no machine in the fleet carries a cloud credential. An instance onKMS_PROVIDER=localhands its workersbrokeredand keepslocalfor consumers, which may create a workspace's first data key. Filesystem blobs pass through unchanged. - A worker never receives
PRIMARY_DB,CREDENTIALS_ENCRYPTION_KEY,CREDENTIALS_ENCRYPTION_READ_KEYSorKMS_LOCAL_MASTER_KEY. A consumer does, because it opens Postgres itself and seals stored credentials. A worker opens only what reaches it sealed under a workspace key. An instance that keeps its DSN in a secret manager rather than the environment has none to send, and the join script says so. DEPLOYMENT_MODE,MAIL_ALLOW_PRIVATE_HOSTSandMAIL_TLS_INSECUREare resolved. A node cannot tell a hosted instance from a self-host on its own, so it receives the mode and the policies its control plane applies. On hosted production that means a node ignoresMAIL_TLS_INSECUREand the other self-host-only opt-outs even whennode.local.envsets them. A node that has not rejoined or refreshed its env since an upgrade keeps what it was given; on a hosted instance, rejoin existing nodes or writeDEPLOYMENT_MODE=cloudinnode.local.env.- The backend address should be
httpson hosted production. Join and env refresh log a warning whenENCRYPTED_KEYS_BACKEND_URL(orAPP_INTERNAL_URL) is plainhttpand does not name this machine. Keep a cleartext address on a private network only. A self-host keeps whatever it uses on its own network. - Each node gets credentials of its own.
NODE_LOG_TOKENfor log ingestion andNODE_CREDENTIALfor fetching its own env are issued per node on every join and never derived from the fleet-wide token. - Addresses are inherited literally.
NATS_URL,REDISandENCRYPTED_KEYS_BACKEND_URLare 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-consumerKeeping a node's env current
A node joined with WARMBLY_ENV_REFRESH=true picks up configuration changes without anyone signing in to it. Every heartbeat reply carries the revision of the env the control plane would render for that node now. When it differs from what the node applied, the updater fetches the new env from the backend with the node's broker token and its own NODE_CREDENTIAL, writes node.env in one step, restarts the node and reports the new revision. If the restarted node does not stay up, the previous node.env is put back and that revision is not tried again. The backend hands each node only its own env, and logs every hand-out and refusal ("event":"broker_access", operation fleet.node_env) without its contents. A worker whose updater predates NODE_CREDENTIAL is still answered, because a worker's env carries nothing the fleet token does not already reach; a consumer's env is answered only against its credential.
warmblyctl fleet env-status lists every live node as current, stale or manual (a node that does not refresh) and exits non-zero until all of them are current. A key rotation that reaches nodes waits on it, so a manual node holds a rotation open until it is rejoined, or adopted with --env-refresh:
curl -fsSL https://<your-instance>/join.sh | sudo sh -s -- --adopt --env-refresh--adopt installs the standard unit and updater for the node the existing node.env describes, without enrolling again. With --env-refresh it first checks that a refresh would keep every key in node.env, and refuses, listing the names, when one exists only on this machine. Keys the control plane stopped sending a worker (the credential keys and the KMS master key) are not listed: dropping them is intended. Name the others with --keep-local to move them into node.local.env. It also refuses when the control plane asks for a different version than the node runs, because the updater would move it within two minutes; set the fleet version first, or pass --accept-target. Adopting issues no NODE_CREDENTIAL, so a consumer joined before node credentials existed resumes env refresh only after it is joined again.
Mailbox connections
Needed on the backend and every worker: the backend starts the OAuth flow, and each worker refreshes the token when it expires.
| Variable | What it does | Default |
|---|---|---|
BOX_GOOGLE_CLIENT_ID, BOX_GOOGLE_CLIENT_SECRET | The Google app used by Gmail mailboxes connected with per-mailbox Google sign-in. Both are required to enable new connects. Keep them set while any mailbox uses Google sign-in. Also carries a Workspace administrator's domain proof when GOOGLE_CLIENT_ID is not set. Redirect URI is your API base plus /addresses/google/callback. Until Google verifies the client it is limited to 100 new users, except on Workspace domains whose administrator marks it Trusted under App Access Control, and an Internal client in your own organization; members see the client ID in the connect dialog and as google_mailbox_client_id on GET /emails/grants/config. The consent screen needs https://mail.google.com/ and gmail.settings.basic (setup) | unset |
BOX_GOOGLE_APP_NAME | The app name on the consent screen of the BOX_GOOGLE_* client. An inbox vendor that signs in to Google mailboxes for SenditCold checks it against the client ID (Zapmail refuses a mismatch), so set it to the name your Google Cloud project shows | SenditCold |
BOX_GOOGLE_OAUTH_CONNECT | Optional backend kill switch for new Gmail OAuth connects. Unset enables the capability when both Google mailbox credentials are present; explicit false or an invalid value disables it. Existing Google sign-in mailboxes keep working and can re-authorize either way. Announced as gmail_oauth_connect on GET /v1/auth/config, which is what the dashboard reads to offer Google sign-in | enabled when configured |
BOX_OUTLOOK_CLIENT_ID, BOX_OUTLOOK_CLIENT_SECRET | Connect Outlook and Microsoft 365 mailboxes. Redirect URI is your API base plus /addresses/outlook/callback | unset |
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.
Multiple dashboards on one backend
Several dashboard deployments can share one backend. Each points WARMBLY_API_URL at the same API and sets WARMBLY_APP_URL to its own dashboard URL. Containers generate /config.js at startup, so no second image is needed. Cloudflare Pages renders it during build:pages; set the variables on the intended Pages project and redeploy.
The customer dashboard also generates a Content Security Policy from the same deployment settings. Static bootstrap scripts and styles use build-generated hashes; scripts and styles injected as markup are not generally allowed. API, Sentry and PostHog origins are derived from their configured URLs. Before nginx starts, the dashboard reads the public /v1/auth/config endpoint at its configured API URL and allows only the exact websocket_url origin. Existing backend WEBSOCKET_URL configuration therefore needs no duplicate CSP setting. If WEBSOCKET_URL is also passed into the web container, it supplies the origin directly without an HTTP request.
pnpm build:pages performs the same discovery at build time, using curl and jq on the build host (both are included in the dashboard container). Discovery uses bounded retries and never weakens CSP if the backend is unreachable or returns invalid configuration. An unavailable backend produces a startup/build warning; restart the dashboard or rebuild Pages after it is ready. Offline/custom deployments can explicitly allow the realtime origin through WARMBLY_CSP_CONNECT_ORIGINS, for example wss://realtime.example.com. Pass it into the web service's environment, not only the Compose .env. Changing origins requires a container restart or a Pages rebuild. Pages remains fully static and needs no Function.
The Pages dashboard removes Cloudflare's default Access-Control-Allow-Origin: * header from its static responses. Dashboard assets are not a cross-origin public API. This does not change the backend's CORS_ALLOW_ORIGINS or the public image URLs used in customer emails.
Email bodies render in a separate static, script-disabled sandbox. That document permits email styling and the reader's remote-content choice without relaxing the dashboard policy. Dashboard images load from its own origin, the configured API, data URLs or temporary blob URLs. External UI images and editor previews use the backend's image-preview endpoint, which allows HTTPS public destinations only, checks every connection and redirect, and caps images at 5 MiB and requests at ten seconds. Owned public images are read through the configured blob store, so filesystem storage and private MinIO endpoints need no public-network exception. The unsafe-webhook development flag does not relax image-fetching restrictions.
This proxy is display-only. Existing records need no migration, and saved and sent email HTML retains its original image URLs, links and styles. Recipients do not use the dashboard proxy or need a session to load email images. Incoming messages keep their separate remote-content consent and do not use this proxy. Dashboard inline style attributes remain permitted for editor-generated email designs; inline scripts and arbitrary style blocks do not. Development uses fresh per-response nonces for Vite's script and stylesheet injection.
On the shared backend, keep APP_URL as the primary dashboard and add both exact dashboard origins to CORS_ALLOW_ORIGINS, for example https://app.example.com,https://assessment.example.com. Google and Microsoft mailbox connects, reauthorization, whole-domain administrator grants, integration OAuth and Slack identity verification bind the initiating browser origin to their server-side OAuth state. The callback posts only to that allowlisted origin, and a popup without an opener returns to its /oauth-return page. Missing, expired or consumed routing state shows a retry notice instead of forwarding a code to another dashboard. Valid legacy states without an origin still use the primary dashboard. Wildcards are not accepted as OAuth return origins. Provider-registered callback URIs stay on the same API.
Email/password login, OTP/2FA, signup, logout and ordinary dashboard navigation use local routes. Password-reset and invitation emails initiated from an allowlisted dashboard link back to that dashboard. Stripe checkout, credit top-ups, billing portal and warmup-plan returns use the initiating dashboard and retain safe same-origin paths. Requests without a trusted browser origin use the primary dashboard.
Each additional dashboard also needs:
- its hostname allowed on the Cloudflare Turnstile widget. A widget restricted to one hostname does not cover a sibling. Keep CAPTCHA enabled.
- its origin appended to the realtime service's
CHECK_ORIGIN_HOSTS, preserving the existing origins. The API origin allowlist does not configure websocket origin checks. - the existing
WEBAUTHN_RP_ID, unchanged. Changing it invalidates every registered passkey. If it is the primary dashboard's hostname, use email/password on a sibling hostname; if it is a shared parent domain and both origins are allowed for WebAuthn, existing passkeys work on both dashboards.
This is not a global rewrite of every primary-dashboard link. Dashboard Google/Apple/OIDC login remains primary-dashboard routed. Server-generated notifications, Slack-launched links, CLI/bootstrap links and self-hosted Cloud broker returns retain their canonical instance routing. Cloud broker return origins remain bound to the registered instance.
Optional hosted static edge
The optional Worker in deploy/cloudflare/ serves public frontend files through
an explicitly configured custom domain. It is independent of the application
containers and is not required by self-hosts.
| Binding | What it does | Default |
|---|---|---|
PAGES_ORIGINS | Worker-only JSON object mapping exact public hostnames to their HTTPS Pages production origins. Only configured hostnames are served. The Worker removes application authorization and cookies before requesting static files; API traffic continues to use its existing host | {} |
Deploying the checked-in Worker configuration attaches no domains or routes.
Stage each hostname, verify effective TLS and frontend responses, and retain the
Pages association and DNS needed for rollback before moving production traffic.
The deployment procedure is in deploy/cloudflare/README.md.
Optional hosted static assets
Workers Static Assets can serve the same built frontend files directly, without a request Worker. This is a separate opt-in delivery path. The existing Pages projects remain the public build-configuration source and the rollback copy. Self-hosted containers and their configuration are unchanged.
| GitHub variable or secret | What it does | Default |
|---|---|---|
CLOUDFLARE_STATIC_ASSETS_DEPLOY_ENABLED | Set the repository variable to true to enable configured static asset deployments alongside release Pages uploads, and permit the manual frontend-only workflow | unset |
CLOUDFLARE_STATIC_ASSETS_TARGETS | Repository variable containing JSON entries with project, worker and explicit domains. Projects must already appear in CLOUDFLARE_PAGES_PROJECTS. Domains are Worker custom-domain bindings; use a staging domain before any production hostname | [] |
CLOUDFLARE_STATIC_ASSETS_ACCOUNT_ID | Cloudflare account that owns the Workers custom domains, configured in the corresponding frontend's GitHub environment | unset |
CLOUDFLARE_STATIC_ASSETS_API_TOKEN | GitHub environment secret permitting Workers asset deployment and the explicitly configured custom domains in that account | unset |
For example, a staging entry is
[{"project":"dashboard-project","worker":"dashboard-assets-stage","domains":["assets-stage.example.com"]}].
No wildcard domain is accepted. Empty domains do not attach a new domain or
detach a custom domain attached by an earlier deployment. Remove an existing
binding in the Worker's Settings > Domains & Routes page when needed.
workers.dev and preview URLs stay disabled. The publisher preserves
_headers and the dashboard's _redirects. For admin it replaces the Pages
catch-all rewrite with the platform's SPA fallback and keeps the other
frontends' 404 behavior. It rejects symlinks, Pages Functions, files above 25 MiB
and artifacts exceeding the Free plan's 20,000-file limit.
The manual Publish released static assets workflow rebuilds an existing
published stable release using its tag and exact commit SHA. It supports
v0.6.28 and later releases containing the public Pages configuration importer;
older tags fail the source check before publishing. It does not create a tag,
release, application deployment or worker-fleet update. It uses current public
Pages configuration, so a rebuilt artifact can differ from the earlier build if
that configuration changed. The deployed artifact includes
/.well-known/warmbly-release.json and is retained as a workflow artifact.
Subsequent releases publish the same built files to Pages and configured static
asset targets. Static asset requests do not invoke a Worker script; confirm the
account's current limits and pricing before enabling any other Workers feature.
Before moving a production hostname, verify the staging site's certificate, TLS policy, root and nested routes, assets, response headers and redirects. Archive its existing Pages association and DNS. If verification fails after a cutover, remove the Worker's custom-domain binding and restore that Pages association and DNS. Redeploying the saved asset artifact to the same Worker reapplies its custom-domain routes; it does not restore Pages or DNS. Keep the Pages deployment and its configuration available through the rollback window. A source merge alone does not attach a domain.
Whole-domain connects
A workspace can connect every mailbox on a Microsoft 365 organization through one administrator's grant (Add account > Microsoft > Whole organization). Google Workspace domain grants are legacy and no longer offered under Add account: a grant a workspace already holds keeps working and is reached from View the grant (Check the grant when the grant is invalid) in the drawer of a mailbox it connected, and a new domain is refused with google_delegation_retired. A self-host that configured only the delegation service account can no longer set up a new Workspace domain; configure the BOX_GOOGLE_* OAuth client instead, which each domain's administrator marks Trusted. A client with the Internal user type on your own Workspace organization is exempt from Google's 100-user cap for unverified apps (Google's help). 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.
| Variable | What it does | Default |
|---|---|---|
GOOGLE_WORKSPACE_DELEGATION_KEY | The JSON key of a Google Cloud service account with domain-wide delegation. Its client_id is what a Workspace super admin authorized. It serves only the Google grants workspaces already hold; keep it set while any is in use. Unset, Google Workspace grants are off. A value that is not a service account key logs one line at boot and leaves them off | unset |
GOOGLE_WORKSPACE_DELEGATION_KEY_FILE | A path to the same key as a file, for a mounted secret. Read when GOOGLE_WORKSPACE_DELEGATION_KEY is empty | unset |
Google, only for an instance whose workspaces already hold grants (a new one has no use for it):
- In the Google Cloud console, create a project or pick the one you will keep. Its project ID is permanent.
- Under APIs & Services > Library, enable the Gmail API and the Admin SDK API.
- Under IAM & Admin > Service accounts, create a service account (for example
warmbly-workspace). Grant it no roles on the project. - 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.
- Put the key's path in
GOOGLE_WORKSPACE_DELEGATION_KEY_FILE(mounted read only) or its JSON inGOOGLE_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, orAPI_PUBLIC_URLplus/v1/auth/google/callback), with theopenidandemailscopes 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, theBOX_GOOGLE_*mailbox client is used instead, the same way. Neither depends onBOX_GOOGLE_OAUTH_CONNECT. - A
TXTrecord at_warmbly.<domain>holding awarmbly-verify=value unique to the workspace and domain, always available and the only proof when neither client is set. The value is keyed byDOMAIN_PROOF_SECRET, which defaults toAUTH_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.SendandUser.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 SenditCold 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.
Mailbox sync
How quickly a worker sees new mail. Workers poll every mailbox once a minute unless the provider announces changes; realtime mailbox sync explains each channel and the Google Cloud and Microsoft setup. Gmail and Graph push are off until configured, and every mailbox keeps polling either way. A joined worker receives IMAP_IDLE and SYNC_PUSH_FALLBACK_INTERVAL from the control plane; a joined consumer that should read Gmail push needs the Gmail settings in its node.local.env.
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
IMAP_IDLE | Read by workers. Holds each IMAP mailbox's INBOX in IDLE on a second connection and syncs within seconds of a change; other folders are then polled every 3 minutes. false keeps IMAP mailboxes on the one-minute poll | true | yes |
SYNC_PUSH_FALLBACK_INTERVAL | Read by workers. How often a mailbox with a live Gmail watch or Graph subscription is still polled, as a Go duration. Bounded to 2m to 60m; an invalid value uses the default | 10m | yes |
GRAPH_PUSH | Read by the backend. true has each Outlook and Microsoft 365 mailbox's worker open a Microsoft Graph change-notification subscription posting to BACKEND_PUBLIC_URL (else API_PUBLIC_URL) plus /api/v1/mailbox-push/graph/<mailbox-id>. Stays off unless that URL is https and reachable by Microsoft. Notifications are authenticated with a value derived from AUTH_SECRET. Unset again, workers close their subscriptions as mailboxes reload | false | yes |
GMAIL_PUSH_TOPIC | Read by the backend. The Cloud Pub/Sub topic, projects/<project>/topics/<topic>, each Gmail mailbox's worker watches into. Create it in the project of BOX_GOOGLE_CLIENT_ID; whole-domain grant mailboxes watch it only when GOOGLE_WORKSPACE_DELEGATION_KEY belongs to the same project, and keep polling otherwise. Set alone it moves Gmail mailboxes to the push fallback poll, so set it with GMAIL_PUSH_SERVICE_ACCOUNT or a consumer's GMAIL_PUSH_SUBSCRIPTION. Unset again, workers stop their watches as mailboxes reload | unset | yes |
GMAIL_PUSH_SERVICE_ACCOUNT | Read by the backend. The service account a Pub/Sub push subscription signs its requests with. Set, the backend accepts Gmail notifications at BACKEND_PUBLIC_URL (else API_PUBLIC_URL) plus /api/v1/mailbox-push/gmail, checking each request's Google-signed token. Stays off unless that URL is https | unset | yes |
GMAIL_PUSH_SUBSCRIPTION | Read by the consumer. A pull subscription on that topic, projects/<project>/subscriptions/<id>, it reads notifications from instead of a push subscription. Needs no public endpoint | unset | yes |
GOOGLE_APPLICATION_CREDENTIALS_JSON | Read by the consumer for Gmail push: a Google service account key (JSON) holding Pub/Sub Subscriber on the subscription. Unset uses Application Default Credentials. The backend reads the same variable for Cloud Tasks callbacks when TASKS_PROVIDER=gcloud | unset | yes |
Integrations
| Variable | What it does | Default |
|---|---|---|
<PROVIDER>_OAUTH_CLIENT_ID, <PROVIDER>_OAUTH_CLIENT_SECRET | OAuth clients for the CRM and messaging integrations | unset |
HUBSPOT_OAUTH_CLIENT_ID, HUBSPOT_OAUTH_CLIENT_SECRET | Your HubSpot app. Needed on the backend and on every consumer: the consumer runs the HubSpot mode pull and sync queue and refreshes the token as it goes. The secret also verifies HubSpot's webhook signatures | unset |
PIPEDRIVE_OAUTH_CLIENT_ID, PIPEDRIVE_OAUTH_CLIENT_SECRET | Your Pipedrive app (see Pipedrive self-hosting). Needed on the backend and on every consumer: the consumer runs the Pipedrive mode pull and sync queue and refreshes the token as it goes. The secret also derives the password Pipedrive presents on each webhook delivery, and verifies the SenditCold panel's signed calls | unset |
SALESFORCE_OAUTH_CLIENT_ID, SALESFORCE_OAUTH_CLIENT_SECRET | Your Salesforce app (see Salesforce self-hosting). Needed on the backend, which runs the sync, and on every consumer, which runs Salesforce automation actions and refreshes the token for them | unset |
GOOGLE_SHEETS_OAUTH_CLIENT_ID, GOOGLE_SHEETS_OAUTH_CLIENT_SECRET | Google Web application client for contact sync sources. Both are required on the backend. Separate settings from Google dashboard login and Gmail mailbox OAuth | unset |
INTEGRATIONS_OAUTH_REDIRECT_URL | Shared redirect URI for those flows | BACKEND_PUBLIC_URL (else API_PUBLIC_URL) plus /integrations/oauth/callback |
SLACK_OAUTH_CLIENT_ID, SLACK_OAUTH_CLIENT_SECRET | The instance's own Slack app. Unset, the dashboard reports Slack as not set up and no workspace can connect it | unset |
SLACK_SIGNING_SECRET | Verifies every request Slack sends to the events and interactivity URLs. Without it Slack still posts notifications and inbox conversations, but the assistant and buttons are off, and those URLs answer 503 slack_not_configured | unset |
The three Slack values come from the Slack app's Basic Information page. Set them on the backend and the consumer and restart both; the shipped docker-compose.yml passes all three to both. The request URLs are built on BACKEND_PUBLIC_URL when it is set, otherwise on API_PUBLIC_URL. See Slack app for creating the app from its manifest.
Google Sheets app
Google Sheets connects from Contacts > Sync sources, not the integrations catalog. The on-demand sync reads the selected tab and imports contacts only when you run it.
Different permissions alone do not require a separate Google Cloud project, but consent configuration and verification are shared by the OAuth clients within a project. To keep Sheets consent and verification separate from an existing Google login or Gmail setup, use a dedicated project with its own consent configuration and Web application client. A second client in the same project does not provide that isolation. Google also recommends a separate test project when evaluating new scopes for an approved app. A separate project still needs the verification required for its requested scopes.
- In your Google Cloud project, enable the Google Sheets API.
- Configure the project's Google Auth Platform branding and audience. For an external app in Testing, add the Google accounts that will connect as test users. For a public launch, follow Google's sensitive-scope verification requirements.
- Create an OAuth client of type Web application, or reuse an existing Web application client in that project. Add the exact backend callback to its Authorized redirect URIs:
INTEGRATIONS_OAUTH_REDIRECT_URLwhen set, otherwiseBACKEND_PUBLIC_URL(elseAPI_PUBLIC_URL) plus/integrations/oauth/callback. For example,https://api.example.com/integrations/oauth/callback. It is not the dashboard URL, the Google login callback, or the Gmail mailbox callback. - The current client requests
https://www.googleapis.com/auth/spreadsheetsandhttps://www.googleapis.com/auth/userinfo.email. Include these scopes in the consent configuration. The Sheets scope is sensitive and grants read/write access, even though contact sync does not write back. It is not the restricted Gmail scope; an existing Google login or Gmail approval does not automatically approve Sheets access. - Set the client's ID and secret as
GOOGLE_SHEETS_OAUTH_CLIENT_IDandGOOGLE_SHEETS_OAUTH_CLIENT_SECRETin the backend's process environment, then restart or redeploy the backend. For containers, explicitly pass both variables into the service; adding them to a Compose.envalone does not forward them. Do not put the secret in dashboard build variables. Changing these settings requires a restart, not a database migration.
Neither GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET nor BOX_GOOGLE_CLIENT_ID / BOX_GOOGLE_CLIENT_SECRET is used as a fallback. If either Sheets credential is missing, Connect with Google returns 501 with an OAuth-not-configured message before contacting Google. On SenditCold Cloud, the service operator configures the client once for all workspaces; workspace members only authorize their own Google account.
After setup, connect a sync source: authorize an account that can access the spreadsheet, paste its ID, select a tab, map the columns (including email), and save. Run Sync now whenever you want to import new rows. The first row should contain column headers, with one contact per subsequent row.
HubSpot app
HubSpot mode on a self-hosted instance uses a HubSpot app you create in your own HubSpot developer account:
- Redirect URL: the value of
INTEGRATIONS_OAUTH_REDIRECT_URL, orBACKEND_PUBLIC_URL(elseAPI_PUBLIC_URL) plus/integrations/oauth/callbackwhen it is unset - Required scopes:
oauth,crm.objects.contacts.read,crm.objects.contacts.write,crm.objects.companies.read,crm.objects.companies.write,crm.objects.deals.read,crm.objects.deals.write,crm.objects.owners.read,crm.schemas.contacts.read,crm.schemas.contacts.write - Optional scopes:
crm.lists.read(import from HubSpot lists) andsales-email-read. An account that cannot grant them still connects - Webhooks (optional): target URL
<backend>/api/v1/integrations/hubspot/webhooks, subscribed tocontact.creation,contact.propertyChange,contact.deletion,deal.creation,deal.propertyChangeanddeal.deletion. Every HubSpot request is verified against itsX-HubSpot-Signature-v3signature over the public URL,BACKEND_PUBLIC_URL(elseAPI_PUBLIC_URL) plus the path, so that URL must be the address HubSpot calls. Without webhooks the consumer's one-minute pull keeps everything current
APP_URL is also what the warmbly_link property on HubSpot contacts links to. A consumer that joined the fleet with warmbly join is handed the HubSpot variables and APP_URL with the rest of its environment; a worker never is.
The repository's hubspot-app/ directory is the same app as a HubSpot developer project: the app definition, the SenditCold card on contact records, the "Add to SenditCold campaign" workflow action and the webhook subscriptions. Replace https://api.senditcold.com with your backend URL in its *-hsmeta.json files and in src/app/cards/WarmblyCard.tsx, then upload it with the HubSpot CLI (hs project upload from that directory). The CLI leaves out files under a hidden directory, so upload from a path with no dot-folder in it; otherwise the card build reports its entrypoint as missing. The card is permitted to fetch only <backend>/api/v1/integrations/hubspot/app/ (permittedUrls.fetch in app-hsmeta.json). The card and the workflow action call these public routes, each verified with the app's client secret. The card routes take the portal and user from the values HubSpot appends to the query; the webhook and workflow action routes take the portal from the body and refuse a request that carries those query values:
| Route | Called by |
|---|---|
POST /api/v1/integrations/hubspot/webhooks | HubSpot webhooks |
POST /api/v1/integrations/hubspot/app/card | The SenditCold card, to show a contact |
POST /api/v1/integrations/hubspot/app/enroll | The card's "Add to campaign" |
POST /api/v1/integrations/hubspot/app/pause | The card's pause and resume |
POST /api/v1/integrations/hubspot/actions/enroll | The "Add to SenditCold campaign" workflow action |
POST /api/v1/integrations/hubspot/actions/campaigns | The workflow action's campaign list |
Pipedrive app
Pipedrive mode on a self-hosted instance uses a Pipedrive app you create in the Pipedrive Developer Hub:
-
Callback URL: the value of
INTEGRATIONS_OAUTH_REDIRECT_URL, orBACKEND_PUBLIC_URL(elseAPI_PUBLIC_URL) plus/integrations/oauth/callbackwhen it is unset -
Scopes: deals, contacts and activities with full access, and users read. Leads full access (an interested reply adds a lead), contact fields full access (the SenditCold person fields) and webhooks full access (instant updates) are used when granted; without them those steps are skipped. Pipedrive takes scopes from the app, not from the request
-
Webhooks: none to set up. SenditCold registers its own, per connection, at
<backend>/api/v1/integrations/pipedrive/webhooks/<connection id>when a workspace switches, and removes them when it switches away. They needBACKEND_PUBLIC_URL(elseAPI_PUBLIC_URL) to be anhttpsaddress Pipedrive can reach; otherwise the consumer checks Pipedrive every five minutes instead -
App extensions (optional): the SenditCold panel on person and deal pages. Under App extensions, add a JSON panel with the API endpoint
<backend>/api/v1/integrations/pipedrive/app/panel, the JSON data structure frompipedrive-app/panel.schema.json, and the Person details and Deal details locations. Add two JSON modals with the same locations: "Add to SenditCold campaign" (<backend>/api/v1/integrations/pipedrive/app/enroll, schemapipedrive-app/enroll-modal.schema.json) and "Pause in SenditCold" (<backend>/api/v1/integrations/pipedrive/app/pause, schemapipedrive-app/pause-modal.schema.json), and add both as actions of the panel. Leave every JWT secret blank: Pipedrive then signs each call with the app's client secret, which is what the backend checks, along with the user and company the token names. Pipedrive needs the backend onhttpswith a certificate from a public authority
APP_URL is also what the Open in SenditCold person field links to. A consumer that joined the fleet with warmbly join is handed the Pipedrive variables, APP_URL and BACKEND_PUBLIC_URL (or API_PUBLIC_URL) with the rest of its environment, since the consumer is what registers the webhooks; a worker never is.
Salesforce reads SALESFORCE_OAUTH_CLIENT_ID and SALESFORCE_OAUTH_CLIENT_SECRET, from an External Client App or Connected App in your own org with PKCE required and the api, refresh_token and id scopes. One app serves production, sandbox and My Domain logins; each member picks theirs when connecting. See Salesforce self-hosting.
AI and search
| Variable | What it does | Default |
|---|---|---|
AI_PROVIDER | openai, openrouter, groq, ollama, anthropic or custom. Omit every AI variable to run with AI off, in which case AI endpoints return a clean 503 and warmup uses only its built-in conversation library. Warmup batch generation requires openai or an unset provider with an OpenAI key | unset |
AI_API_KEY | Provider key. Not needed for ollama | unset |
AI_MODEL, AI_MODEL_TRIAL, AI_MODEL_PAID | Model selection, optionally split by plan | provider preset |
AI_BASE_URL | Required for custom. Any OpenAI compatible endpoint | unset |
AI_FREE | Treats AI usage as uncharged | derived |
SEARCH_PROVIDER | Web search for the assistant, contact research, AI variables and the lead finder: serper, searxng, ddgs, jina, brave or tavily. A comma-separated list (ddgs,jina) is tried in order, and a provider that errors or finds nothing hands the query to the next | unset |
SEARCH_API_URL, SEARCH_API_KEY | Endpoint and key of the first provider in SEARCH_PROVIDER. A single-provider setup needs nothing else | unset |
SEARCH_<PROVIDER>_URL, SEARCH_<PROVIDER>_API_KEY | Per-provider endpoint and key, for example SEARCH_DDGS_URL=http://ddgs:4479 and SEARCH_JINA_API_KEY. searxng and ddgs need a URL (your own server); serper, jina, brave and tavily need a key and default to their public endpoints | unset |
WEB_READER | Reader services tried in order when a site answers 401, 403, 406, 429 or 503 to the lead finder or the assistant's fetch_url: crawl4ai, jina, ddgs, custom. A comma-separated list (crawl4ai,jina). The lead finder still obeys robots.txt and never hands a reader a page it disallows; fetch_url reads only the page the assistant asked for, as it does without a reader | unset |
WEB_READER_CRAWL4AI_URL, WEB_READER_CRAWL4AI_API_KEY | A Crawl4AI server (http://crawl4ai:11235) and its bearer token, if it has one | unset |
WEB_READER_JINA_URL, WEB_READER_JINA_API_KEY | Jina Reader. Works without a key at Jina's free rate limit; the URL points at a self-hosted copy | https://r.jina.ai |
WEB_READER_DDGS_URL | A DDGS API server (ddgs api, port 4479 by default), read through its /extract endpoint | unset |
WEB_READER_CUSTOM_URL, WEB_READER_CUSTOM_API_KEY | Any reader that answers a GET with the page's HTML, or JSON carrying it (html, content, data.html). The URL is a template: {url} is replaced by the page address as is, {url_encoded} by its query-escaped form. The key is sent as a bearer token | unset |
MAILHOST_ISPDB | true 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 control | false on a self-host, true on SenditCold Cloud |
MAILVENDOR_SANDBOX_URL | Development 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 dev | unset |
TYPESAFE_API_KEY | TypeSafe API key. Turns on the typed judgments: reply classification, the copy judgment in the editor, warmup content review (otherwise performed by OpenAI when its generator is configured), bounce classification, import column mapping and per-form triage. Inbox tagging needs the switch below as well. See data control for what each one sends | unset |
TYPESAFE_ENDPOINT | URL the TypeSafe judgments are posted to, for a regional or proxied TypeSafe endpoint. Read by the backend, the consumer and warmblyctl inbox-tag; has no effect without TYPESAFE_API_KEY. Must be https, except http to a loopback or private-network host; any other value stops the service at startup | https://api.typesafe.ai/v1/systemone |
INBOX_TAGGING_ENABLED | true enables automatic inbox tagging when TYPESAFE_API_KEY is also set. Message subject, current body, and the previous outbound body are sent to TypeSafe | false |
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.
The OpenAI-compatible agent loop also handles catalogs larger than 128 tools automatically. It exposes read-only tool discovery and loads up to 20 matching tools at a time, rather than sending the whole catalog in one request. All permitted built-in and connected MCP tools remain discoverable, and their original permissions and approval rules still apply. Existing conversations can continue after upgrading; no database migration or AI configuration change is needed.
Set these on the backend and the consumer.
A reader answers from its own network, so it can follow a site's redirect to anything that network reaches. SenditCold refuses a page when the reader reports it landed on a private address (Jina and Crawl4AI report where they landed; DDGS and most custom readers do not), and it only hands a reader pages on public addresses. Run a self-hosted reader where it cannot reach your private services.
Workspaces can also bring their own: a Jina AI key under Integrations gives that workspace's lead finder Jina Reader and Jina Search, and a tool on a connected MCP server can be picked as the workspace's web search. See the lead finder.
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.
Lead finder
The lead finder works with nothing set: OpenStreetMap through public Overpass servers, Komoot's public Photon geocoder for typed locations, and OpenFreeMap's keyless vector tiles for the map. Every variable here is optional and read by the backend.
| Variable | What it does | Default |
|---|---|---|
LEAD_FINDER_OVERPASS_URL | Overpass endpoints for OpenStreetMap searches, separated by commas and tried in order when one is busy. Point it at your own Overpass for dependable searches; the public ones are shared and often at capacity | overpass-api.de, overpass.private.coffee and maps.mail.ru mirrors |
LEAD_FINDER_GEOCODER_URL | A Photon-compatible /api endpoint for the Where box | https://photon.komoot.io/api |
LEAD_FINDER_MAP_STYLE_URL, LEAD_FINDER_MAP_STYLE_DARK_URL | MapLibre style JSON for the map in light and dark themes. The Streets style is offered only with the default styles | OpenFreeMap positron and dark |
LEAD_FINDER_GOOGLE_PLACES_API_KEY | An instance-wide Google Maps Platform key with Places API (New). Every workspace without its own Google Places connection can then search Google, at one credit a page | unset |
LEAD_FINDER_FOURSQUARE_API_KEY | An instance-wide Foursquare service key, used the same way | unset |
LEAD_FINDER_OUTSCRAPER_API_KEY | An instance-wide Outscraper key, used the same way | unset |
A workspace's own key, connected under Integrations, always takes precedence over the instance's and costs no credits.
The dashboard's content security policy allows https://tiles.openfreemap.org. A map style served from anywhere else, including your own tile server, also needs its origin (and the origins of its tiles, sprites and glyphs) in WARMBLY_CSP_CONNECT_ORIGINS on the web container, or the map stays blank.
Reading websites for emails runs from the backend, opens only public addresses on ports 80 and 443, and identifies itself as WarmblyBot/1.0. See data control for every call the lead finder makes.
Tasks and billing
Warmup replies use the same persisted SQL task identity with either queue provider. They wait at least 45 minutes after the exact parent receipt and recheck mailbox windows, roles, consent, standing, suppression and remaining budgets before native dispatch. Queue callbacks do not grant sending authority. See diagnostic generation and rollout.
Per-mailbox test_mode, test_send_enabled and test_receive_enabled separate diagnostic sending from receiving; new mailboxes default off and legacy NULL values keep prior behavior. Positive shared_daily_limit and rolling_recipient_limit optionally constrain all outbound lanes above their separate cold/diagnostic allocations. These are database-backed mailbox PATCH fields, not environment variables or global admin settings. Current participation, calendars, suppression, provider holds and durable reservations are rechecked by protocol-2 executors. See defaults, repair evidence and rollout.
| Variable | What it does | Default |
|---|---|---|
TASKS_PROVIDER | local (an in-process Postgres poller; each backend replica claims the due tasks it runs) or gcloud (Cloud Tasks) | local |
TASKS_LOCAL_POLL_INTERVAL | How often the local poller looks for due work | 1s |
SEND_PLAN_SNAPSHOT_INTERVAL | How often the backend re-walks the send plan of every active campaign whose stored plan is out of date. Each walk reads all of that campaign's leads, so a longer interval lowers database load on large campaigns | 1m |
SEND_PLAN_SNAPSHOT_MAX_AGE | How old a stored send plan may be before it counts as out of date. An out-of-date plan is still served, with stale: true, while a fresh one is computed in the background | three times SEND_PLAN_SNAPSHOT_INTERVAL (3m) |
BILLING_PROVIDER | none (every feature unlocked, no trial expiry; the dashboard reports the workspace as self-hosted rather than on a free tier and hides billing) or stripe | none |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PUBLISHABLE_KEY | Required together when BILLING_PROVIDER=stripe. The backend exits at boot if any is missing | unset |
The local task dispatcher runs at most eight handlers at once, lowered to one third of the backend's effective Postgres pool ceiling with a minimum of one. This leaves room for API requests and reconciliation. Work beyond that limit stays pending in Postgres without a dispatch attempt or failure stamp. The ceiling applies across polling rounds, not separately to each batch, and does not change sending authority or unknown-outcome protections.
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 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. Money added to a workspace balance needs no product or price in Stripe: each payment is billed as an invoice, or a Checkout line, created for the amount. The STRIPE_CREDIT_PACK_*_PRICE_ID variables are no longer read and can be removed.
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 SenditCold 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=falsekeeps 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.
| Variable | Read by | What it does | Default |
|---|---|---|---|
POSTHOG_KEY | backend, consumer, worker | Server-side error tracking, and the server-side product events below | unset |
WARMBLY_POSTHOG_UI_HOST / PUBLIC_POSTHOG_UI_HOST | Where 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 do | https://us.posthog.com | yes (build or container start) |
POSTHOG_PROXY_INGEST | Upstream 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 reporting | https://us.i.posthog.com | yes |
POSTHOG_PROXY_ASSETS | Upstream for /ingest/static/*. PostHog serves its browser bundle from a different host than it ingests on, and sending one to the other 404s | https://us-assets.i.posthog.com | yes |
POSTHOG_KEY | forms service | The forms service's own errors. It reads its own environment, like tracking | unset |
POSTHOG_KEY | tracking service | Rust errors and panics, posted to the capture endpoint directly | unset |
POSTHOG_KEY | realtime service | Elixir exceptions, with the stack trace the rescue site caught | unset |
POSTHOG_HOST | every server-side service | Capture host. Point it at a self-hosted PostHog if you run one | https://us.i.posthog.com |
POSTHOG_ERROR_TRACKING | every server-side service | false keeps the key for product analytics and reports no exceptions to PostHog | true |
SENTRY_DSN | backend, consumer, worker | Server-side error reporting. Optional in every environment, including prod | unset |
SENTRY_DSN | forms service | The forms service's own errors | unset |
SENTRY_DSN | tracking service | Rust errors and panics. An invalid DSN disables reporting with a log line rather than stopping the service | unset |
SENTRY_DSN | realtime service | An empty string is treated as unset on purpose, because the library rejects "" hard enough to take the node down | unset |
WARMBLY_POSTHOG_KEY | web, admin containers | Browser error tracking, read at container start like the other WARMBLY_* values. The same key carries the product analytics and session replay below | unset |
WARMBLY_POSTHOG_KEY | forms service | Stamped 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 loads | unset |
WARMBLY_POSTHOG_HOST | web, admin containers, forms service | Capture host for the browser | https://us.i.posthog.com |
WARMBLY_POSTHOG_ERROR_TRACKING | web, admin containers, forms service | false reports no browser exceptions to PostHog anywhere, including the form pages the forms service stamps. Everything else the key turns on keeps running | true |
WARMBLY_POSTHOG_SESSION_REPLAY | web, admin containers | false records no sessions and keeps everything else. Form pages never record, whatever this says | true |
WARMBLY_COMPANY_LOGOS | web container | on 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 SenditCold Cloud. See outbound calls | unset |
WARMBLY_SENTRY_DSN | web, admin containers | The Sentry half of the same thing. Unset means the SDK is never fetched and no host is contacted | unset |
WARMBLY_SENTRY_DSN | forms service | Stamped into the public form page, along with the service's APP_ENV as the environment | unset |
WARMBLY_SENTRY_ENVIRONMENT | web, admin containers | The 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 instead | unset |
APP_ENV | every server-side service | Doubles 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 error | dev |
WARMBLY_RELEASE | tracking, realtime | The 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 instead | dev |
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 SenditCold 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:
| Property | On | What it is for |
|---|---|---|
organization_id, user_id | browser 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_id | server panics, and browser exceptions whose trail includes a failed call | The 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_method | server panics | The 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, release | everything | Which 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 variable | Backend | What it does |
|---|---|---|
POSTHOG_CLI_API_KEY, POSTHOG_CLI_PROJECT_ID | PostHog | Both 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_HOST | PostHog | The instance to upload to, for PostHog EU or a self-hosted one |
SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT | Sentry | All 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.
| Variable | Read by | What it does | Default |
|---|---|---|---|
POSTHOG_KEY | backend | Server-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 group | unset |
POSTHOG_HOST | backend | Capture host. Point it at a self-hosted PostHog if you run one | https://us.i.posthog.com |
WARMBLY_POSTHOG_KEY | web, admin containers | Browser analytics and session replay, read at container start. Unset means the SDK chunk is never fetched | unset |
WARMBLY_POSTHOG_HOST | web, admin containers | Capture host for the browser | https://us.i.posthog.com |
WARMBLY_POSTHOG_SESSION_REPLAY | web, admin containers | false keeps the analytics and records no sessions | true |
PUBLIC_POSTHOG_KEY | site/ build | Marketing site analytics. Build-time: unset produces a site with no analytics code in it at all | unset |
PUBLIC_POSTHOG_HOST | site/ build | Capture host for the site | https://us.i.posthog.com |
What each surface sends, with a key set:
| Surface | Identity | Captured |
|---|---|---|
| Dashboard | The signed-in account (identify with email and name) and its workspace as an organization group | Pageviews 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 panel | The signed-in operator | The same set, so a slow screen or a broken action in the operator surface is findable the same way |
| Marketing site | Nobody. Cookieless: nothing is stored in the browser and no person is ever created | Pageviews 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 pages | Nobody. Cookieless, for the stranger filling in a customer's form | Pageviews, 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
| Variable | What it does | Default |
|---|---|---|
APNS_KEY or APNS_KEY_PATH, APNS_KEY_ID, APNS_TEAM_ID, APNS_TOPIC | Mobile push on backend and consumer. APNS_KEY is the .p8 file as PEM or base64, and APNS_TOPIC the app's bundle id. Partial configuration disables push with a warning, never a crash | unset |
Updates
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
UPDATE_CHECK_ENABLED | Polls GitHub Releases for a newer SenditCold and shows it in the admin panel's top bar and on Setup and health | true | yes |
UPDATE_CHECK_INTERVAL | How often the release check runs. Minimum 5m | 30m | yes |
UPDATE_CHANNEL | stable follows releases; dev also offers prereleases | stable | yes |
RELEASES_GITHUB_REPO | The owner/repo whose releases count as SenditCold versions. Point a fork's instance at the fork | warmbly/warmbly | yes |
RELEASES_GITHUB_TOKEN | Optional GitHub token; only raises the API rate limit | unset | yes |
RELEASES_ENABLED | Resolves the fleet's target release from RELEASES_GITHUB_REPO on boot and on each check, and hands it to nodes in their heartbeat so they update themselves. Off by default: no release lookup on boot, and nodes are never told to update | false | yes |
RELEASES_WORKER_IMAGE_REPO | Image repository a node's target version is pulled from, as <repo>:<tag>. Point it at your own registry to roll a fleet from your own builds | ghcr.io/warmbly/warmbly/worker | yes |
UPDATER_URL | The host-side updater that applies an update (pull, rebuild, restart). Unset, or none, leaves the panel report-only | http://updater:8095 under compose | yes |
UPDATER_TOKEN | The bearer token the backend presents to the updater, which can pull and restart the instance. Give it its own value so the token the tracking and forms services hold cannot start an update; the installer generates one | INTERNAL_API_TOKEN | yes |
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.
| Variable | What it does | Default |
|---|---|---|
FORMS_PORT | Listen port | 8090 |
FORMS_STATIC_DIR | The built forms app (pnpm build output). The service exits at boot when index.html is missing there | forms/dist |
BACKEND_INTERNAL_URL | Where the service resolves forms and forwards submissions, the same variable the tracking service uses. Required: the service exits at boot without it | none |
BACKEND_INTERNAL_CA, BACKEND_INTERNAL_CA_FILE | PEM certificates that replace the system roots when verifying an https:// BACKEND_INTERNAL_URL. See TLS listeners | unset (system roots) |
INTERNAL_API_TOKEN | Bearer token for those calls, matching the backend's. Required: the service exits at boot on an empty value | none |
FORM_IP_RATE_LIMIT | Public form submissions allowed per source IP per 10 minutes, per forms-service instance | 30 |
POSTHOG_KEY, POSTHOG_HOST, POSTHOG_ERROR_TRACKING | The service's own error tracking. Unset means none | unset |
SENTRY_DSN | The same through Sentry, alongside PostHog or instead of it | unset |
WARMBLY_POSTHOG_KEY, WARMBLY_POSTHOG_HOST, WARMBLY_POSTHOG_ERROR_TRACKING | Stamped into the form page so the browser app reports too. Unset, or error tracking off, means the page loads no reporting SDK at all | unset |
WARMBLY_SENTRY_DSN | The Sentry half of the same stamping | unset |
TRUSTED_PROXIES | CIDRs 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's | empty |
Portal service
The portal service serves what an agency's clients open: client connect pages, shared reports and the /embed.js loader that puts either on a website. Its source is portal/worker, and it serves the page app built from portal/. It answers two kinds of host:
- the shared address,
PORTAL_DOMAIN: connect links and reports of any workspace, and nothing else. Every other path answers "not found" - every verified agency portal domain: that agency's clients' connect links and reports, and the dashboard from
DASHBOARD_ORIGINon every other path. Any other host answers404, or goes toFALLBACK_ORIGINwhen that is set
It holds no database and no secret beyond PORTAL_EDGE_TOKEN: it asks the backend whether a host is a verified portal domain and resolves every link through the backend's internal portal routes. Connect links and report links need it. Without PORTAL_DOMAIN the backend mints neither and every other feature works as before.
On SenditCold Cloud it is a Cloudflare Worker (see above). A self-hosted instance runs the same code as its own portal container, ghcr.io/warmbly/warmbly/portal, published with every release beside the other images. The image runs it on workerd, the open-source runtime Cloudflare Workers run on, as a non-root user, listening on port 8095. The installer, the bundled docker-compose.yml and the Coolify compose file all include it. It reads its own environment:
| Variable | What it does | Default |
|---|---|---|
API_ORIGIN | Where it reaches the backend, as an http(s) origin such as https://api.example.com or http://backend:8080. Required: unset, it serves nothing | none |
PORTAL_DOMAIN | The same value the backend reads. Required: unset, it serves nothing | none |
PORTAL_EDGE_TOKEN | Its bearer token for the backend, the same value as the backend's. At least 32 characters; a shorter one answers 503 everywhere. Required | none |
DASHBOARD_ORIGIN | Where the dashboard build is served from, as an http(s) origin such as the Pages origin or http://web:80. Unset, a portal domain serves connect links and reports only | unset |
CLIENT_IP_HEADER | The header naming the visitor's address, for the per-visitor budgets and the address passed to the backend. Behind a proxy that appends, the last entry counts. The compose files set X-Forwarded-For | CF-Connecting-IP |
FALLBACK_ORIGIN | A plain http:// origin, such as http://tracking:3000, that receives every request for a host that is neither PORTAL_DOMAIN nor a verified portal domain, with its method, path, query, headers, body and Host unchanged, and whose answer is returned as it is. A failed portal lookup still answers 503 and is never passed on. With PORTAL_DOMAIN or PORTAL_EDGE_TOKEN unset, every request is passed on. The compose files set http://tracking:3000; SenditCold Cloud leaves it unset | unset |
FALLBACK_ORIGIN is what lets one proxy rule send every customer-owned domain through the portal. The installer's Caddyfile sends a custom domain's tracking paths (/t/, /c/, /tracking.js, /p, /unsubscribe/) to the tracking service and its form paths (/f/, /forms.js, /api/forms/) to the forms service, as before, and everything else to the portal. The portal serves an agency portal domain itself and hands any other host, such as a custom tracking domain's root or a root redirect, to tracking unchanged, so those answer exactly as they did before the portal existed.
Per visitor and minute it allows 30 connect link lookups, 10 sign-in starts or server-details submits, 60 sign-in result polls and 60 report reads, counted by the platform's rate-limit bindings when it has them (CONNECT_VIEWS, CONNECT_ACTIONS, CONNECT_POLLS, REPORT_VIEWS) and in the process otherwise. The backend also limits attempts per link.
The backend side:
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
PORTAL_EDGE_TOKEN | The bearer token the portal service presents. It opens only the internal routes the portal service calls (the portal lookup, connect links and shared reports), and no other token opens them, INTERNAL_API_TOKEN included. At least 32 characters: the backend refuses to start with a shorter value. Treat it as a secret. PORTAL_EDGE_PREVIOUS_TOKENS and PORTAL_EDGE_PREVIOUS_UNTIL keep a replaced value working until a cutoff, like the internal token overlap. Unset, those routes open for nobody | unset | yes |
The dashboard on a portal domain
For the dashboard an agency's portal domain serves (see white-label):
- Set
DASHBOARD_ORIGINon the portal service. - Keep
CHECK_ORIGIN_HOSTSset on the realtime service (the installer sets it for a domain install). Portal origins are only added to a configured list. - The dashboard's
WARMBLY_API_URLmust be an absolute URL, such ashttps://api.example.com, because the dashboard on a portal domain calls the API on its own address. - For the security check on portal sign-in, set
TURNSTILE_SITE_KEYon the backend and list the API's hostname for that site key in Cloudflare (see captcha).
Upgrading a self-hosted instance
The portal is optional, and an instance upgraded without it keeps working: connect links and report links are refused (client_links_unavailable, report_links_unavailable) until both PORTAL_DOMAIN and the portal container exist, and nothing else changes. To turn it on:
- Installed with
install.sh: run the installer again. It adds theportalservice,PORTAL_DOMAIN(portal.<host>by default) and a generatedPORTAL_EDGE_TOKENto an existing install without touching any other value, and the regenerated Caddyfile adds the portal's site block. Point the portal host's DNS at the machine. The admin panel's Update and restart pins a release and pulls it but does not regenerate the compose file, so it does not add the portal on its own. - The bundled
docker-compose.yml: it already lists theportalservice. SetPORTAL_DOMAINandPORTAL_EDGE_TOKEN(openssl rand -hex 32) in.env, rundocker compose up -d, and route the portal host to port8095with itsHostheader kept. With both unset the container serves nothing itself and passes every request toFALLBACK_ORIGIN. - Coolify: set the same two variables and give the
portalservice the portal host on port8095.
The backend reads PORTAL_DOMAIN per request and PORTAL_EDGE_TOKEN at start, so it needs a restart after the token is set; docker compose up -d recreates it when .env changes. Start the portal before or with the backend that has PORTAL_DOMAIN, because links are built on that address from the first request. During a rolling update the order does not matter otherwise: a backend from before the portal ignores both variables and builds no link, so a new portal in front of it has nothing to serve and passes every other host to FALLBACK_ORIGIN as before.
The on-demand certificate check (/tls/authorize) answers yes for PORTAL_DOMAIN and every verified portal domain. The API accepts cross-origin calls, and OAuth sign-ins return, only for portal domains that are verified at the time, checked against the database and cached for at most two minutes.
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.
| Variable | What it does | Default |
|---|---|---|
TRACKING_HOST, TRACKING_PORT | Listen address | 0.0.0.0, 3000 |
BACKEND_INTERNAL_URL | Where tracking resolves opaque /c/<id> click tickets. Required: the service exits at boot without it | none |
BACKEND_INTERNAL_CA, BACKEND_INTERNAL_CA_FILE | PEM certificates that replace the system roots when verifying an https:// BACKEND_INTERNAL_URL. See TLS listeners | unset (system roots) |
INTERNAL_API_TOKEN | Bearer token for that lookup. Required: the service exits at boot on an empty value | none |
TRACKING_RATE_LIMIT_PER_MIN | Counted pixel and click requests per source per minute. Over budget, pixels are still served but not counted, and click redirects get 429 | 300 |
TRACKING_PAGEHIT_RATE_LIMIT_PER_MIN | Website page views accepted per source per minute, on top of the shared budget above. Over budget, the snippet gets 429 | 60 |
TRACKING_TRUSTED_PROXIES | CIDRs 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_PROXIES | empty |
TRACKING_IP_HASH_KEY | Secret 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 enumeration | INTERNAL_API_TOKEN |
TRACKING_CLIENT_IP_HEADER | The 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 Cloudflare | x-forwarded-for |
TRACKING_SCANNER_BUILTINS | Whether the known-scanner catalogue shipped with SenditCold is loaded: Microsoft 365's Exchange Online Protection ranges and Barracuda's Email Gateway Defense blocks. false leaves only your own entries | true |
TRACKING_SCANNER_NETWORKS | Extra 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 automated | empty |
TRACKING_SCANNER_CLICK_NETWORKS | The 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 opens | empty |
TRACKING_SCANNER_ASN_DB | Path 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 proxy | unset |
TRACKING_SCANNER_ASN_DB_URL | Where 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 GeoIP | unset |
TRACKING_SCANNER_ASN_HEADER | Header 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.asnum | empty |
EVENTBUS_PROVIDER | nats or kafka. Kafka needs the tracking:*-kafka image, or a build with CARGO_FEATURES=kafka | nats |
NATS_URL, NATS_SUBJECT_PREFIX | JetStream address and subject prefix. The publish subject is <prefix>.<topic> | nats://localhost:4222, warmbly |
NATS_TLS_CA_B64 | Base64 PEM trust bundle for private NATS TLS. Accepts multiple CA certificates for rotation; certificate and hostname checks remain enabled | unset |
CODEC_PROVIDER | json or avro, and it has to match the consumer's. avro is refused at boot without SCHEMA_REGISTRY_URL | json |
KAFKA_TRACKING_TOPIC | Event topic, read by the Rust publisher and the Go subscriber | tracking-events |
KAFKA_BOOTSTRAP_SERVERS, KAFKA_SASL_USERNAME, KAFKA_SASL_PASSWORD | Broker transport when EVENTBUS_PROVIDER=kafka | unset |
SCHEMA_REGISTRY_URL, SCHEMA_REGISTRY_KEY, SCHEMA_REGISTRY_SECRET | Registry for the Avro codec | unset |
AWS_CONFIG_ENABLED | true falls back to AWS SSM and Secrets Manager for any value missing from the environment | false |
APP_ENV | Environment label used in logs, and on reported errors | dev |
POSTHOG_KEY, POSTHOG_HOST, POSTHOG_ERROR_TRACKING | Error and panic reporting. Unset means none | unset |
SENTRY_DSN | The same through Sentry. An invalid value logs and disables it rather than stopping the service | unset |
WARMBLY_RELEASE | The build reported errors are tagged with | dev |
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.
| Variable | What it does | Default |
|---|---|---|
JWT_SECRET | Must equal the backend's AUTH_SECRET. Required: the service refuses to boot without it | none |
SECRET_KEY_BASE | Phoenix session signing. Required | none |
DATABASE_URL | Postgres, used to validate API keys. Required | none |
REDIS_URL | The Redis bridge the backend publishes events onto | redis://localhost:6379/0 |
PHX_HOST | The service's own hostname | localhost |
PORT | Listen port | 4000 |
CHECK_ORIGIN_HOSTS | Origins a browser may open a socket from, comma separated, e.g. https://app.example.com,https://admin.example.com. Not this service's own host. With it set, the https origin of any verified agency portal domain is accepted as well, looked up in Postgres and cached for two minutes | unset |
CHECK_ORIGIN | Legacy host-based form, consulted only when CHECK_ORIGIN_HOSTS is empty. true accepts an upgrade only from PHX_HOST | false |
REALTIME_ALLOW_QUERY_CREDENTIALS | true accepts an API key or OAuth token in the socket URL's token parameter, for an older client that cannot send the x-warmbly-token header. URLs reach proxy and access logs, so leave it off unless such a client still depends on it; the realtime service logs a warning at boot while it is on. Read in every environment, not only prod | false |
PUBSUB_ENABLED | true swaps the Redis bridge for Google Pub/Sub | false |
GCP_PROJECT_ID | Required when PUBSUB_ENABLED=true; the service refuses to boot without it | unset |
MAX_CONNECTIONS_PER_USER | Concurrent sockets one account may hold. The caller's plan limit applies too, whichever is lower | 10 |
MAX_CONNECTIONS_PER_IP | Concurrent sockets from one address | 50 |
MAX_CONNECTIONS_GLOBAL | Concurrent sockets on this node | 100000 |
RATE_LIMIT_WS_MESSAGE | Websocket messages per minute | 120 |
RATE_LIMIT_WS_CONNECT | Socket handshakes per minute | 30 |
RATE_LIMIT_WS_JOIN | Channel joins per minute, counted per phx_join on an open socket | 30 |
RATE_LIMIT_WS_EVENT | Client events per minute, which is what bounds presence updates | 60 |
POSTHOG_KEY, POSTHOG_HOST, POSTHOG_ERROR_TRACKING | Error reporting. Unset means none | unset |
SENTRY_DSN | The same through Sentry. An empty string is treated as unset on purpose, because the library rejects "" hard enough to take the node down | unset |
WARMBLY_RELEASE | The build reported errors are tagged with | dev |
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.
| Setting | Type | Default | What it does |
|---|---|---|---|
invitations.ttl_hours | integer, 1 to 48 | 48 | How 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_enabled | boolean | true | Whether 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_signup | boolean | true | Whether 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_days | integer, 1 to 730 | 90 | How far back a newly connected mailbox's initial import reaches, newest first |
sync.backfill_messages | integer, 1 to 100000 | 5000 | The most messages that import stores per mailbox |
sync.daily_messages_per_mailbox | integer, 1 to 100000 | 2000 | New (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_org | integer, 1 to 2000000 | 25000 | New plus imported messages one organization may store per UTC day |
retention.engagement_event_days | integer, 1 to 3650 | 365 | How 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_days | integer, 1 to 3650 | 180 | How 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_days | integer, 1 to 3650 | 90 | How 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_days | integer, 3 to 3650 | 30 | How long warmup mail stays in each mailbox before SenditCold 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_days | integer, 30 to 3650 | 365 | How long the per-message warmup records (tokens, receipts, tampering events, spam reports, warmup thread turns, and linked instances' retired warmup commands and 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 |
retention.campaign_log_days | integer, 0 to 3650 | 0 | How long each campaign's activity feed keeps an entry. 0 keeps every entry. Campaign stats, progress and sent mail are separate and unaffected |
retention.notification_days | integer, 0 to 3650 | 0 | How long in-app notifications are kept, read or unread. 0 keeps them all. One still waiting to be emailed is kept until it is sent or skipped |
retention.webhook_delivery_days | integer, 0 to 3650 | 0 | How long a settled webhook delivery (delivered or abandoned) stays in its endpoint's delivery log. 0 keeps them all. A delivery still being retried is kept until it settles, and one past this window can no longer be redelivered |
tracking.machine_window_open_seconds | integer, 1 to 900 | 60 | How 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_seconds | integer, 1 to 900 | 30 | The 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_seconds | integer, 1 to 86400 | 600 | The 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_auth | boolean | true | Whether 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_hours | integer, 1 to 720 | 72 | How 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 |
mailbox_signin.auto_vendor_reconnect | boolean | true | Whether SenditCold has an inbox vendor that can sign in for it (InboxKit, Zapmail) reconnect its Google Workspace mailboxes automatically: those on an app password, on the older Google sign-in without full mail access or on a legacy domain grant, with no click. A mailbox keeps its current sign-in until the new one is proven, and keeps it if the reconnect fails. At most one request per mailbox is open, and a mailbox is not asked again for a week after one ends. Off leaves the reconnect to a member's click. Needs Google sign-in configured on the instance; without it nothing is asked |
warmup_generation | object | Existing saved model and generation policy, with generation enabled and bounded control defaults | Effective model, cadence, stop, daily reservation cap, available-capacity targets and canonical reply-count controls. Provider selection uses AI_PROVIDER and AI_API_KEY from the AI environment variables above. A mailbox does not see the same conversation again within 30 days in either role while unseen ones remain; optional refresh defaults off to preserve usable stock; legacy AI selection share is retained for rollback. See all supported fields and defaults before editing; do not remove credentials while batches drain |
placement.tests_per_month_trial | integer, 1 to 100000 | 3 | How 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_paid | integer, 1 to 100000 | 40 | The same free allowance for a workspace on a paid plan. Past it, a test is charged to the workspace balance at usage.units.placement_test plus the markup |
placement.seeds_per_test | integer, 1 to 100 | 20 | The 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_seconds | integer, 5 to 600 | 60 | The 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_test | integer, 0 to 10000 | 25 | Legacy, kept for the previous release; the admin panel no longer edits it. The price of a paid test is now usage.units.placement_test. A stored 0 still turns paid tests off |
placement.batch_senders_max | integer, 1 to 100000 | 10000 | The 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_concurrency | integer, 1 to 500 | 20 | How 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_concurrency | integer, 1 to 5000 | 200 | The 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_minute | integer, 1 to 600 | 10 | How many mailboxes one batch starts per minute, so a large batch ramps up instead of starting its whole concurrency at once |
usage.markup_percent | integer, 0 to 500 | 25 | The markup added to every provider price when a workspace balance is charged. See usage pricing |
usage.models | list of {model, input_per_mtok, output_per_mtok} | Sonnet 4 class $3 / $15, Haiku 4 class $1 / $5, Claude 3.5 Haiku $0.80 / $4, gpt-6-luna $0.10 / $0.50, * $3 / $15 | Provider price per million input and output tokens, in dollars before the markup. A * row is required and is added back if missing |
usage.units | map of unit to dollars | web_search $0.01, placement_test $0.40, lead_website_read $0.016, lead_person_email $0.016, lead_search_page $0.032 | Provider price of one unit, in dollars before the markup. 0 for placement_test turns paid placement tests off |
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 eight 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.
Usage pricing
The usage section is the price list a workspace balance is charged from. A charge is what the work used at these prices, plus usage.markup_percent, rounded up to the next millionth of a dollar. Edit it in the admin panel under Instance > Configuration > Settings, in the Usage pricing section, which shows what a workspace is charged for each row after the markup.
- Models are matched on the model name that ran the call: a row with the exact name first, then the longest row the name starts with (so
claude-sonnet-4pricesclaude-sonnet-4-6), then the*row, which prices every model without a row of its own and cannot be removed. Rows with an empty or repeated name or a negative price are dropped on save. - Units price work that is not tokens:
web_search(one web search an AI feature makes),placement_test(one paid placement test), and three for the lead finder:lead_website_read(one website read, including the people it names and their addresses),lead_person_email(one named person's email found on request) andlead_search_page(one page of a search through a source the instance pays for; the default is Google Places' Text Search list price, so set it to what your provider charges). A missing or negative unit falls back to its default. - A change applies to charges made after it, within the 30-second settings cache. Charges already made keep their price.
Only an instance that bills (BILLING_PROVIDER=stripe) charges anything, so on a self-host with billing off this section has no effect. See Balance and usage for what workspaces see. A partial patch replaces usage.models and usage.units whole when either is present:
{"usage": {"markup_percent": 30, "units": {"web_search": 0.01, "placement_test": 0.5}}}Variables that do not do what their name suggests
| Variable | What actually happens |
|---|---|
KAFKA_CLUSTER | Nothing. A loader exists but no caller does. Remove it |
SENTRY_DSN_API | Nothing, for the same reason. Superseded by SENTRY_DSN |
CAPTCHA_PROVIDER | Read, but derived when unset rather than defaulting to a constant. See captcha |
See also
Hosted AWS deployment automation accepts DEPLOY_FLEET_BAKE_SECONDS (300 through
3600, default 300) in its deployment client. The workflow supplies it from
fleet_bake_seconds or the AWS_FLEET_BAKE_SECONDS GitHub repository variable.
It controls healthy uptime per stage for future worker rollouts, not application
startup or existing self-host settings. See AWS deployment.
- Install for the one-command install that writes all of this for you
- Data control for where each store lives and what each retention window governs
- First run for claiming a fresh instance
- Accounts and access for who may sign in and how to invite people
- Instance health for the checks that read these values back
- Troubleshooting for the errors these settings produce