Self-host MillionSend
MillionSend sends through the user's own AWS SES account. Two containers: Postgres plus one app container running the API (port 3001), worker, and web dashboard (port 3000). Migrations run automatically on boot.
Prerequisites: Docker with Compose; an AWS account with SES access in the chosen region (SES sandbox accounts can only send to verified recipients — request production access to send to anyone); a sending domain the user controls.
Fastest path — the setup wizard
One command in an empty directory (Node 18+):
mkdir millionsend && cd millionsend
npx @millionsend/setup
The wizard creates .env with generated secrets, optionally provisions the AWS resources (IAM policy + user + access key, SNS event topic, SES configuration set; adds an SQS queue when there is no public https URL), downloads the compose file, and runs docker compose up -d. Every step is skippable and safe to re-run; --dry-run prints the plan and touches nothing. Run it wherever AWS admin credentials live (laptop is fine) — the server itself never needs admin credentials; teardown deletes everything it created. No Node on the server? The same CLI ships inside the image: docker run --rm -it --user root -v ~/.aws:/root/.aws ghcr.io/millionsend/millionsend:edge setup.
Dashboard: http://localhost:3000 · API: http://localhost:3001.
Manual path
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
# edit .env, then:
docker compose up -d
Uses the prebuilt multi-arch image ghcr.io/millionsend/millionsend:edge (:edge is a moving head — pin a version tag for production). From source instead: clone github.com/MillionSend/millionsend and docker compose up --build -d.
Required .env values (everything else defaults to a working local setup):
MASTER_ENCRYPTION_KEY,BETTER_AUTH_SECRET—openssl rand -base64 32each. The master key encrypts email bodies at rest — back it up with the database.APP_BASE_URL— the exact URL the dashboard is opened at (defaulthttp://localhost:3000). It is the only origin sign-in accepts and is baked into unsubscribe/tracking links; a mismatch breaks login and blocks broadcast sends.AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY— or rely on the default AWS credential chain. SES sandbox accounts must keepSES_MAX_SEND_RATE=1.
SES events (bounces, complaints, deliveries, opens, clicks)
The setup wizard always configures this. Two transports:
- Public https
APP_BASE_URL→ SNS subscription pushing tohttps://<your-host>/ses/events(confirms itself once the app runs withSNS_TOPIC_ARNSset; if pending, use "Request confirmation" in the SNS console). - Otherwise → an SQS queue (
millionsend-events) the worker long-polls; its URL goes in.envasSQS_QUEUE_URL.
Manual equivalent: SNS standard topic (same region as SES) → its ARN in SNS_TOPIC_ARNS (an allowlist — events from other topics are rejected); an SES configuration set with an event destination to that topic (event types: Send, Delivery, Delivery Delay, Bounce, Complaint, Open, Click, Reject, Rendering Failure) → name in SES_CONFIGURATION_SET. Restart after setting them. Without SES_CONFIGURATION_SET, sends go out but emit no events (no delivery tracking, no webhooks, no automatic suppression). The dashboard's Settings → SES page also offers a CloudFormation quick-create link.
Domain verification (DNS)
From the dashboard after boot (Domains → add domain → it shows the DKIM CNAME records to create at the DNS provider), or via the REST API (POST /domains → records → POST /domains/{id}/verify — see the millionsend-domains skill). Sending from a domain is refused until it shows verified.
SMTP relay
For software that only speaks SMTP. Host: the Docker host; port 2587 (SMTP_PORT to change); username millionsend (fixed); password: an ms_ API key from the dashboard. STARTTLS is offered (and required before AUTH) when SMTP_TLS_CERT_PATH/SMTP_TLS_KEY_PATH point at a PEM keypair; without one the relay refuses to start unless SMTP_ALLOW_INSECURE_AUTH=true is set explicitly — do that only on a trusted private network with SMTP_BIND_ADDRESS=127.0.0.1, never on a public bind (AUTH would send the API key in plaintext). Same accept pipeline as POST /emails. The service is in the compose files; remove it if unused.
Operations gotchas
- First user to register becomes the initial account; registration then closes (
ALLOW_SIGNUP=trueto reopen). Keep port 3000 private unless signup is deliberately open. - Run exactly one worker container — the SES rate limiter is in-memory, so N replicas send at N× the configured rate. Scale vertically. To split processes across containers set
PROCESStoapi|worker|web(defaultall). - Send rate and email retention are dashboard settings (Settings → Instance; defaults 14/s and 30 days).
SES_MAX_SEND_RATE/EMAIL_RETENTION_DAYSenv vars still work as boot overrides. - Ports are tunable:
WEB_PORT(dashboard),PORT(API),SMTP_PORT(relay). If the dashboard moves, updateAPP_BASE_URLto match. - Password recovery ("forgot password") and sign-up email verification only exist when
AUTH_EMAIL_FROMis set — a sender likeAcme <auth@acme.dev>whose domain is a verified SES identity of the instance — and SES credentials are configured. Leave it unset to skip both. - Account mail is logged where its domain is verified. Verify the
AUTH_EMAIL_FROM/NOTIFICATIONS_EMAIL_FROMdomain under Domains in a team and password resets, invitations and owner notices appear in that team's Emails and Metrics (tagmillionsend_system, body purged once SES accepts them). Until then they go straight through SES with no trace. On an open instance (ALLOW_SIGNUP=true) every new account also becomes a contact of that team (source: signup) once its address is verified; a closed instance enrolls nobody. The instance never contacts millionsend.com by itself; the wizard's release-notes prompt is the operator's own one-time opt-in. - Full reference:
SELF_HOSTING.mdin github.com/MillionSend/millionsend.