MillionSend Docs

Self-hosting

Deploy MillionSend on your own infrastructure with Docker Compose and your own AWS SES account.

Self-hosted MillionSend sends through your own AWS SES account. A deployment is two containers: Postgres and one app container running the API (port 3001), the worker, and the web dashboard (port 3000). An optional third container runs the SMTP relay (port 2587). (Prefer not to run infrastructure? MillionSend Cloud is the same platform, hosted.)

Prerequisites: Docker with Compose; an AWS account with SES access in your chosen region (sandbox accounts can only send to verified recipients — request production access to send to anyone); a sending domain you control. Domain verification (DKIM records) is done from the dashboard after boot.

Quickstart (no clone)

One command, in an empty directory (Node 18+):

mkdir millionsend && cd millionsend
npx @millionsend/setup

The wizard detects what is already there and offers each step — create .env from a built-in template with generated secrets, provision the AWS resources and the S3 buckets for uploads and backups (both below), download the standalone compose file, and docker compose up -d. Every step is skippable and safe to re-run; --dry-run prints the full plan and touches nothing. On an install that is already set up, a terminal run opens on a menu of next steps instead of walking every step again.

The manual equivalent runs the same multi-arch prebuilt image:

mkdir millionsend && cd millionsend
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

Fill in .env (see the environment reference — everything else defaults to a working local setup), then:

docker compose up -d

Migrations run automatically on boot. Dashboard: http://localhost:3000. API: http://localhost:3001.

Upgrades

docker compose pull
docker compose up -d

Migrations run on boot, so that is the whole upgrade for a small instance. The compose file runs ghcr.io/millionsend/millionsend:latest, the latest tagged release (:1.2.3 and :1.2 tags exist alongside it; :edge follows main, where every build passed the test suite first). To hold a version, set MILLIONSEND_IMAGE in .env to a version tag or an immutable digest (ghcr.io/millionsend/millionsend@sha256:…; docker image ls --digests shows what is running) and docker compose up -d. The previous pin put back is the rollback — with the caveat that schema migrations run forward only, so take a dump before a big jump (Backups); a rolled-back image may not start on a newer schema.

Once tables are large (millions of emails or contacts), a migration that rewrites or indexes them takes minutes. Migrations run in one transaction and their locks block reads and writes on the tables they touch until it commits, so that wait is downtime whether it happens at boot or before the swap. Run it before the swap anyway, from a throwaway container, at a quiet hour: a migration that fails leaves the old container serving instead of a container that will not boot, and the boot-time pass then finds nothing pending:

docker compose pull && docker compose run --rm --no-deps millionsend migrate && docker compose up -d

Automatic upgrades, for a host that can only reach out (behind a CDN-only firewall, say): a cron line is enough, since up -d recreates a container only when its image changed.

( crontab -l 2>/dev/null; echo "*/5 * * * * cd /opt/millionsend && docker compose pull -q && docker compose up -d" ) | crontab -

From source

For contributors, or when you want to modify the code:

git clone https://github.com/MillionSend/millionsend.git
cd millionsend
cp .env.example .env   # fill it as in the quickstart
docker compose up --build -d

The root docker-compose.yml builds the image locally from the Dockerfile. To run a clone against the published image instead: docker compose -f docker-compose.yml -f docker-compose.prebuilt.yml up -d.

Without Docker (Node 24+, pnpm 11, local Postgres): pnpm install, point DATABASE_URL at your Postgres, pnpm --filter @millionsend/db db:migrate, then run pnpm --filter @millionsend/api dev, pnpm --filter @millionsend/worker dev, and pnpm --filter @millionsend/web dev in separate terminals.

Environment reference

From .env.example. Only the two secrets are required; everything else has working local defaults. Billing (plans, Stripe) exists only on hosted deployments — see Billing; a self-hosted instance has no plan limits.

Required

