# Millionsend Self Host

> Stand up a self-hosted MillionSend instance (open-source Resend alternative on AWS SES) — Docker Compose, the npx @millionsend/setup wizard, AWS SES/SNS/SQS event pipeline, domain DNS/DKIM verification, and the SMTP relay. Use when installing, configuring, or troubleshooting a MillionSend deployment.

- Skill: `millionsend/millionsend-self-host` (Agent Skill)
- Install (CLI): `npx skillmds@latest add millionsend/millionsend-self-host`
- Raw SKILL.md: https://api.skillmd.com/api/skills/millionsend/millionsend-self-host/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: MillionSend (https://skillmd.com/u/millionsend)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/millionsend/millionsend-self-host

---


# 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+):

```sh
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

```sh
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 32` each. 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 (default `http://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 keep `SES_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 to `https://<your-host>/ses/events` (confirms itself once the app runs with `SNS_TOPIC_ARNS` set; if pending, use "Request confirmation" in the SNS console).
- Otherwise → an SQS queue (`millionsend-events`) the worker long-polls; its URL goes in `.env` as `SQS_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=true` to 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 `PROCESS` to `api` | `worker` | `web` (default `all`).
- Send rate and email retention are dashboard settings (Settings → Instance; defaults 14/s and 30 days). `SES_MAX_SEND_RATE` / `EMAIL_RETENTION_DAYS` env vars still work as boot overrides.
- Ports are tunable: `WEB_PORT` (dashboard), `PORT` (API), `SMTP_PORT` (relay). If the dashboard moves, update `APP_BASE_URL` to match.
- Password recovery ("forgot password") and sign-up email verification only exist when `AUTH_EMAIL_FROM` is set — a sender like `Acme <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_FROM` domain under Domains in a team and password resets, invitations and owner notices appear in that team's Emails and Metrics (tag `millionsend_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.md` in github.com/MillionSend/millionsend.

