selfhost.directory

The index of self-hostable projects — ranked, categorized and served with live updates.
Own your data and stop giving it to big tech.

MillionSend logo

Self-host MillionSend

v0.26.1

Open-source email API on AWS SES, Resend-compatible

BetaOpen SourceAGPL-3.0
Updated9 hours ago
LanguageTypescript
Setup difficultyAdvanced
ThemeDark mode
Stars
258
Ports
3000 (web dashboard), 3001 (API), 2587 (SMTP relay), 2525 (MX inbound mail (container port)), 3002 (documentation site)
Requires
AWS SES, PostgreSQL
Docker
ghcr.io/millionsend/millionsend
Install with Docker Compose
MillionSend dashboard Emails page showing each send's delivery status

What is MillionSend?

MillionSend is an open-source email platform for transactional and marketing messages that operates on your own AWS SES account. It delivers a Resend-compatible HTTP API with matching request and response formats, a web dashboard for handling emails, contacts, segments, topics, templates, and broadcast campaigns, plus an SMTP relay on port 2587. Capabilities include idempotency keys, scoped API keys, suppression lists, delivery metrics, Standard Webhooks with signed payloads, domain verification with custom DKIM, one-click unsubscribe per RFC 8058, API request logging, and optional inbound mail receiving in beta. Self-hosting uses Docker Compose with PostgreSQL and no Redis, aided by a setup wizard that provisions AWS resources. The hosted edition has a free tier and paid plans; the self-hosted version imposes no caps, plans, or license keys. The platform code is AGPL-3.0 while SDKs use MIT.

Should you self-host MillionSend?

MillionSend scores 48/100 on selfhost.directory (how scoring works) and ranks #65 of 106 in Email. It has 258 GitHub stars.

It shipped 10 stable releases in the past year. The latest, v0.26.1, came out on 28 October 2026.

Setup is advanced: plan for several services and some ongoing maintenance. An official Docker image is available. It is AGPL-3.0-licensed and written mostly in Typescript.

Its closest alternative on selfhost.directory is Postal (72/100, 16 897 stars). It scores higher overall, so compare the two before you pick. See MillionSend vs Postal side by side.

Features

  • API Keys with Scopes
  • API Request Logs
  • Broadcast Campaigns
  • Contacts & Segments
  • Delivery Metrics
  • Domain Verification with BYO DKIM
  • Idempotency keys
  • Inbound Mail Receiving (Beta)Beta feature
  • Multi-language Dashboard
  • One-click Unsubscribe (RFC 8058)
  • Resend-compatible Email API
  • SMTP Relay
  • Standard Webhooks
  • Suppression lists
  • Templates with Merge Fields

MillionSend README

How to install MillionSend

Install MillionSend with Docker Compose

Docker with Compose; an AWS account with SES access; a sending domain you control
Environment variables
DATABASE_URL, POSTGRES_PASSWORD, MASTER_ENCRYPTION_KEY, BETTER_AUTH_SECRET, APP_BASE_URL, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, SNS_TOPIC_ARNS, SQS_QUEUE_URL, SES_CONFIGURATION_SET, COMPOSE_PROFILES, PORT, WEB_PORT, SMTP_PORT, MILLIONSEND_IMAGE, etc.

mkdir millionsend && cd millionsend npx @millionsend/setup

or manual:

curl -O https://raw.githubusercontent.com/MillionSend/millionsend/main/deploy/docker-compose.yml curl -o .env https://raw.githubusercontent.com/MillionSend/millionsend/main/.env.example docker compose up -d

Setup wizard provisions AWS resources and generates secrets; manual path uses prebuilt image

