# MillionSend self-host configuration. # Save as .env, generate the two secrets below, and `docker compose up -d`. # This file holds every secret the instance has: keep it readable by its # owner only (chmod 600 .env) and out of version control and backups. # --- Required --- # Postgres connection string. The default matches the docker-compose postgres # service; for local dev without Docker, point it at your own instance # (e.g. postgres://postgres:postgres@localhost:5432/millionsend). A managed # Postgres that requires TLS (RDS for PostgreSQL 15+, for one) needs # ?sslmode=require appended. DATABASE_URL=postgres://millionsend:millionsend@postgres:5432/millionsend # Password of the compose postgres service; the setup wizard generates one and # puts it in DATABASE_URL too. Keep both in sync. POSTGRES_PASSWORD=millionsend # Standalone deploy/docker-compose.yml only: the image to run. The default, # ghcr.io/millionsend/millionsend:latest, is the latest tagged release, so # `docker compose pull` is the upgrade; :edge follows main (every build there # passed the test suite first). Set a version tag or an immutable @sha256 # digest here to hold a version; the backup sidecar pins the same way. # MILLIONSEND_IMAGE=ghcr.io/millionsend/millionsend@sha256: # MILLIONSEND_BACKUP_IMAGE=ghcr.io/millionsend/backup@sha256: # Optional compose services, comma-separated: smtp (the relay; mount a # STARTTLS keypair first), mx (inbound mail on port 25; needs the receiving # settings below), and in deploy/docker-compose.yml also docs (the # documentation site) and backup (scheduled dumps; needs S3_BACKUP_BUCKET). # COMPOSE_PROFILES=docs,backup # Encryption key for email bodies at rest and the root the tracking and # unsubscribe link keys derive from. Generate: openssl rand -base64 32 # Always required, cloud included. Losing it makes stored bodies # unrecoverable; changing it orphans old bodies and every link already mailed. MASTER_ENCRYPTION_KEY= # Cloud only (IS_CLOUD=true): AWS KMS key (ARN or key id) that wraps per-email # data keys instead of MASTER_ENCRYPTION_KEY, which stays set (bodies sealed # under it remain readable; links still derive from it). Boot refuses it # when IS_CLOUD is false. # KMS_KEY_ID= # Dashboard session signing secret. Generate: openssl rand -base64 32 BETTER_AUTH_SECRET= # Public base URL of this deployment — the origin browsers use to reach the # dashboard (e.g. https://mail.example.com). Sign-in is only accepted from # this origin; SNS subscriptions and hosted unsubscribe pages derive from it. # The default matches the docker-compose setup on the host machine. # MUST match the exact scheme+host+port you open the dashboard on — including # a custom WEB_PORT (e.g. http://localhost:3009). A mismatch makes login and # signup fail with an "invalid origin" error. APP_BASE_URL=http://localhost:3000 # Optional: give the hosted unsubscribe pages their own host, e.g. # https://unsubscribe.example.com (point it at the same web process). Links in # mail and the page's own redirects use it, and that host answers the # unsubscribe flow only — the dashboard and its cookies stay on APP_BASE_URL, # out of reach of link scanners and URL reputation lists. Unset: APP_BASE_URL. # UNSUBSCRIBE_BASE_URL=https://unsubscribe.example.com # Public origin of the API, for deployments whose reverse proxy serves it on # its own hostname (e.g. https://api.example.com) rather than on port 3001 of # the dashboard host. Unset, that derived URL is what the dashboard prints as # the API base and what MCP tokens are bound to — correct for the compose # setup, unroutable once a proxy moves the API. Changes only the published # URL; the api still listens on PORT. # PUBLIC_API_URL= # --- AWS SES (bring your own account) --- # IAM credentials with ses:SendEmail / ses:SendRawEmail. Omit to use the # default AWS credential chain (instance profile, SSO, etc). AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= # Serve more than one SES region from this deployment, comma-separated; the # first is the default (platform mail and sends with no domain row). Each # region needs its own SNS topic, configuration set and production access — # `npx @millionsend/setup` adds one to an existing install. Unset: AWS_REGION. # AWS_REGIONS=sa-east-1,us-east-1 # Comma-separated SNS topic ARNs allowed to deliver SES events (bounces, # complaints, deliveries). Unset disables event ingestion entirely. # See SELF_HOSTING.md for the SNS setup checklist. SNS_TOPIC_ARNS= # SQS queue the worker long-polls for SES events (setup always creates it; it # buffers through restarts and needs no inbound reachability). Only messages # from topics in SNS_TOPIC_ARNS are accepted. Keep it set even when SNS also # pushes to https:///ses/events — the app dedupes the two. SQS_QUEUE_URL= # SES configuration set applied to sends that have no per-domain configuration # set. Point its event destination at the SNS topic above so delivery events # reach MillionSend. Unset sends without a configuration set. SES_CONFIGURATION_SET= # One SES tenant per team, so SES tracks bounce/complaint reputation per # customer instead of per account and can pause one sender without pausing # the rest; suspending a team disables its tenant. Defaults to IS_CLOUD; the # IAM policy needs the ses:*Tenant* actions and # ses:UpdateReputationEntityCustomerManagedStatus. SES_TENANTS= # --- Optional --- # Content monitoring: TypeSafe Jev judges a new team's mail, and any team's # mail to a destination it has never used, before SES takes it, and a sample # of the rest after (see "Content monitoring" in SELF_HOSTING.md). Off unless # ABUSE_JUDGE=typesafe; then ABUSE_JUDGE_API_KEY is required. While off, # nothing waits on it. Sampling rates and thresholds are edited in the console # (Trust & safety → Monitoring settings) or seeded as MONITOR_*. The model # is pinned because the thresholds are tuned against one version; jev-latest # moves whenever TypeSafe ships a new one. ABUSE_JUDGE=off ABUSE_JUDGE_API_KEY= ABUSE_JUDGE_MODEL=jev-1.13.0 # Image reading for the judge (see "Image reading" in SELF_HOSTING.md): # Claude Haiku 5.5 on Amazon Bedrock writes down the text in an email's # images, with the worker's AWS credentials, through ABUSE_VISION_REGION's # geographic inference profile, so images are read within that geography # (from us-east-1: N. Virginia, Ohio or Oregon). Off unless # ABUSE_VISION=bedrock; refusing program files, holding risky files and # reading PDFs, Office files and QR codes work without it. ABUSE_VISION=off ABUSE_VISION_REGION=us-east-1 # New-domain warm-up (see "New-domain warm-up" in SELF_HOSTING.md): a daily # cap per sending domain by its registration age. Unset follows IS_CLOUD. The # caps (WARMUP_CAP_FIRST_DAY/_WEEK/_MONTH) are edited in the console (Trust & # safety → Monitoring settings) or seeded here. While on, or while the # content monitor's review hold is (a judge set and MONITOR_AUTO_HOLD on), the # worker looks domain ages up over RDAP, WHOIS (TCP 43) and crt.sh. WARMUP_ENABLED= # Break-glass content access (see "Content access (break-glass)" in # SELF_HOSTING.md): with this on, the console can unwrap the subject and # rendered text of any team's messages, and what screening read from the # images and attachments of mail it held, for a recorded security reason, for # 30 minutes. The read is kept in the operator audit, not shown to the team. # Off unless CONTENT_REVEAL=on. Say so in your terms and privacy notice before # turning it on. CONTENT_REVEAL=off # Refuse addresses on the disposable-email-domains list (public domain) at # sign-up, on invitations and on email changes; a subdomain counts as its # parent. Existing accounts keep working and the console tags them. The app # ships the list; while this is on, the worker refreshes it daily from # raw.githubusercontent.com. Unset follows IS_CLOUD. BLOCK_DISPOSABLE_EMAILS= # Network and country of the sign-up IPs in the console's team dialog, from # DB-IP's free databases (CC BY 4.0), which the worker downloads from # download.db-ip.com once a month; lookups never leave this instance. Unset # follows IS_CLOUD. IP_GEOLOCATION= # Read-only support view (see "Support view (optional)" in SELF_HOSTING.md): # with this on, the console can open a team's dashboard read-only for 30 # minutes against a request reference, logged in the team's audit at once. # Exactly off or on; any other value fails validation and the process exits. # Say so in your privacy notice before turning it on. SUPPORT_VIEW=off # Error tracking (see "Error tracking (optional)" in SELF_HOSTING.md): report # errors to a Sentry project through the Sentry SDK. Off while SENTRY_DSN is # unset: the SDK is not even loaded. SENTRY_DSN covers the web, api and # worker processes; SENTRY_BROWSER_DSN also reports the dashboard's browser # errors, through this app's own origin (same project or another). Errors # only by default; SENTRY_TRACES_SAMPLE_RATE (0 to 1) also sends that share # of request traces. Events are scrubbed of bodies, queries, cookies # and credentials, but they leave the instance: a tracker you do not host is # a sub-processor to name in your privacy notice. SENTRY_DSN= SENTRY_BROWSER_DSN= SENTRY_ENVIRONMENT=production SENTRY_TRACES_SAMPLE_RATE=0 # The first user can always register; after that, signup stays closed unless # this is true. Keep false when the dashboard is reachable from the internet. ALLOW_SIGNUP=false # Hosted cloud only (ignored when IS_CLOUD=false): force branded tracking # subdomains on even without a TRACKING_EDGE_HOST — for a cloud that serves # customer hostnames on the app host itself. Setting TRACKING_EDGE_HOST already # enables them, so leave this false when you run a tracking edge. Self-host # always allows branded subdomains (the operator owns the proxy and its certs). ALLOW_TRACKING_SUBDOMAINS=false # Host a team's branded tracking subdomain CNAMEs at. Unset, the CNAME targets # this app's own host, so whatever terminates TLS for this app must also hold a # certificate for every team's tracking subdomain: Cloudflare's proxy does not, # nor does certbot for names it was not given. Otherwise run the tracking edge # in deploy/tracking-edge (Caddy issuing certificates on demand and proxying # /t/* back here) and set this to its hostname; "Branded tracking hosts" in # SELF_HOSTING.md lists the other options. An edge must pass the host each # click came in on as X-Tracking-Host, replacing any value the client sent; the # app checks that host against the team whose link it is. # TRACKING_EDGE_HOST=track.example-dns.com # Shared secret the tracking edge sends when asking whether a hostname is the # tracking host of a domain that verified (gates on-demand certificates). # Generate: openssl rand -hex 32 # TRACKING_ASK_SECRET= # Inbound mail (receiving, beta). RECEIVING_MX_HOST is the hostname the MX # process (PROCESS=mx, the optional mx compose service) is reachable at; # receiving domains point their MX record there. Unset hides receiving # entirely. MX_INGEST_SECRET is the bearer secret the MX presents to this app, # at least 32 characters; the two are set together. # Generate: openssl rand -hex 32 # RECEIVING_MX_HOST=mx.example.com # MX_INGEST_SECRET= # The mx process holds no database and reads only MX_* variables. The mx # compose service needs nothing beyond the two above: it greets as # RECEIVING_MX_HOST, reaches this API on the compose network, and offers # STARTTLS once a keypair sits in ./mx-tls (SELF_HOSTING.md, "Receiving # mail"). Run anywhere else, it takes MX_INGEST_URL (this API's public origin # plus /internal/mx), MX_HOSTNAME (its greeting name), the keypair paths, and # MX_PORT (default 2525; publish host port 25 to it). # MX_INGEST_URL=https://api.example.com/internal/mx # MX_HOSTNAME=mx.example.com # MX_PORT=2525 # MX_TLS_CERT_PATH= # MX_TLS_KEY_PATH= # Agent inboxes on a shared domain (beta). Customers get addresses on per-team # subdomains of this domain without DNS. It must be its own registrable domain, # its MX and wildcard MX must point at RECEIVING_MX_HOST, and an SES identity # for it must exist in the default region (the first of AWS_REGIONS, or # AWS_REGION). Unset hides the shared domain. What its addresses may send is # edited in the console (Trust & safety → Monitoring settings) or seeded as # SHARED_INBOX_SEND (default true), SHARED_INBOX_OPEN and SHARED_INBOX_TENANTS # (default false) and SHARED_INBOX_TEAM_DAILY_CAP (default 1000); see "Shared # inbox domain" in SELF_HOSTING.md. # SHARED_INBOX_DOMAIN=agents.example.com # Optional virus scanning of received mail by clamd over TCP (host[:port], # port 3310 by default). Unset: no scanning. # CLAMAV_HOST=clamav:3310 # API port. Under docker compose this moves both the container's listen port # and the published host port together. PORT=3001 API_RATE_LIMIT_PER_MINUTE=600 # Compose binds application ports to loopback by default. Set only the service # that must be reachable directly to 0.0.0.0; prefer a TLS reverse proxy. WEB_BIND_ADDRESS=127.0.0.1 API_BIND_ADDRESS=127.0.0.1 DOCS_BIND_ADDRESS=127.0.0.1 SMTP_BIND_ADDRESS=127.0.0.1 # Host port the compose file publishes the web dashboard on (the web process # itself is always 3000 inside the container). Remember to keep APP_BASE_URL # in sync with wherever the dashboard is actually reachable. WEB_PORT=3000 # Host port the compose file publishes the documentation site on (the docs # process itself is always 3002 inside the container). The docs service is # optional and needs no database. DOCS_PORT=3002 # SMTP relay (the optional smtp compose service) listen port. Clients # authenticate with username "millionsend" and an ms_ API key as the password. SMTP_PORT=2587 # Host the dashboard's SMTP tab (Settings → SMTP) tells clients to connect # to. Unset: the host of APP_BASE_URL. Set it when the relay answers on # another name; the relay itself never reads it. With IS_CLOUD=true the tab # appears only once this and the keypair below are set. # SMTP_PUBLIC_HOST=smtp.example.com # STARTTLS keypair for the SMTP relay (PEM paths inside the container). Both # set: STARTTLS is offered and required before AUTH. Without a keypair the # relay refuses to start unless the private-network escape hatch below is # explicitly enabled. SMTP_TLS_CERT_PATH= SMTP_TLS_KEY_PATH= SMTP_ALLOW_INSECURE_AUTH=false # Terms of Service / Privacy Policy links for the auth screen's consent line # ("By signing in, you agree to…"). Optional — unset links are omitted, and # the whole line is hidden when neither is set. TERMS_URL= PRIVACY_URL= # Sender for account emails (password reset, email verification), as # "Name " or a bare address. Its domain must be a verified # identity in this instance's SES account; verify it in a team under Domains # and those emails are logged there, with new accounts as its contacts. # Leave unset to hide password recovery and skip verification entirely. AUTH_EMAIL_FROM= # Sender of the onboarding "Send email" button, as "Name " or a # bare address on a domain verified in this SES account. It carries one fixed # test email per team, to the team's owner, and nothing else: API, SMTP and # broadcast sends from this address are refused, even for a team that # verified its domain. Leave unset to hide the button. ONBOARDING_EMAIL_FROM= # Domain-verification token from OpenAI's plugin portal (platform.openai.com # → Plugins → MCPs), served as plain text at # /.well-known/openai-apps-challenge on the API host. Leave unset otherwise. OPENAI_APPS_CHALLENGE_TOKEN= # Cloudflare Turnstile (https://dash.cloudflare.com → Turnstile), both keys or # neither. When set, sign-in (Google and GitHub included), sign-up, password # reset, the product-updates form and the onboarding "Send email" button # verify a challenge token; an invisible or managed widget works. Leave both # unset to run without a captcha. TURNSTILE_SITE_KEY= TURNSTILE_SECRET_KEY= # Sender for account notifications to team owners (daily quota nearly or fully # used, bounce/complaint rates at risk or paused) and for team invitation # emails, same forms as AUTH_EMAIL_FROM. Leave unset to send them from # AUTH_EMAIL_FROM; with neither set, only the webhook events go out and # invitations are link-only. Verify its domain in a team under Domains to # log these emails there. NOTIFICATIONS_EMAIL_FROM= # Invitations one team, and one member across all their teams, may create in # a rolling day, revoked ones and those of deleted teams included. Past it, # new invitations are refused until the day rolls over. # INVITES_PER_TEAM_PER_DAY=50 # Proxies the web process skips when it reads a client's address (sign-in # rate limits, session and sign-up IPs, tracking hits) from X-Forwarded-For, # comma-separated IPs or CIDRs. It walks the header from the right past these # and takes the first address not listed, so behind one reverse proxy that # appends the header nothing needs setting. List only proxies in front of it # that append too (a CDN, a load balancer, a tracking edge), or their address # stands in for every client. # Never a whole pod or VPC CIDR: every caller inside it would share one # rate-limit bucket. Keep web and api reachable only through the reverse # proxy, since web takes the header from whoever connects. The API's # failed-auth throttle keys on the connection's address instead. # TRUSTED_PROXIES=127.0.0.1,::1 # Local development only: let webhook endpoints target http:// and # private/loopback addresses. Keep false on any internet-reachable instance. WEBHOOK_ALLOW_LOCALHOST=false # Leave false. true enables hosted-cloud behavior (KMS, Stripe billing). IS_CLOUD=false # Hosted cloud only (ignored when IS_CLOUD=false): Stripe API secret key, the # signing secret of the webhook endpoint pointed at /api/billing/webhook, and # an optional customer-portal configuration id (unset = the account default). STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= STRIPE_PORTAL_CONFIG= # --- Object storage (S3-compatible, optional) --- # ONE credential set, shared by both S3-backed features below (team logo # uploads and database backups); each feature is then enabled by its own # bucket variable. Set all three together. Cloudflare R2 works out of the # box; its endpoint is https://.r2.cloudflarestorage.com S3_ENDPOINT= S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= # Defaults suit Cloudflare R2. Other S3-compatibles: set a real region if the # endpoint needs one, and rclone's provider name (AWS, Minio, ...) for the # backup job. S3_REGION=auto S3_PROVIDER=Cloudflare # Uploads (team logos): a bucket that already exists and serves objects # PUBLICLY, plus the public base URL it serves from (the R2 public bucket # URL, or a CDN/custom domain in front of it). Uploaded objects are addressed # as ${S3_STORAGE_PUBLIC_URL}/. Unset: no upload UI appears anywhere. S3_STORAGE_BUCKET= S3_STORAGE_PUBLIC_URL= # Backups: scheduled pg_dump of the database, run by the `backup` compose # service. Use a SEPARATE, PRIVATE bucket that already exists — dumps contain # the whole database, and R2 public access is bucket-wide, so a dump in the # public uploads bucket would be world-readable. Unset: the service prints a # hint and idles. S3_BACKUP_BUCKET= # Backup tuning, meaningful only with S3_BACKUP_BUCKET set: object key prefix # inside the bucket (default backups), dump schedule (default 0 3 * * *, UTC; # only the daily form ` * * *` is honoured, any other shape # makes the backup service exit 1; one dump also runs at service start unless # the bucket already holds one from the last 26 hours), and how many days of # dumps to keep before pruning (default 14). S3_BACKUP_PREFIX= BACKUP_CRON= BACKUP_RETENTION_DAYS= # age public key (age1...); set to encrypt dumps before upload. Restore with # `age --decrypt -i ` first. BACKUP_AGE_RECIPIENT= # --- Social login (optional) --- # OAuth credentials for the dashboard's social sign-in. Each provider's # "Continue with …" button appears only when BOTH its id and secret are set. # Register this callback URL on the OAuth app: {APP_BASE_URL}/api/auth/callback/ # e.g. https://mail.example.com/api/auth/callback/google # Google: https://console.cloud.google.com/apis/credentials (OAuth client ID, type Web application) GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= # GitHub: https://github.com/settings/developers (New OAuth App) GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= # Worker sizing. Lanes: about 1.2 per message/second of SES send rate (default fits 14/s). # WORKER_REPLICAS divides the send rate per process when more than one worker runs. # SQS_POLL_CONCURRENCY: parallel SQS long-poll loops (4 keeps up with a 55/s burst). # WEBHOOK_DELIVERY_RETENTION_DAYS: how long webhook delivery rows stay readable. # EMAIL_METADATA_RETENTION_DAYS: days whole email rows (recipients, subject, status, # events) are kept; bodies leave earlier on the dashboard's retention setting. # Daily counters and broadcast results are kept regardless. Was 365 before v0.6.30. # OPEN_PREFETCH_WINDOW_SECONDS: a tracking-pixel fetch within this many seconds of # delivery (or before it) is recorded as prefetched, not opened; 0 keeps only the # user-agent rules (Apple Mail privacy, Gmail prefetch, scanners). # SES_TRANSACTIONAL_RESERVE: percent of every region's rolling 24h SES quota kept for # transactional mail (5-90); broadcasts get the rest and are paced over days past it. # Bootstrap value only: Console -> Regions overrides it at runtime. # SEND_CONCURRENCY=16 # WORKER_REPLICAS=1 # SES_TRANSACTIONAL_RESERVE=30 # SQS_POLL_CONCURRENCY=4 # WEBHOOK_DELIVERY_RETENTION_DAYS=30 # EMAIL_METADATA_RETENTION_DAYS=30 # OPEN_PREFETCH_WINDOW_SECONDS=10