SendSetsDocs

Configuration reference

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

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

That rule has three consequences worth stating before the tables:

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

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

The panel reads the backend, not the whole fleet

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

Seeing what is actually set

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

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

An empty value in .env is not an empty value

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

Deployment

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

prod does not mean cloud

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

Secrets

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

VariableFormatWhat it protectsRestart needed
AUTH_SECRET32 characters or moreJWT and session signing. The realtime service reads the same value as JWT_SECRETyes
INTERNAL_API_TOKENany random stringThe backend's /api/v1/internal/ routes, which workers and the tracking service authenticate againstyes
NODE_BROKER_TOKENany random stringThe routes that perform a privileged operation for the caller: opening a sealed data key, signing a blob operation, and minting a provider access token for a mailbox SendSets Cloud manages. Optional, and falls back to INTERNAL_API_TOKEN. The same value has to be set on the control plane and on every node that calls those routes, or they answer 401. Worth setting in a split deployment, where the tracking and forms services are internet-facing and hold the shared tokenyes
SECRET_KEY_BASE64 characters or morePhoenix session signing in the realtime serviceyes
KMS_LOCAL_MASTER_KEYbase64, exactly 32 bytesThe root key that seals every per-organization data keyyes
CREDENTIALS_ENCRYPTION_KEYexactly 64 hex charactersMailbox credentials at rest: SMTP and IMAP passwords, and Gmail and Outlook OAuth access and refresh tokensyes

Generate real ones before anyone else can reach the instance:

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

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

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

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

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

Only the backend refuses to boot on a published default

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

Back up the two encryption keys

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

Addresses

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

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

Setting PUBLIC_HOST turns localhost off

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

Network and proxy

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

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

TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12

Authentication

VariableWhat it doesDefaultRestart needed
AUTH_LOGIN_CODEalways, new_device or off. Whether a login also requires a code emailed to the accountoff on self-host, new_device on cloudyes
REQUIRE_EMAIL_VERIFICATIONWhether a signup must confirm an emailed code before the account existsfalse on self-hostyes
DISABLE_REGISTRATIONfalse, invite_only or true. See registration modesinvite_only on self-hostyes
DISABLE_PASSWORD_LOGINTurns off email and password entirely, for single sign-on only deploymentsfalseyes
SSO_AUTO_PROVISIONtrue lets a verified identity provider assertion create an account regardless of DISABLE_REGISTRATIONfalseyes
AUTH_IP_RATE_LIMITUnauthenticated auth requests allowed per source IP per 15 minutes60yes
CLI_AUTH_IP_RATE_LIMITCLI sign-in handshake requests allowed per source IP per 15 minutes. Its own budget, because one sendsets auth login polls around 200 times and must not exhaust the allowance above500yes
SENDSETS_BOOTSTRAP_EMAILFirst owner's address, read only while the users table is emptyunsetyes
SENDSETS_BOOTSTRAP_PASSWORD_HASHArgon2 PHC string for that owner. Preferred over the plaintext formunsetyes
SENDSETS_BOOTSTRAP_PASSWORDPlaintext convenience form. Warns at boot, and leaves a password in your process environmentunsetyes
SENDSETS_BOOTSTRAP_ORGName of the organization created with that ownerderived from the nameyes
TWOFA_SECRETKey that encrypts stored TOTP secrets. Falls back to AUTH_SECRET, so existing deployments keep working; rotating it invalidates every enrolled TOTP secretAUTH_SECRETyes
WEBAUTHN_RP_IDPasskey relying party id. Derived from APP_URL when unset. Changing it invalidates every enrolled passkeyderivedyes
WEBAUTHN_RP_ORIGINSOrigins accepted for passkey ceremoniesderived from APP_URLyes
WEBAUTHN_RP_DISPLAY_NAMEThe name the passkey prompt showsSendSetsyes
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETSign in with Google in the browser. Both are required; unrelated to the BOX_GOOGLE_* mailbox clientunsetyes
GOOGLE_REDIRECT_URIRedirect URI registered at Google. Served by the API, not the dashboardAPI_PUBLIC_URL plus /v1/auth/google/callbackyes
GOOGLE_IOS_CLIENT_IDAdditional Google client id accepted from the iOS app. Native only: it does not enable the browser buttonunsetyes
APPLE_APP_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_KEY_SECRETSign in with Apple. APPLE_APP_ID is the Services IDunsetyes
APPLE_REDIRECT_URIReturn URL registered at Apple. Must be HTTPSAPI_PUBLIC_URL plus /v1/auth/apple/callbackyes
APPLE_IOS_BUNDLE_IDBundle id accepted from the iOS appcom.sendsets.appyes
OIDC_ISSUER_URLGeneric OpenID Connect issuer. Discovery runs at bootunsetyes
OIDC_CLIENT_ID, OIDC_CLIENT_SECRETThe client SendSets authenticates asunsetyes
OIDC_REDIRECT_URLRedirect URI registered at the provider. Defaults to API_PUBLIC_URL plus /v1/auth/oidc/callbackderivedyes
OIDC_SCOPESScopes requested at the provideropenid,profile,emailyes
OIDC_ALLOWED_DOMAINSEmail domains allowed to sign in through the providerempty (any)yes
OIDC_DEFAULT_ORGOrganization uuid every single sign-on user joinsunsetyes
OIDC_PROVIDER_NAMEThe label on the sign-in buttonSingle sign-onyes

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