VariablePurpose
DATABASE_URLPostgres connection string. The default matches the compose postgres service.
POSTGRES_PASSWORDPassword of the compose postgres service (default millionsend); the setup wizard generates one and puts it in DATABASE_URL too. Keep both in sync.
MASTER_ENCRYPTION_KEYEncryption key for email bodies at rest. Generate with openssl rand -base64 32. Losing it makes stored bodies unrecoverable; changing it orphans old bodies. Back it up with the database.
BETTER_AUTH_SECRETDashboard session signing secret. Generate with openssl rand -base64 32.
APP_BASE_URLPublic base URL of the 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, unsubscribe links, and tracking links derive from it. It must match the exact scheme+host+port you open the dashboard on — including a custom WEB_PORT — or login and signup fail with an "invalid origin" error. Default http://localhost:3000.
PUBLIC_API_URLPublic origin of the API when a reverse proxy serves it on its own hostname (e.g. https://api.example.com). It is what the dashboard prints as the API base and what MCP tokens are bound to; unset, the API is assumed at port 3001 of the dashboard host.

AWS SES

VariablePurpose
AWS_REGIONSES region (default us-east-1); also the KMS and SQS client region.
AWS_REGIONSComma-separated SES regions this deployment sends from, the first being the default; unset, the one region in AWS_REGION. Each region needs its own SNS topic and configuration set — see Adding a region.
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEYIAM credentials with ses:SendEmail / ses:SendRawEmail. Omit to use the default AWS credential chain (instance profile, SSO, …).
SNS_TOPIC_ARNSComma-separated SNS topic ARNs allowed to deliver SES events. Unset disables event ingestion entirely.
SQS_QUEUE_URLSQS queue the worker long-polls for SES events. Setup always creates it; keep it set even when SNS also pushes to https://<your-host>/ses/events (the app dedupes the two).
SES_CONFIGURATION_SETSES configuration set applied to sends that have no per-domain configuration set. Unset sends without one (and without delivery events).
SES_TENANTSOne SES tenant per team, so SES tracks bounce/complaint reputation per customer and can pause one sender without the rest. Defaults to IS_CLOUD; needs the ses:*Tenant* IAM actions.

Optional

VariablePurpose
MILLIONSEND_IMAGEStandalone deploy only: which image to run. The default ghcr.io/millionsend/millionsend:latest is the latest tagged release (:edge follows main), so docker compose pull is the upgrade. Set a version tag or an immutable ghcr.io/millionsend/millionsend@sha256:… digest to hold a version (MILLIONSEND_BACKUP_IMAGE pins the backup sidecar the same way).
ALLOW_SIGNUPThe first user can always register; after that signup stays closed unless this is true. Keep false when the dashboard is reachable from the internet.
TRUSTED_PROXIESReverse proxies whose forwarded-client-IP headers (X-Forwarded-For, CF-Connecting-IP) are believed, comma-separated IPs or CIDRs. Default 127.0.0.1,::1 covers a proxy on the same host; add your proxy's address when it runs elsewhere. See the nginx section.
WEBHOOK_ALLOW_LOCALHOSTLocal development only: lets webhook endpoints (test fires included) target http:// and loopback/private addresses on any port. Keep false on any internet-reachable instance.
COMPOSE_PROFILESOptional compose services, comma-separated: smtp (the relay; mount a STARTTLS keypair first), and in the standalone file also docs (this documentation site) and backup (scheduled dumps).
PORTAPI port (default 3001). Under compose this moves both the container's listen port and the published host port together.
UNSUBSCRIBE_BASE_URLOptional own host for the hosted unsubscribe pages (e.g. https://unsubscribe.example.com), pointed at the same web process. Unsubscribe links in mail and the page's redirects use it, and that host serves the unsubscribe flow only, so recipients and link scanners never reach the dashboard's origin, cookies or reputation. Unset: APP_BASE_URL.
WEB_PORTHost port the compose file publishes the dashboard on (the web process is always 3000 inside the container). Keep APP_BASE_URL in sync.
DOCS_PORTHost port the compose file publishes this documentation site on (the docs process is always 3002 inside the container).
SMTP_PORTSMTP relay listen port (default 2587).
SMTP_TLS_CERT_PATH / SMTP_TLS_KEY_PATHSTARTTLS keypair for the SMTP relay (PEM paths inside the container). Both set: STARTTLS is offered and required before AUTH. Without the pair the relay refuses to start (unless SMTP_ALLOW_INSECURE_AUTH=true).
SMTP_ALLOW_INSECURE_AUTHExplicit local/private-network escape hatch for plaintext SMTP AUTH. Keep false; never combine true with a public bind.
IS_CLOUDLeave false. true enables hosted-cloud behavior (KMS, Stripe billing).
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET / STRIPE_PORTAL_CONFIGHosted cloud only; ignored when IS_CLOUD=false. Stripe API key, the signing secret of the webhook endpoint at /api/billing/webhook, and an optional customer-portal configuration id.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETOAuth credentials for "Continue with Google". The button appears only when both are set. Callback URL: {APP_BASE_URL}/api/auth/callback/google.
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRETSame, for GitHub. Callback URL: {APP_BASE_URL}/api/auth/callback/github.
AUTH_EMAIL_FROMSender for the emails to a person about their own account (password reset, email verification, the welcome, a password-changed receipt, an app granted access), as Name <user@domain> or a bare address; its domain must be a verified identity in this instance's SES account. Password recovery and sign-up verification only exist when this is set and SES credentials are configured — leave unset to skip both. Verify its domain in a team under Domains and those emails are logged there, with new accounts as its contacts (see Account mail).
TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEYCloudflare Turnstile keys, both or neither. When set, sign-in, sign-up, password reset and the onboarding "Send email" button verify a challenge token (invisible or managed widgets both work). Unset, every form runs without a captcha.
ONBOARDING_EMAIL_FROMShared sender for the onboarding "Send email" button and snippet, as Name <user@domain> or a bare address on a domain verified in this SES account. Any team may send from it, but only to members who verified their address (where the instance verifies), and always with this exact display name. Leave unset to hide the button; the snippet then asks for the team's own domain.
NOTIFICATIONS_EMAIL_FROMSender for the notices to team owners (quota, bounce/complaint rates, a domain verifying or losing its records, a new API key, a rotated webhook secret, a member joining, a broadcast that went out or is held, billing on the cloud) and for team invitation emails, same forms as AUTH_EMAIL_FROM, which it falls back to. With neither set, only the webhook events go out and invitations are link-only. Verify its domain in a team to log these emails there.

Worker sizing

Defaults fit the 14/s send rate. Postgres runs with max_connections=200 in the compose files; each process (api, worker, web) holds a pool of up to 24 connections, so separate containers and worker replicas fit without tuning.

VariablePurpose
SEND_CONCURRENCYParallel send lanes in the worker (default 16) — about 1.2 per message/second of SES send rate.
WORKER_REPLICASNumber of worker processes running (default 1). The SES rate limiter is a per-process token bucket, so each worker divides the send rate by this to keep the account at its total.
SES_TRANSACTIONAL_RESERVEPercent of every region's rolling 24-hour SES quota that broadcasts never touch (default 30, allowed 5–90). Transactional mail may use all of it and borrow beyond it; a broadcast larger than the rest is paced over the following days. Bootstrap value only: Console → Regions overrides it at runtime.
SQS_POLL_CONCURRENCYParallel SQS long-poll loops for SES events (default 4).
WEBHOOK_DELIVERY_RETENTION_DAYSHow long webhook delivery rows and payloads stay readable in the delivery log (default 30); older ones are purged.
EMAIL_METADATA_RETENTION_DAYSDays whole email rows (recipients, subject, status, events) are kept (default 30, the industry norm); bodies leave earlier on the dashboard's retention setting, and daily counters and broadcast results are kept regardless. Releases before v0.6.30 defaulted to 365: set it explicitly before upgrading if that history must stay.
OPEN_PREFETCH_WINDOW_SECONDSA tracking-pixel fetch within this many seconds of delivery (or before it) is recorded as prefetched, not opened (default 10); 0 keeps only the user-agent rules. See open-rate accuracy.

Object storage (uploads & backups)

VariablePurpose
S3_ENDPOINT / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEYONE S3-compatible credential set shared by team logo uploads and database backups (Cloudflare R2 works out of the box). Set all three together.
S3_REGION / S3_PROVIDERDefaults auto / Cloudflare suit 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_STORAGE_BUCKET / S3_STORAGE_PUBLIC_URLPublic uploads bucket (team logos) and the public base URL it serves from. Set together; unset hides the upload UI everywhere.
S3_BACKUP_BUCKETPRIVATE bucket for scheduled database dumps — never the public uploads bucket. Unset disables the backup service.
S3_BACKUP_PREFIX / BACKUP_CRON / BACKUP_RETENTION_DAYSBackup tuning: object key prefix (default backups), daily dump schedule (<minute> <hour> * * *, UTC, default 0 3 * * *; any other shape makes the service exit 1), days of dumps kept (default 14).
BACKUP_AGE_RECIPIENTage public key (age1…); set to encrypt dumps before upload. Restore with age --decrypt -i <key file> first.

AWS setup

The AWS step of npx @millionsend/setup creates everything MillionSend needs in AWS — IAM policy + user + access key, the SNS event topic, the SQS events queue (millionsend-events) the worker long-polls, and the SES configuration set. An HTTPS APP_BASE_URL additionally gets events pushed to your host; the queue works without any public URL.

Run it anywhere Node 18+ and your AWS admin credentials live — laptop or server; the MillionSend server never needs admin credentials. It verifies your AWS identity, shows the plan, creates everything, and writes the AWS_* lines into the .env in the current directory (no .env there → it prints them to paste where MillionSend runs). --dry-run prints the full plan and exits.

npx @millionsend/setup teardown deletes everything the setup created, including all access keys of the millionsend IAM user, so a running server stops sending. Re-running setup is safe, but each run mints a new access key — delete stale ones in the IAM console.

No Node on the server? The same CLI ships inside the image — run it from the deploy directory, which it reads and writes as /work (the wizard writes nothing outside it, so run it as yourself and the .env it creates is yours, mode 600):

docker run --rm -it --user "$(id -u):$(id -g)" -e HOME=/home/ms -v ~/.aws:/home/ms/.aws:ro -v "$PWD":/work -w /work ghcr.io/millionsend/millionsend:latest setup

Prefer not to run a CLI? The dashboard's Settings → SES page offers a CloudFormation quick-create link and a pre-filled shell script that create the same resources.

Adding a region

One deployment can send through several SES regions. A domain lives in one region, the one picked when it is added (to move it, delete it and add it again); identities, the 24-hour quota, the send rate and the sandbox status are all per region; every region's events land in the one SQS queue, because SNS delivers across regions.

Run the wizard's add-region command where the .env lives — the deploy directory, or on the server through the image with short-lived admin credentials in the environment (nothing is stored):

npx @millionsend/setup add-region us-east-1
# or, on the server, from the deploy directory:
docker run --rm -it --user "$(id -u):$(id -g)" -e HOME=/home/ms \
  -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
  -v "$PWD":/work -w /work ghcr.io/millionsend/millionsend:latest setup add-region us-east-1

npx @millionsend/setup add-region needs @millionsend/setup 0.9.0 or later; the image already carries it. Without a terminal (stdin piped), the region argument is required and the confirmation needs an explicit yes line: the command refuses to guess a region, and an empty answer means no.

The interactive wizard offers the same step as Add a region at its AWS step. Run anywhere else, without a .env, the command asks for the install's SQS_QUEUE_URL, SNS_TOPIC_ARNS, AWS_REGIONS, and optional APP_BASE_URL (empty: queue only), then confirms before creating anything and prints the two lines instead of writing them.

It keeps the IAM user, policy and access key (no new key), creates in the new region the SNS topic, the SES configuration set with its event destination and the bounce-only suppression setting, subscribes the topic to the existing queue, and appends to .env:

AWS_REGIONS=sa-east-1,us-east-1   # the first entry stays the default region
SNS_TOPIC_ARNS=<first topic ARN>,<new topic ARN>

AWS_REGION and SQS_QUEUE_URL are left as they are. Restart the stack (docker compose up -d): the region then appears in the add-domain form and on Settings → SES, marked Sandbox until AWS grants production access there — request it per region, as for the first one. While one region has production access, a sandbox region is listed but not selectable in the form; a sandbox region paces its own sends at its 1/s and holds only its own domains when its 24-hour quota is spent.

Pricing: since 2026-07-21 an SES account × region with no prior sending starts on the Essentials plan ($0.16 per 1,000 messages instead of the à la carte $0.10). After provisioning, the wizard reads the region's plan and, on Essentials, asks whether to cancel it; nothing MillionSend uses needs a plan, and a defaulted plan's cancellation takes effect immediately. By hand: aws sesv2 put-account-pricing-attributes --plan NONE --region <region>.

Manual equivalent: in the new region, the SNS topic, the configuration set and the suppression setting exactly as in SES events; a sqs subscription of that topic to the existing queue's ARN, and the queue's policy extended so sqs:SendMessage is allowed from the new topic ARN as well; then the two .env lines above and a restart.

SES events (bounces, complaints, deliveries)

The setup CLI always configures this: an SQS queue (millionsend-events) that the worker long-polls, its URL in .env as SQS_QUEUE_URL. The queue buffers events through restarts and needs no inbound reachability, so it is the transport every deployment gets; a public HTTPS APP_BASE_URL additionally gets an SNS subscription pushing to your host, and the app dedupes the two. SNS_TOPIC_ARNS gates ingestion either way: events are only accepted from topics on that allowlist. Keep SQS_QUEUE_URL set even after switching to an https APP_BASE_URL — clearing it leaves events piling up in the queue.

Manual equivalent: an SNS standard topic (same region as SES) subscribed to https://<your-host>/ses/events (or to an SQS queue whose policy lets the topic send and whose URL is in .env as SQS_QUEUE_URL), its ARN in .env as SNS_TOPIC_ARNS; an SES configuration set with an event destination pointing at the topic (event types: Delivery, Delivery Delay, Bounce, Complaint, Reject, Rendering Failure — do NOT subscribe Open or Click, which makes SES rewrite every link and inject its own pixel while MillionSend tracks engagement itself), its name in .env as SES_CONFIGURATION_SET. Restart after setting them. Without SES_CONFIGURATION_SET, sends go out without a configuration set and emit no events.

The wizard also sets SES's account-level suppression list to bounces only. That list is per region and shared by every team on the instance: a hard-bounced mailbox is dead for everyone, so SES may stop it account-wide, but a spam report is about one sender's mail — MillionSend suppresses it for that team alone, and left on the SES list it would also block an unrelated team's receipt or a password reset to the same person. If you provisioned by hand or with the CloudFormation template, set it yourself in the SES console (Suppression list → Account-level settings) or with aws sesv2 put-account-suppression-attributes --suppressed-reasons BOUNCE.

The HTTPS SNS subscription confirms itself once the app runs with SNS_TOPIC_ARNS set; if it stays pending, use "Request confirmation" on it in the SNS console. Same-account SQS subscriptions need no confirmation.

The subscription endpoint is {APP_BASE_URL}/ses/events, but the API process serves that path, not the dashboard: a reverse proxy in front of the dashboard hostname must route that one path to the API (the nginx section does), or the confirmation POST lands on the dashboard, 404s, and the subscription stays pending with every bounce and delivery lost.

SES tenants (per-team reputation)

With SES_TENANTS=true (the default on Cloud) every team gets its own SES tenant, named by the team id, in each region it has a domain in. A domain's identity and the shared SES_CONFIGURATION_SET are associated with the tenant when the domain is created, and every send from it names the tenant, so SES keeps bounce and complaint metrics — and its own sending pause — per customer instead of per account. Domains that predate the flag, or whose association failed, are picked up by the hourly tenants.sync job. The IAM policy the wizard and the CloudFormation template install includes the ses:CreateTenant, ses:GetTenant, ses:DeleteTenant, ses:CreateTenantResourceAssociation and ses:DeleteTenantResourceAssociation actions; an existing deployment re-runs the wizard (or updates the millionsend-ses policy) before turning the flag on.

To update the policy in place, publish the JSON the SES settings page shows as a new default version:

aws iam create-policy-version --policy-arn arn:aws:iam::<account-id>:policy/millionsend-ses \
  --policy-document file://millionsend-ses.json --set-as-default

SMTP relay

A drop-in SMTP relay for software that speaks SMTP instead of HTTP — legacy apps, CMS plugins, anything with an "SMTP settings" form. Messages go through the same accept pipeline as POST /emails: same domain verification, suppression checks, request logging, and delivery events.

Connection details:

  • Host: wherever the smtp service is reachable (the compose files publish it on the Docker host).
  • Port: 2587 (SMTP_PORT to change).
  • Username: millionsend (fixed).
  • Password: an ms_ API key from the dashboard.
  • Encryption: STARTTLS is offered (and required before AUTH) when SMTP_TLS_CERT_PATH and SMTP_TLS_KEY_PATH point at a PEM keypair. Without one, the relay refuses to start unless SMTP_ALLOW_INSECURE_AUTH=true is explicitly enabled for a trusted private network.

STARTTLS with your existing certificates

Before exposing the relay to the internet, give it a certificate — otherwise SMTP AUTH sends the API key in plaintext. Any PEM keypair works, and if you followed the nginx guide above you already have one: reuse the Let's Encrypt certificate certbot issued for your domain. Mount it into the smtp container with a docker-compose.override.yml:

services:
  smtp:
    volumes:
      - /etc/letsencrypt/live/mail.example.com:/certs:ro

and point the env at it in .env:

SMTP_TLS_CERT_PATH=/certs/fullchain.pem
SMTP_TLS_KEY_PATH=/certs/privkey.pem

With both set, STARTTLS is required before AUTH — credentials never cross the wire unencrypted. Mount the live/<domain> directory (a symlink certbot keeps current), not a copy of the files, so a renewal lands at the same path — and restart the relay after each renewal, since it reads the keypair when it starts (certbot: --deploy-hook 'docker compose -f /opt/millionsend/docker-compose.yml restart smtp'). A wildcard or any other CA-issued PEM works the same way.

Nodemailer example:

import nodemailer from "nodemailer";

const transport = nodemailer.createTransport({
  host: "localhost",
  port: 2587,
  auth: { user: "millionsend", pass: "ms_..." },
});

await transport.sendMail({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Hello",
  html: "<p>Sent over SMTP.</p>",
});

The smtp service is defined in both compose files behind the smtp profile, so it stays off until asked for: once the keypair is mounted, add smtp to COMPOSE_PROFILES in .env (comma-separated with any others) and docker compose up -d.

Documentation site

The image can also serve this documentation site: a docs compose service runs with PROCESS=docs and publishes port 3002 (host side tunable via DOCS_PORT). It needs no database and is entirely optional; in the standalone file it sits behind the docs profile (COMPOSE_PROFILES=docs).

Production: nginx + TLS

The recommended production shape: nginx on the host terminates TLS and proxies one hostname per service, and the compose ports bind to loopback so nginx is the only way in. The API needs its own hostname (or an exposed port): its routes (/emails, /domains, …) share paths with dashboard pages, so the two cannot split one hostname by path. Set PUBLIC_API_URL to that hostname — it is what the dashboard prints as the API base and what MCP tokens are bound to; unset, the API is assumed at port 3001 of the dashboard host.

/etc/nginx/conf.d/millionsend.conf:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      close;
}

# Dashboard.
server {
    listen 80;
    server_name mail.example.com;

    # The broadcast editor posts full HTML bodies through the dashboard.
    client_max_body_size 25m;

    # SES events: SNS is subscribed at {APP_BASE_URL}/ses/events, and the API
    # process serves that path, not the dashboard.
    location = /ses/events {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

# API.
server {
    listen 80;
    server_name api.example.com;

    # POST /emails/batch takes up to 100 emails per request; html/text bodies
    # carry no schema byte cap, but SES rejects messages over 10 MB anyway.
    # 25m covers a full batch of large bodies without unbounded uploads.
    client_max_body_size 25m;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Docs (optional).
server {
    listen 80;
    server_name docs.example.com;

    location / {
        proxy_pass http://127.0.0.1:3002;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

TLS and the http→https redirect in one line — certbot rewrites the blocks above to listen on 443 with Let's Encrypt certificates, adds the redirect, and installs automatic renewal:

sudo certbot --nginx --redirect -d mail.example.com -d api.example.com -d docs.example.com

Then set APP_BASE_URL=https://mail.example.com and PUBLIC_API_URL=https://api.example.com in .env and restart. APP_BASE_URL must be the exact public https origin of the dashboard — any other value makes login and signup fail with an "invalid origin" error. Forward Host and X-Forwarded-Host to the dashboard and docs upstreams as above, so any absolute URL either app derives from the request names the public hostname rather than localhost.

Client addresses (sign-in rate limits, audit entries) come from X-Forwarded-For, and only proxies listed in TRUSTED_PROXIES (comma-separated IPs or CIDRs; default 127.0.0.1,::1, which covers nginx on the same host) are believed. With proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for each hop appends itself, and the chain is walked right-to-left past every trusted proxy, so the first untrusted address is the client. Add your proxy's address when it runs on another host, and a CDN's ranges when one sits in front of nginx; headers from any other source are ignored and the socket address is used instead.

The compose files bind every application port to loopback by default (WEB_BIND_ADDRESS, API_BIND_ADDRESS, DOCS_BIND_ADDRESS, SMTP_BIND_ADDRESS, all 127.0.0.1), so only a local reverse proxy reaches them. Docker publishes ports by editing iptables directly, so do not rely on a host firewall to compensate for a public bind: set a *_BIND_ADDRESS to 0.0.0.0 only for a service that must be reachable directly.

The SMTP relay (:2587) is TCP, not HTTP — an http server block cannot proxy it. Either publish it directly (SMTP_BIND_ADDRESS=0.0.0.0 and open the firewall), or keep it on loopback and pass the TCP stream through nginx's stream module — bytes pass through untouched, so STARTTLS still terminates in the relay via SMTP_TLS_CERT_PATH/SMTP_TLS_KEY_PATH:

# /etc/nginx/nginx.conf — top level, outside the http {} block
stream {
    server {
        listen 2587;
        proxy_pass 127.0.0.1:2587;
    }
}

Firewall: allow 80 and 443, plus 2587 only if the SMTP relay is used from outside; everything else closed:

sudo ufw default deny incoming
sudo ufw allow 80,443/tcp
sudo ufw allow 2587/tcp   # only if the SMTP relay is exposed
sudo ufw enable

Object storage (team logos)

Optional. With an S3-compatible bucket configured, team admins can upload a team logo in the dashboard; it also brands hosted unsubscribe pages when MillionSend branding is hidden. ONE S3_* credential set is shared with the backup job below — each feature is then enabled by its own bucket variable.

The storage step of npx @millionsend/setup prompts for the endpoint and keys, creates (or adopts) both buckets — millionsend-storage and millionsend-backups by default — and writes the S3_* lines to .env. The one thing it cannot do over the S3 API is make the uploads bucket serve objects publicly: on R2, enable public access on the bucket (or attach a custom domain), then set that URL — uploads are addressed as ${S3_STORAGE_PUBLIC_URL}/<key>:

S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_STORAGE_BUCKET=millionsend-storage
S3_STORAGE_PUBLIC_URL=https://<public-bucket-url-or-custom-domain>

Keep the two buckets separate: R2 public access is bucket-wide, so a database dump in the public uploads bucket would be world-readable.

Backups

The backup compose service takes a scheduled pg_dump of Postgres and uploads it to any S3-compatible bucket via rclone — Cloudflare R2 works out of the box. It is off by default: without S3_BACKUP_BUCKET the container prints backups disabled — set S3_BACKUP_BUCKET to enable and exits 0, harmless.

Enable it by setting the shared S3 credentials and a backup bucket in .env (the setup wizard's storage step creates the bucket and writes these lines). The bucket must exist before the first dump and must stay private — dumps contain the whole database, and R2 public access is bucket-wide, so never reuse the public uploads bucket. For R2 the defaults S3_PROVIDER=Cloudflare and S3_REGION=auto are already right:

S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_BACKUP_BUCKET=millionsend-backups

Then add backup to COMPOSE_PROFILES in .env and docker compose up -d: the service dumps once immediately, and after that daily on BACKUP_CRON (default 0 3 * * *, UTC). Only the daily form <minute> <hour> * * * is honoured — the sidecar runs unprivileged as postgres with every capability dropped, so the schedule is a sleep loop rather than crond, and any other shape makes the service exit 1. Each dump is pg_dump -Fc (compressed custom format, named millionsend-YYYYMMDD-HHMMSS.dump), its uploaded size is verified against the bucket before anything else happens, and dumps older than BACKUP_RETENTION_DAYS (default 14) are pruned. S3_BACKUP_PREFIX (default backups) sets the object key prefix. Other S3-compatible stores work by setting S3_PROVIDER to rclone's provider name (AWS, Minio, …) and a real region if the endpoint needs one.

Set BACKUP_AGE_RECIPIENT to an age public key (age1…) to encrypt each dump before upload (.dump.age); the bucket then never holds a readable copy of the database. Keep the matching private key with MASTER_ENCRYPTION_KEY, and restore with age --decrypt -i <key file> before pg_restore.

The dumps contain email bodies encrypted with MASTER_ENCRYPTION_KEY — back that key up separately, or restored bodies are unrecoverable.

The standalone deploy/docker-compose.yml runs the published ghcr.io/millionsend/backup image (MILLIONSEND_BACKUP_IMAGE pins it, the way MILLIONSEND_IMAGE pins the app); a repository clone builds it from scripts/backup. Restores use the same container either way.

Restore

Stop the app first so nothing writes mid-restore:

docker compose stop millionsend smtp
# list the bucket, pick a dump
docker compose run --rm --entrypoint /usr/local/bin/backup.sh backup \
  sh -c 'rclone lsl ":s3:$S3_BACKUP_BUCKET/${S3_BACKUP_PREFIX:-backups}"'
# download it and restore over the current database
docker compose run --rm --entrypoint /usr/local/bin/backup.sh backup \
  sh -c 'rclone copyto ":s3:$S3_BACKUP_BUCKET/${S3_BACKUP_PREFIX:-backups}/millionsend-YYYYMMDD-HHMMSS.dump" /tmp/restore.dump \
    && pg_restore --clean --if-exists -d "$DATABASE_URL" /tmp/restore.dump'
docker compose start millionsend smtp

Signup policy

The first user to register becomes the initial account — no configuration needed. After that, registration is closed: anyone with an account can create API keys that send through your SES account, so signup stays off unless you opt in with ALLOW_SIGNUP=true. Keep port 3000 off the public internet unless you have opened signup deliberately.

Account mail, contacts and product updates

MillionSend's own emails go out from AUTH_EMAIL_FROM and NOTIFICATIONS_EMAIL_FROM. To a person, about their account: password resets, email verification, a welcome once the address is theirs, a receipt when a password reset goes through, and one when an MCP app is granted access — sent from the request itself. To a team's owners: team invitations, the quota and deliverability notices, a domain that verified or lost a record (a domain SES gave up on says to add it again), a new API key (also to whoever created it), a rotated webhook secret with the old secret's deadline, a member who joined, a broadcast that went out — or is waiting for its quota, or held while its region is paused — and, on the cloud, a plan activated, moved or ended, a scheduled cancellation with a reminder three days out, and a failed charge. A broadcast report is sent by the worker as the broadcast finishes and the billing notices by the Stripe webhook itself (only the cancellation reminder and a plan lapsing at its period end come from the worker); every other owner notice comes from the notification sweep, so it arrives within ten minutes of the change. Each is written in the reader's own language, which sign-up records and the dashboard's language switcher changes; an account older than that reads the language of its contact in the team that holds the sender's domain (the row sign-up enrolls, below), else English. Each owner chooses which of these notices they get under Settings → Notifications; mail about a person's own account and the security receipts are always sent.

Verify the sender's domain under Domains in a team, and from then on those emails are logged and measured in that team like any other: they appear in its Emails list with a millionsend_system tag, count in its Metrics, and its Suppressions fill in from their bounces. Their body is purged the moment SES accepts the message, since a reset link is a live credential, and their links are never rewritten for click tracking. Until a team holds the domain they are sent straight through SES and leave no trace, as before.

That team is the instance's own, and an operator can mark it as such: on the system plan it is never capped or billed, its badge reads System and the Billing tab shows a notice instead of plans. On a self-hosted instance plans carry no limits, so the mark only labels the team.

The same team is the audience for product updates. On an instance with ALLOW_SIGNUP=true, every new account becomes a contact there with a source: signup property (name, address, sign-up date and dashboard locale) once its address is verified — a social sign-in arrives verified, a password sign-up counts when the emailed link is opened. The sign-up screen says so, and deleting the account deletes the contact and scrubs the address from that team's history. A closed instance enrolls nobody. To enroll accounts that existed before the domain was verified, run once (accounts that never verified enroll on their own at their next sign-in):

insert into contacts (team_id, email, first_name, last_name, properties)
select '<team id>', email,
       split_part(name, ' ', 1),
       nullif(substr(name, length(split_part(name, ' ', 1)) + 2), ''),
       jsonb_build_object('source', 'backfill', 'signed_up_at', to_char(created_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
from "user"
where email_verified
on conflict do nothing;

Sends to these contacts follow the usual rules: create a topic (say, "Product updates") so an unsubscribe applies to it and never to account mail, and send broadcasts from the team's verified domain.

Email verification is on whenever AUTH_EMAIL_FROM is set and SES credentials are configured — the same condition as password recovery. A password sign-up gets no session until the emailed link is opened; accounts from before verify at their next sign-in. Where the instance verifies, the onboarding sender only reaches members who did.

On an instance upgrading to this release, domains already verified and day-old audit rows are marked as told, but a domain that is demoted at the moment of the upgrade is reported as lost on the first sweep.

Nothing on a self-hosted instance contacts millionsend.com on its own. The setup wizard offers, once and interactively, to subscribe the operator's address to MillionSend release notes; that is the wizard on your machine posting your answer, and a confirmation link is sent before anything is stored. When it cannot reach millionsend.com it prints the page instead, app.millionsend.com/updates?source=self-host, and Settings → Instance links to the same page; the source tags you as a self-hoster either way.

Console

/console is the operator's view of the whole deployment: an overview (sends, deliverability, teams, contacts, domains, queue, one card per SES region with quota, pricing plan and enforcement status, and the health probes with their history), a Regions page, a Teams page with operator actions (change plan or type, daily send ceiling, pause broadcasts, suspend and reinstate), a Trust & safety page built on the guardrail, the account score, the stored content insights (never email bodies) and, when the optional content monitor is on, the model's sampled verdicts, and an instance-wide audit log.

Only the instance operator (the first registered user) can open it; everyone else gets a 404, and nothing in the app links to it. On a self-hosted instance, Settings → SES shows the operator an "Instance console" card with an "Open console" button; the direct URL https://<your-host>/console works too. Every number comes from Postgres or a free SESv2 GetAccount read per region; no paid AWS API is called, and the per-region cost is a local estimate.

A suspended team keeps its data and its keys authenticate, but every send answers 403 team_suspended (SMTP 550); a broadcast pause holds broadcasts while transactional mail flows; a daily ceiling caps the team's day under its plan. Owners are emailed about each action (except a phishing suspension), and every action lands in the audit log with its reason.

Content monitoring (optional)

Off by default. With a judge configured, a sample of accepted mail is scored 0–100 by TypeSafe Jev after SES has taken it and folded into a per-team risk the operator sees on Trust & safety. Nothing on the send path waits for it: a verdict never delays, holds or refuses a message, and a judge failure of any kind (feature off, missing credentials, throttling, timeout, upstream error, unparseable answer, body already purged by retention) records the sample as unjudged and changes nothing else. The deterministic content checks (the insights, the guardrail, the account score) run on every send whether or not the judge is on. Self-hosters can leave it off.

What it does. It opens the monitor flag on Trust & safety when a team's risk crosses the flag line, emails the operator once a day per team past the alert line, and, for a team in the New tier only (inside its first 1,000 sends or 72 hours, or under 10,000 sends within 7 days), pauses broadcasts when the risk passes the pause line and a sampled message scored 90 or more within a day (transactional mail keeps flowing; the team sees "paused pending review"; the operator resumes from the review page). It never suspends a team and never holds transactional mail: a person decides. The pause policy is a setting and can be switched off.

Turning it on in the instance's .env, read by the worker and the app (a restart applies it):

ABUSE_JUDGE=typesafe
ABUSE_JUDGE_API_KEY=...
# Optional; jev-1.13.0 is the default.
ABUSE_JUDGE_MODEL=jev-1.13.0
ABUSE_JUDGE_TIMEOUT_MS=20000

A missing API key fails the boot. The default model is a pinned version, not the jev-latest alias, because the score thresholds are calibrated against one version's answers: move to a newer version deliberately, after checking its scores. Each sample records the versioned model id that answered. ABUSE_JUDGE_BASE_URL (default https://api.typesafe.ai) points the judge at another endpoint serving the same API; it posts to <base>/v1/systemone. The questions Jev answers ship in packages/core/src/abuse-judge/questions.ts.

Where the samples go. Every sampled message is sent to TypeSafe, a sub-processor that hosts its service in the United States. Per its published terms, its data processing agreement keeps personal data for as long as necessary for the purpose of the processing; its customer agreement gives it a perpetual licence to use submitted data for fraud and abuse monitoring, telemetry and legal compliance; zero data retention is offered only on enterprise plans; submitted data is not used to train its models. Its DPA offers the EU standard contractual clauses and the UK Addendum as transfer mechanisms, and no Brazilian (ANPD) clauses. Before turning the judge on, accept TypeSafe's data processing agreement, list TypeSafe as a sub-processor, and describe it in the instance's privacy notice (see also its customer agreement and privacy policy).

Exactly what Jev sees, built in memory per call and never stored by MillionSend: the team's name, verified domains (plus each one's registrable domain, the form links are shown in, where the suffix is unambiguous), days since its first send and plan; the From and Reply-To headers as sent; the Subject; the rendered visible text, without elements hidden by an inline style or the hidden attribute (up to 6,000 characters); a table of link anchor texts and their registrable domains (up to 30 rows); the image count, the attachment names and content types, and the count of hidden characters. The subject, the visible text, the anchor texts and the attachment names are redacted first by the same pass the content access below uses (links cut to their domain and a short path stub; credential-shaped strings and 4-to-8-digit codes in the 40 characters after words like code, OTP, senha or token masked); on top of that, email addresses in them keep only their domain. Whitespace in every field, line breaks included, collapses to one space. Other personal data written in these fields (names, phone numbers, tax ids such as CPF, postal addresses) is not removed: nothing detects it reliably. Never a recipient address or header, never the raw HTML, never an attachment's content. A judged sample keeps its score, verdict, categories, reason codes, language, model version, latency and error class; the review page shows those and never a subject or a body. Sample rows are pruned after 90 days.

Sampling. After each accepted message a keyed draw decides whether it is judged. Every value below is edited in the console under Trust & safety → Monitoring settings, or set as its MONITOR_* environment variable until it is; the console wins.

SettingDefaultMeaning
MONITOR_FIRST_SENDS1000A team's first N accepted messages are judged in full
MONITOR_FIRST_HOURS72Everything in the first H hours after a team's first send is judged in full
MONITOR_RAMP_SENDS10000Up to this lifetime count the ramp rate applies
MONITOR_RAMP_RATE0.25The ramp rate; the ramp ends at the count above or on the day below, whichever comes first
MONITOR_RAMP_DAYS7
MONITOR_PROBATION_RATE0.05The ramp's end to day 30
MONITOR_ESTABLISHED_RATE0.02Day 30 onward
MONITOR_TRUSTED_RATE0.005120 days, 50,000 sends and no flag in 90 days
MONITOR_BROADCAST_COPIES3Rendered copies judged per broadcast (plus the broadcast's own HTML), established and trusted teams
MONITOR_BROADCAST_COPIES_NEW10The same for new, ramp and probation teams
MONITOR_ANOMALY_MULTIPLIER20One failing link-domain, shortener or phishing-pattern check multiplies the rate; two force the sample
MONITOR_TEAM_DAILY_CAP600Judged messages per team per UTC day; past it sampling stops silently
MONITOR_INSTANCE_DAILY_CAP50000Instance-wide; past it tier sampling stops, first sends and anomalies continue
MONITOR_FLAG_RISK0.5The team gets the monitor flag and samples four times as much
MONITOR_ALERT_RISK0.7The operator is emailed, once per team per day
MONITOR_PAUSE_RISK0.85New teams only: broadcasts pause, with a verdict of 90 or more in the last day
MONITOR_AUTO_PAUSEtrueWhether the pause policy applies
MONITOR_FLAG_SCORE70A sample counts as flagged in the console from this score

The risk is a decayed mean of the verdicts (half-life 7 days) with a prior that starts new teams higher. The Overview's Monitoring card charts the hourly sample count, and the operator is emailed, at most once every six hours, when more than 20% of an hour's samples (at least 20 of them) went unjudged, or as soon as TypeSafe rejects the API key.

Content access (break-glass)

Off by default. With it on, an authorised operator can read the subject and the rendered visible text of specific messages of a flagged team, for a security reason they name and justify before anything is decrypted, for at most 30 minutes. It is the break-glass path for the cases the stored metadata cannot settle: the insights know a link points at a shortener, not whether the text around it is a bank lure or a newsletter.

What an operator can see. The subject, and the rendered visible text of the HTML with hidden elements stripped (or the plain-text part when there is no HTML), cut at 20,000 characters and redacted on the way out: every link — written with a scheme or as a bare www. host — is reduced to its scheme, its registrable domain and at most 24 characters of path, with the query and the fragment dropped entirely, so a one-time link cannot be followed; anything shaped like a credential (a JWT, 32 or more hex characters, 40 or more of base64, one of this instance's own ms_ API keys) is masked, as is a 4-to-8-digit run in the 40 characters after a word like code, código, OTP, PIN, token, senha, password or verification. Never the raw HTML, the recipient addresses, the headers, the attachments or the click-tracking targets, and the view offers no copy or download. A body retention has already purged cannot be revealed by anyone.

For how long. A grant lasts 30 minutes from the moment it is made and is never extended; a later look is a new grant, with a new reason and a new audit row. Every view is counted on the grant.

What is logged. The grant row (content_access_grants) keeps the operator, the reason, the justification they wrote, the scope, the message ids, the view count and the times. Nothing prunes those rows: they are the inventory of who read what. An instance audit row (content.revealed) is written before anything decrypts, with the grant id, the reason and the number of messages — never the justification's text and never any content.

What the team sees, and when. Seven days later a daily job adds a content.accessed row to the team's own audit log — dated at the access, not at the disclosure — and emails the team's owners in their own language: when it happened, the reason, how many messages, and what was withheld. The one exception is a team suspended for phishing since the grant, where the row and the notice are withheld; the grant records that the disclosure step ran either way, so it is not retried nightly.

Turning it on in the instance's .env, read by the worker and the app (a restart applies it):

CONTENT_REVEAL=on

Off, the console's buttons render disabled with a tooltip naming the variable and both procedures refuse. Reading other people's messages is lawful only as a narrow, recorded, disclosed security measure: say so in the instance's terms and privacy notice before turning it on.

Support view (optional)

Off by default; SUPPORT_VIEW=on turns it on. From the console's Teams list, "View as owner" opens a team's dashboard as its owner sees it, read-only, for 30 minutes, after the operator names a reason (support ticket, billing dispute, other) and the ticket reference. Every reason is a request the customer made; an operator checking an abuse report works from the console's own Trust & safety pages instead, and from the content reveal when the message text itself is needed. The session rides on the operator's own login; no session is ever minted for the owner.

What the operator sees. The dashboard under a banner ("Support view of <team> · read-only · ends in mm:ss"): emails and their events, contacts, domains, broadcasts, templates, API key names, webhook endpoints, settings and usage.

What stays hidden. The content of sent mail: email bodies (the detail says "Email content is hidden in support view"); the body and preheader of a broadcast that has started going out, is sent, or was canceled mid-send; every template body, since a template's text is copied into the broadcasts sent from it and nothing records which; API log request and response bodies; CSV exports (the export route answers 403); and every secret, so API keys, webhook signing secrets and SMTP credentials are never returned. A draft or scheduled broadcast stays readable, since nothing of it has reached anyone. Every change is refused: the server answers FORBIDDEN to any mutation while the view is live, whatever the screen shows.

How long. 30 minutes, enforced on every request. One live view per operator, starting another ends the previous, and a view cannot start another. The operator ends it from the banner, the owner from Settings → Support access, and expiry ends it on the next request.

What is logged. support.view_started and support.view_ended, in the instance audit and, at once, in the team's own Settings → Audit log: who, the reason, the reference, how it ended, the minutes, and how many distinct procedures were read. The record keeps a count per procedure name and never anything a procedure returned.

What the owner receives. An email when the session starts, naming who opened it, why, the reference, until when, and where to end it; and the Support access card under Settings while it is live, with an "End session" button.

SUPPORT_VIEW=on

Operations

  • Send rate and email retention are managed in the dashboard: Settings → Instance (owner/admin). Defaults are 14/s and 30 days until changed there; the worker picks up a rate change within a minute, retention on the next purge run.
  • Worker sizing: SEND_CONCURRENCY lanes (default 16, about 1.2 per message/second of SES rate) and WORKER_REPLICAS (default 1). The SES rate limiter lives in each worker process, so every worker divides the account's rate by WORKER_REPLICAS; set it to the number of worker containers you run.
  • To run processes in separate containers, set PROCESS to api, worker, web, smtp, or docs per container (default all = api + worker + web). Upgrade them in the same up -d: the Metrics chart counts only what upgraded processes write, so a writer left on an older image during the swap is missing from that day's chart (the daily usage figures are unaffected).
  • Email bodies are gzipped, then encrypted at rest with MASTER_ENCRYPTION_KEY, and purged after the retention window. Back up the key with the database.

On this page