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
.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 SendSets 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 | 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) |
SENDSETS_CLOUD_URL | The SendSets Cloud API a self-hosted instance links to for the hosted warmup pool (guide). Outbound HTTPS only | https://api.sendsets.com | no |
INSTANCE_NAME | The name shown on the SendSets Cloud approval page when linking this instance | the hostname | no |
SENDSETS_VERSION | Version string reported to SendSets Cloud on link requests | dev | no |
SENDSETS_ALLOW_UNSAFE_WEBHOOK_URLS | true lets customer webhooks point at http:// and private addresses. Development only: it lets any workspace member make the backend reach into your internal network | false | yes |
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 | JWT and session signing. The realtime service reads the same value as JWT_SECRET | yes |
INTERNAL_API_TOKEN | any random string | The backend's /api/v1/internal/ routes, which workers and the tracking service authenticate against | yes |
NODE_BROKER_TOKEN | any random string | The 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 token | yes |
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, worker | Sealed data keys cannot be opened, so mailbox credentials stop decrypting |
CREDENTIALS_ENCRYPTION_KEY | backend, worker | 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.
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 base used in generated worker configuration | falls back to API_PUBLIC_URL | yes |
APP_ORIGIN | The exact origin the mailbox OAuth callback page posts the authorization code back to. Only needed when the dashboard is served somewhere other than APP_URL | 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) |
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
| 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, 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/12Authentication
| 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 sendsets auth login polls around 200 times and must not exhaust the allowance above | 500 | yes |
SENDSETS_BOOTSTRAP_EMAIL | First owner's address, read only while the users table is empty | unset | yes |
SENDSETS_BOOTSTRAP_PASSWORD_HASH | Argon2 PHC string for that owner. Preferred over the plaintext form | unset | yes |
SENDSETS_BOOTSTRAP_PASSWORD | Plaintext convenience form. Warns at boot, and leaves a password in your process environment | unset | yes |
SENDSETS_BOOTSTRAP_ORG | Name of the organization created with that owner | derived from the name | yes |
TWOFA_SECRET | Key that encrypts stored TOTP secrets. Falls back to AUTH_SECRET, so existing deployments keep working; rotating it invalidates every enrolled TOTP secret | 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 | SendSets | yes |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Sign in with Google in the browser. Both are required; unrelated to the BOX_GOOGLE_* mailbox client | unset | yes |
GOOGLE_REDIRECT_URI | Redirect URI registered at Google. Served by the API, not the dashboard | 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 | 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.sendsets.app | yes |
OIDC_ISSUER_URL | Generic OpenID Connect issuer. Discovery runs at boot | unset | yes |
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET | The client SendSets 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.
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 |
SENDSETS_TURNSTILE_KEY | The Turnstile site key, read by the dashboard and admin panel at container start | a test key under compose | yes (container start) |
SENDSETS_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 |
SENDSETS_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) |
TURNSTILE_BYPASS_TOKEN | A token that skips verification. Only honoured when APP_ENV=dev | unset | 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. The frontends carry their own copy in SENDSETS_TURNSTILE_KEY | unset | no (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.
| 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 | SendSets | 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 | 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 | false | yes |
EMAIL_BRAND_NAME | The product name in transactional subjects and the header | SendSets | yes |
EMAIL_BRAND_LEGAL_ENTITY, EMAIL_BRAND_COMPANY_NUMBER, EMAIL_BRAND_PLACE_OF_REG, EMAIL_BRAND_ADDRESS | The identification line in the transactional footer | 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 | 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. 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.
| 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. 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
| 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 | 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 |
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.
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 |
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 and nothing otherwise; set it empty to turn it off | derived from EVENTBUS_PROVIDER | 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 | sendsets | 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 |
EVENTBUS_HANDLER_TIMEOUT | How long one handler may take before the delivery is abandoned | 30s | 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 |
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
| 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 |
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 |
DATABASE_URL | The realtime service's own name for the same database | the compose postgres | yes |
DATABASE_POOL_SIZE | Maximum pooled connections for the realtime service only. The Go services use the driver default and do not read it | 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 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
| 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: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.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:
- A file that is already there is never replaced. The download only happens when
GEODB_PATHholds 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
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.
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 fallback reported by the worker. The control plane uses the address observed on the heartbeat when it is public | detected | yes |
SENDSETS_WORKER_CAPACITY | Assigned-mailbox planning target for this worker. This guides placement and is not a mailbox-provider send limit | 100 | yes |
SENDSETS_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 | 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 | false | 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
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=awshands its nodesbrokered, and one onBLOB_PROVIDER=s3hands thembrokeredtoo, 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,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/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-consumerMailbox 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 | Connect Gmail and Google Workspace mailboxes. Redirect URI is your API base plus /addresses/google/callback | unset |
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.
Integrations
| Variable | What it does | Default |
|---|---|---|
<PROVIDER>_OAUTH_CLIENT_ID, <PROVIDER>_OAUTH_CLIENT_SECRET | OAuth clients for the CRM and messaging integrations | unset |
INTEGRATIONS_OAUTH_REDIRECT_URL | Shared redirect URI for those flows | derived from API_PUBLIC_URL |
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 | 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, SEARCH_API_URL, SEARCH_API_KEY | Web search for the assistant (serper or searxng) | unset |
TYPESAFE_API_KEY | TypeSafe API key used by optional automatic inbox tagging | unset |
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.
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
| Variable | What it does | Default |
|---|---|---|
TASKS_PROVIDER | local (an in-process Postgres poller) or gcloud (Cloud Tasks) | local |
TASKS_LOCAL_POLL_INTERVAL | How often the local poller looks for due work | 1s |
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 |
INBOXKIT_API_KEY (or INBOXAPI_KEY), INBOXKIT_BASE_URL | The 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 feature | unset, 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=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 |
SENDSETS_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 |
SENDSETS_POSTHOG_KEY | web, admin containers | Browser error tracking, read at container start like the other SENDSETS_* values. The same key carries the product analytics and session replay below | unset |
SENDSETS_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 |
SENDSETS_POSTHOG_HOST | web, admin containers, forms service | Capture host for the browser | https://us.i.posthog.com |
SENDSETS_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 |
SENDSETS_POSTHOG_SESSION_REPLAY | web, admin containers | false records no sessions and keeps everything else. Form pages never record, whatever this says | true |
SENDSETS_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 |
SENDSETS_SENTRY_DSN | forms service | Stamped into the public form page, along with the service's APP_ENV as the environment | unset |
SENDSETS_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 SENDSETS_SENTRY_ENVIRONMENT instead, because the container serving the bundle is not the process reporting the error | dev |
SENDSETS_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 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:
| 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 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 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 |
SENDSETS_POSTHOG_KEY | web, admin containers | Browser analytics and session replay, read at container start. Unset means the SDK chunk is never fetched | unset |
SENDSETS_POSTHOG_HOST | web, admin containers | Capture host for the browser | https://us.i.posthog.com |
SENDSETS_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. 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 SendSets 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 SendSets versions. Point a fork's instance at the fork | AddisonHoff/SendSets | yes |
RELEASES_GITHUB_TOKEN | Optional GitHub token; only raises the API rate limit | unset | 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 | 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 |
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 |
SENDSETS_POSTHOG_KEY, SENDSETS_POSTHOG_HOST, SENDSETS_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 |
SENDSETS_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 |
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 |
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 SendSets 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, sendsets |
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 |
SENDSETS_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 | true accepts a websocket upgrade only from PHX_HOST | 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 |
SENDSETS_RELEASE | The build reported errors are tagged with | dev |
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.
| Setting | Type | Default | What it does |
|---|---|---|---|
invitations.ttl_hours | integer, 1 to 720 | 168 | 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 sendsetsctl 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 |
provisioning.max_mailboxes_per_order | integer, 1 to 500 | 25 | The most managed mailboxes one order may ask for, so a runaway agent cannot buy a hundred inboxes on one call |
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 |
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
| 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
- 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