Proxying PostHog

Set the UI host whenever you proxy

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

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

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

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

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

Captcha

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

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

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

Platform mail

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

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

A self-hosted instance never fills those in with ours. SendSets'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 sendsets.com reads it: the sign-in screen's wordmark and its Terms and Privacy links, the copyable API example on the API keys page, a shared stats card, and the "powered by" line at the foot of a hosted form page. Each renders nothing when the value is unset, so a stranger filling in one of your forms is never sent to a website with no relationship to it.

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

Read a code out of the log:

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

The consumer only warns

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

Pre-send verification

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

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

An unqualified HELO name gets the whole session rejected

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

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

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

Encryption

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

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

Storage

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

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

Event bus

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

Topics are created by the bus, not by hand

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

json needs nothing and is the default. avro resolves every event against SCHEMA_REGISTRY_URL and ships only in the -kafka images. Producers and consumers have to agree on one: there is no in-band marker, so a consumer on the other codec cannot read what is already on the bus. Change it by draining the bus, not in place. PUBSUB_ENABLED must agree across backend, consumer and realtime.

The tracking topic is read by two languages

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

Database

VariableWhat it doesDefaultRestart needed
PRIMARY_DBPostgreSQL connection string. Carries inline credentials, so it is never returned by any APIthe compose postgresyes
PGSSLROOTCERTCertificate bundle used to verify the database's TLS certificate, read by the Postgres driver. Amazon RDS chains to a root in no public trust store, so sslmode=verify-full against it needs AWS's bundle; the backend and consumer images ship it at /etc/ssl/rds/global-bundle.pem. Prefer sslrootcert= in the DSN so the setting travels with the connection it applies tounset (system trust store)yes
DATABASE_URLThe realtime service's own name for the same databasethe compose postgresyes
DATABASE_POOL_SIZEMaximum pooled connections for the realtime service only. The Go services use the driver default and do not read it10yes
DATABASE_SSLWhether the realtime service connects to Postgres over TLStrue, and false under composeyes
DATABASE_SSL_CA_FILECA bundle the realtime service verifies Postgres against. Unset verifies against the system store, which has no Amazon RDS root in it, so an RDS database needs this set to /etc/ssl/rds/global-bundle.pem (shipped in the image)unsetyes

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

Cache

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

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

GeoIP

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

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

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

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

Getting the databases in

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

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

The older permalink no longer works

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

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

Two things to know:

  • A file that is already there is never replaced. The download only happens when GEODB_PATH holds nothing, so a mounted database, a volume that survived a restart, and a copy you dropped in by hand all win. On a host with no persistent disk that means one download per start; on a host with one, the first start only.
  • A download that is not a database is thrown away, not installed. An expired licence key returns an error page, and storing that would disable location lookups permanently, because the next start would find a file and not retry. The bytes are opened before they are put in place, and a failure leaves the path empty and one line in the log.
  • Plain http is refused for a URL carrying a credential, in the query or the userinfo, because it would put the licence key on the wire in the clear. An http mirror with nothing secret in its URL is allowed, and a redirect from https down to http is refused either way.