How to configure MillionSend

  • ABUSE_JUDGEContent 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 → Monitorin

    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 → Monitorin

  • ABUSE_JUDGE_API_KEY
  • ABUSE_JUDGE_MODELjev-1.13.0
  • ABUSE_VISIONoffImage 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 risk

    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 risk

  • ABUSE_VISION_REGIONus-east-1
  • ALLOW_SIGNUPfalseThe first user can always register; after that, signup stays closed unless this is true. Keep false when the dashboard is reachable from the internet.

    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_TRACKING_SUBDOMAINSfalseHosted 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).

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

  • API_BIND_ADDRESS127.0.0.1
  • API_RATE_LIMIT_PER_MINUTE600
  • APP_BASE_URLhttp://localhost:3000Public 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://loca

    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://loca

  • AUTH_EMAIL_FROMSender for account emails (password reset, email verification), as "Name <user@domain>" 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.

    Sender for account emails (password reset, email verification), as "Name <user@domain>" 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.

  • AWS_ACCESS_KEY_ID
  • AWS_REGIONus-east-1IAM credentials with ses:SendEmail / ses:SendRawEmail. Omit to use the default AWS credential chain (instance profile, SSO, etc).

    IAM credentials with ses:SendEmail / ses:SendRawEmail. Omit to use the default AWS credential chain (instance profile, SSO, etc).

  • AWS_REGIONSServe 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.

    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_SECRET_ACCESS_KEY
  • BACKUP_AGE_RECIPIENTage public key (age1...); set to encrypt dumps before upload. Restore with `age --decrypt -i <key file>` first.

    age public key (age1...); set to encrypt dumps before upload. Restore with `age --decrypt -i <key file>` first.

  • BACKUP_CRON
  • BACKUP_RETENTION_DAYS
  • BETTER_AUTH_SECRETDashboard session signing secret. Generate: openssl rand -base64 32

    Dashboard session signing secret. Generate: openssl rand -base64 32

  • BLOCK_DISPOSABLE_EMAILSRefuse 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.

    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.

  • CLAMAV_HOSTOptional virus scanning of received mail by clamd over TCP (host[:port], port 3310 by default). Unset: no scanning.

    Optional virus scanning of received mail by clamd over TCP (host[:port], port 3310 by default). Unset: no scanning.

  • COMPOSE_PROFILESOptional 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).

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

  • CONTENT_REVEALoffBreak-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 y

    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 y

  • DATABASE_URLPostgres 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.

    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.

  • DOCS_BIND_ADDRESS127.0.0.1
  • DOCS_PORT3002Host 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.

    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.

  • EMAIL_METADATA_RETENTION_DAYS
  • GITHUB_CLIENT_IDGitHub: https://github.com/settings/developers (New OAuth App)

    GitHub: https://github.com/settings/developers (New OAuth App)

  • GITHUB_CLIENT_SECRET
  • GOOGLE_CLIENT_IDGoogle: https://console.cloud.google.com/apis/credentials (OAuth client ID, type Web application)

    Google: https://console.cloud.google.com/apis/credentials (OAuth client ID, type Web application)

  • GOOGLE_CLIENT_SECRET
  • INVITES_PER_TEAM_PER_DAYInvitations 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.

    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.

  • IP_GEOLOCATIONNetwork 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.

    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.

  • IS_CLOUDfalseLeave false. true enables hosted-cloud behavior (KMS, Stripe billing).

    Leave false. true enables hosted-cloud behavior (KMS, Stripe billing).

  • KMS_KEY_IDCloud 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.

    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.

  • MASTER_ENCRYPTION_KEYEncryption 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.

    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.

  • MILLIONSEND_BACKUP_IMAGE
  • MILLIONSEND_IMAGEStandalone 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.

    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.

  • MX_HOSTNAME
  • MX_INGEST_SECRET
  • MX_INGEST_URLThe 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 n

    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 n

  • MX_PORT
  • MX_TLS_CERT_PATH
  • MX_TLS_KEY_PATH
  • NOTIFICATIONS_EMAIL_FROMSender 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.

    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.

  • ONBOARDING_EMAIL_FROMSender of the onboarding "Send email" button, as "Name <user@domain>" 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.

    Sender of the onboarding "Send email" button, as "Name <user@domain>" 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.

  • OPENAI_APPS_CHALLENGE_TOKENDomain-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.

    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.

  • OPEN_PREFETCH_WINDOW_SECONDS
  • PORT3001API port. Under docker compose this moves both the container's listen port and the published host port together.

    API port. Under docker compose this moves both the container's listen port and the published host port together.

  • POSTGRES_PASSWORDPassword of the compose postgres service; the setup wizard generates one and puts it in DATABASE_URL too. Keep both in sync.

    Password of the compose postgres service; the setup wizard generates one and puts it in DATABASE_URL too. Keep both in sync.

  • PRIVACY_URL
  • PUBLIC_API_URLPublic 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 o

    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 o

  • RECEIVING_MX_HOSTInbound 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

    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

  • S3_ACCESS_KEY_ID
  • S3_BACKUP_BUCKETBackups: 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.

    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_PREFIXBackup 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 `<minute> <hour> * * *` 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 pru

    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 `<minute> <hour> * * *` 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 pru

  • S3_ENDPOINTONE 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://<accountid>.r2.cloudflarestorage.com

    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://<accountid>.r2.cloudflarestorage.com

  • S3_PROVIDERCloudflare
  • S3_REGIONautoDefaults 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.

    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_SECRET_ACCESS_KEY
  • S3_STORAGE_BUCKETUploads (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}/<key>. Unset: no upload UI appears anywhere.

    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}/<key>. Unset: no upload UI appears anywhere.

  • S3_STORAGE_PUBLIC_URL
  • SEND_CONCURRENCYWorker 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, s

    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, s

  • SENTRY_BROWSER_DSN
  • SENTRY_DSNError 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_SA

    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_SA

  • SENTRY_ENVIRONMENTproduction
  • SENTRY_TRACES_SAMPLE_RATE0
  • SES_CONFIGURATION_SETSES 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 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_TENANTSOne 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.

    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_TRANSACTIONAL_RESERVE
  • SHARED_INBOX_DOMAINAgent 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 (T

    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 (T

  • SMTP_ALLOW_INSECURE_AUTHfalse
  • SMTP_BIND_ADDRESS127.0.0.1
  • SMTP_PORTSMTP relay (the optional smtp compose service) listen port. Clients authenticate with username "millionsend" and an ms_ API key as the password.

    SMTP relay (the optional smtp compose service) listen port. Clients authenticate with username "millionsend" and an ms_ API key as the password.

  • SMTP_PUBLIC_HOSTHost 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.

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

    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_KEY_PATH
  • SNS_TOPIC_ARNSComma-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.

    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.

  • SQS_POLL_CONCURRENCY
  • SQS_QUEUE_URLSQS 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://<your-host>/ses/events — the app dedupes the two.

    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://<your-host>/ses/events — the app dedupes the two.

  • STRIPE_PORTAL_CONFIG
  • STRIPE_SECRET_KEYHosted 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).

    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_WEBHOOK_SECRET
  • SUPPORT_VIEWoffRead-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.

    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.

  • TERMS_URLTerms 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 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.

  • TRACKING_ASK_SECRETShared 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

    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_EDGE_HOSTHost 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)

    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)

  • TRUSTED_PROXIESProxies 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 loa

    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 loa

  • TURNSTILE_SECRET_KEY
  • TURNSTILE_SITE_KEYCloudflare Turnstile (https://dash.cloudflare.com → Turnstile), both keys or neither. When set, sign-in, sign-up, password reset and the onboarding "Send email" button verify a challenge token; an invisible or managed widget works. Leave both unset to run without a captcha.

    Cloudflare Turnstile (https://dash.cloudflare.com → Turnstile), both keys or neither. When set, sign-in, sign-up, password reset and the onboarding "Send email" button verify a challenge token; an invisible or managed widget works. Leave both unset to run without a captcha.

  • UNSUBSCRIBE_BASE_URLOptional: 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.

    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.

  • WARMUP_ENABLEDNew-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

    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

  • WEB_BIND_ADDRESS127.0.0.1Compose 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.

    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.

  • WEBHOOK_ALLOW_LOCALHOSTfalseLocal development only: let webhook endpoints target http:// and private/loopback addresses. Keep false on any internet-reachable instance.

    Local development only: let webhook endpoints target http:// and private/loopback addresses. Keep false on any internet-reachable instance.

  • WEBHOOK_DELIVERY_RETENTION_DAYS
  • WEB_PORT3000Host 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.

    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.

  • WORKER_REPLICAS

MillionSend releases & version history

More in API

Rate this project

Click to rate — no account needed.

0 comments

Posting as guest
Loading comments…