Both URLs are secrets

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

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

Workers

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

An unset key URL is silent

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

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

What a joining node is given

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

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

Settings the control plane cannot know

node.env is rewritten on every join. Next to it, /etc/sendsets/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/sendsets/node.local.env
systemctl restart sendsets-consumer

Mailbox connections

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

VariableWhat it doesDefault
BOX_GOOGLE_CLIENT_ID, BOX_GOOGLE_CLIENT_SECRETConnect Gmail and Google Workspace mailboxes. Redirect URI is your API base plus /addresses/google/callbackunset
BOX_OUTLOOK_CLIENT_ID, BOX_OUTLOOK_CLIENT_SECRETConnect Outlook and Microsoft 365 mailboxes. Redirect URI is your API base plus /addresses/outlook/callbackunset

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

Integrations

VariableWhat it doesDefault
<PROVIDER>_OAUTH_CLIENT_ID, <PROVIDER>_OAUTH_CLIENT_SECRETOAuth clients for the CRM and messaging integrationsunset
INTEGRATIONS_OAUTH_REDIRECT_URLShared redirect URI for those flowsderived from API_PUBLIC_URL
VariableWhat it doesDefault
AI_PROVIDERopenai, openrouter, groq, ollama, anthropic or custom. Omit every AI variable to run with AI off, in which case AI endpoints return a clean 503unset
AI_API_KEYProvider key. Not needed for ollamaunset
AI_MODEL, AI_MODEL_TRIAL, AI_MODEL_PAIDModel selection, optionally split by planprovider preset
AI_BASE_URLRequired for custom. Any OpenAI compatible endpointunset
AI_FREETreats AI usage as unchargedderived
SEARCH_PROVIDER, SEARCH_API_URL, SEARCH_API_KEYWeb search for the assistant (serper or searxng)unset
TYPESAFE_API_KEYTypeSafe API key used by optional automatic inbox taggingunset
INBOX_TAGGING_ENABLEDtrue enables automatic inbox tagging when TYPESAFE_API_KEY is also set. Message subject, current body, and the previous outbound body are sent to TypeSafefalse

An unset provider still uses a key

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

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

Set these on the backend and the consumer.

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

Tasks and billing

VariableWhat it doesDefault
TASKS_PROVIDERlocal (an in-process Postgres poller) or gcloud (Cloud Tasks)local
TASKS_LOCAL_POLL_INTERVALHow often the local poller looks for due work1s
BILLING_PROVIDERnone (every feature unlocked, no trial expiry; the dashboard reports the workspace as self-hosted rather than on a free tier and hides billing) or stripenone
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PUBLISHABLE_KEYRequired together when BILLING_PROVIDER=stripe. The backend exits at boot if any is missingunset
INBOXKIT_API_KEY (or INBOXAPI_KEY), INBOXKIT_BASE_URLThe mailbox vendor account SendSets Cloud provisions managed mailboxes from, and the source of truth for every infrastructure price. Instance-wide, never exposed to a customer, an agent or the CLI; SendSets opens one vendor workspace per organization. Self-hosted instances leave it unset: managed mailboxes are a hosted featureunset, https://api.inboxkit.com

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

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

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

A managed mailbox order is its own Stripe subscription (mode subscription, one line per price above) carrying metadata.purpose=mailbox_provisioning; the webhook handler routes anything with that marker to the order and never to the workspace's plan. The vendor's own status changes arrive at /webhook/inboxkit/<token>, a per-workspace token SendSets registers with the vendor; a background poller reconciles orders every minute regardless.

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 SendSets can report its errors, and none of them do unless you say where. Where is the operator's choice, not a requirement of the software, so the default install reports nowhere and contacts no host.

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

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

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

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

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

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

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

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

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

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

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

Source maps

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

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

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

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

Product analytics and session replay

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

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

What each surface sends, with a key set:

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

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

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

Push

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

Updates

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

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

Forms service

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

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

Tracking service

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

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

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

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

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

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

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

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

Realtime service

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

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

CHECK_ORIGIN is false by default

The shipped default accepts a websocket upgrade from any origin. A token is still required to join a channel, so an attacker needs a valid JWT either way, but on a deployment reachable from the internet set PHX_HOST to the public websocket hostname and CHECK_ORIGIN=true so only your own dashboard can open a socket.

Settings stored in the database

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

SettingTypeDefaultWhat it does
invitations.ttl_hoursinteger, 1 to 720168How long a new invitation stays valid. Read when the invitation row is written, so it applies to invitations created after the change, not to existing ones
invitations.links_enabledbooleantrueWhether the copyable invitation link is returned at all. Off makes GET /organization/invitations/:id/link return 404 with an explanation, so an invitation can only arrive by mail
access.allow_invited_signupbooleantrueWhether holding a live invitation lets someone create their own account under invite_only. Off means an administrator creates every account with sendsetsctl user create
sync.backfill_daysinteger, 1 to 73090How far back a newly connected mailbox's initial import reaches, newest first
sync.backfill_messagesinteger, 1 to 1000005000The most messages that import stores per mailbox
sync.daily_messages_per_mailboxinteger, 1 to 1000002000New (live) messages one mailbox may store per UTC day. Over it, mail waits for the next day; replies to the mailbox's own sends have a separate budget of the same size
sync.daily_messages_per_orginteger, 1 to 200000025000New plus imported messages one organization may store per UTC day
retention.engagement_event_daysinteger, 1 to 3650365How long the per-event open and click logs (client, device, approximate location) are kept. Campaign progress keeps its own summary that outlives them, so counts, filters and branching never change
retention.form_event_daysinteger, 1 to 3650180How long form funnel events (views, starts, field-level drop-off) are kept. Funnel reports range up to 90 days, so anything shorter shortens the report too
retention.audit_log_daysinteger, 1 to 365090How long the audit trail is kept. It carries IP addresses, user agents and change payloads, so this is also how long that data is held
provisioning.max_mailboxes_per_orderinteger, 1 to 50025The most managed mailboxes one order may ask for, so a runaway agent cannot buy a hundred inboxes on one call
tracking.machine_window_open_secondsinteger, 1 to 90060How soon after a send was dispatched an open is recorded as automated rather than a person's. The clock starts when the send is handed to a worker, so this window also covers the sending provider's queue and the transit to the recipient, not just reading time. Raise it when delivery-time scanners are being counted as opens; lower it when recipients who read immediately are being missed. A change applies within a minute and only to events recorded after it
tracking.machine_window_click_secondsinteger, 1 to 90030The same window for click tickets. Kept separate because the two mistakes cost different things: a misjudged open loses a metric, a misjudged click loses the automation behind an interested lead
tracking.machine_window_probable_secondsinteger, 1 to 86400600The window used instead of the two above when the tracking service recognised the source as a mail-security network that also renders clicked pages for people, which Proofpoint, Mimecast and Cisco do through browser isolation. Such a match cannot settle whether a person is behind the request, so it widens the window rather than deciding: inside it the event is the delivery-time scan, past it the recipient who got to the mail later. Never applied shorter than the window for the kind of event in hand, so naming a network can only ever catch more scans. Its ceiling reaches a day because how long a vendor takes to detonate a link is the vendor's property, not the instance's
deliverability.enforce_domain_authbooleantrueWhether a sending domain that fails SPF or DMARC stops cold campaign sending and warmup sending from every mailbox on it. Off keeps the check running and still shows the state and the Advisor card, it just never blocks
deliverability.auth_grace_hoursinteger, 1 to 72072How long a domain must stay failing before the gate applies. The clock starts when the background check first sees the failure, so this is also how much warning the owner gets

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 a few minutes without a restart. 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 three 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. 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 SENDSETS_SETTINGS_BOOTSTRAP holding the same partial JSON the admin API takes:

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

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

Variables that do not do what their name suggests

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

See also

On this page