What you need
Six accounts, all free to start. The only metered piece in the stack is Amazon SES, and it charges per email.
You'll add DKIM, DMARC, SPF and MX records. My DNS lives on Cloudflare for pintukr.in.
To clone / fork marclou/mailcheap and connect it to Vercel.
Hosts the Next.js app. Hobby tier is enough at newsletter scale.
Stores subscribers, newsletters, sends and suppressions.
Amazon Simple Email Service does the actual sending. Region used here: us-east-1.
Redis for rate limiting + QStash for scheduled / queued jobs.
The stack at a glance
Total running cost at small scale: effectively $0 plus SES per-email charges. The full breakdown is in What it costs at the end.
Deploy the app to Vercel
Clone the repo, create a Vercel project from it, and set the core environment variables the first successful deploy — the app needs its auth secret and admin allowlist from the start.
- Fork or clone
github.com/marclou/mailcheapto your GitHub. - In Vercel: New Project → import the repo. Framework preset should detect Next.js automatically. Don't deploy yet — add env vars first.
- Generate two long random secrets (one for auth, one for the suppression hash). On macOS/Linux:
openssl rand -hex 32— run it twice. - Add the core env vars below, then deploy.
# Auth — long random string (openssl rand -hex 32)
BETTER_AUTH_SECRET=replace-with-a-long-random-string
# Used to hash suppressed / unsubscribed addresses — another random string
SUPPRESSION_HASH_KEY=replace-with-another-long-random-string
# Sign-in is admin-only. Only these emails can log in.
ADMIN_EMAILS=you@example.com
ALLOWED_EMAILS=you@example.com
# Default sender identity shown in env / system mail
EMAIL_FROM="MailCheap <login@mail.pintukr.in>"
# Your Vercel deployment URL (update if you add a custom domain)
APP_URL=https://your-app.vercel.app
Once deployed, verify the app is alive:
GET https://your-app.vercel.app/api/health
# Expected response
{"ok": true}
Why two allowlists? MailCheap is single-operator by design. ADMIN_EMAILS and ALLOWED_EMAILS both set to your own email keeps the dashboard locked to you — there's no public sign-up to secure later.
MongoDB Atlas — free cluster
MailCheap stores everything in MongoDB. The free M0 cluster is plenty to start.
- Create a new Atlas project for MailCheap — especially if your organisation already uses its one free cluster for something else. One org can hit the free-cluster limit quietly; a fresh project avoids the clash.
- Create a free M0 cluster in the region nearest your readers (I used the closest available to India / my Vercel region).
- Database Access: create a database user with
readWriteAnyDatabase. Use a strong, unique password — you'll paste it into the connection string once. - Network Access: allow
0.0.0.0/0. Vercel serverless functions have no fixed outbound IP, so an IP allowlist will break in production. - Copy the connection string into Vercel as
MONGODB_URIand redeploy.
MONGODB_URI=mongodb+srv://<db-user>:<db-password>@<cluster>.mongodb.net/mailcheap?retryWrites=true&w=majority
Replace <db-user>, <db-password> and <cluster> with your Atlas values. If your password contains special characters, URL-encode it — Atlas shows a warning for this when you copy the string.
GET /api/health still returns {"ok": true} and the app loads without a database error — that means the connection string and the 0.0.0.0/0 network rule are both right.Amazon SES — create the domain identity
Verify a subdomain, not your apex domain.
I used mail.pintukr.in for sending, keeping it completely separate from the mail setup on pintukr.in itself. If SES reputation ever takes a hit, or you change providers, your main domain's mail is untouched.
- Open Amazon SES in region
us-east-1(the region matters — your DNS, IAM and env must all match it). - Identities → Create identity → Domain → enter
mail.yourdomain.com(mine:mail.pintukr.in). - Enable Easy DKIM with RSA 2048.
- SES will show you 3 CNAME records to publish. Keep that tab open — you'll need them in the next step.
Don't verify individual email addresses as your main identity. A domain identity lets you send from any address at that subdomain — news@, login@, hello@ — without re-verifying each one.
DNS records on Cloudflare — the exact set
Six records total. Add them in Cloudflare under DNS → Records for pintukr.in. Every SES-related record should be DNS only (grey cloud) — never proxied.
| Type | Name (host) | Value / Target | Notes |
|---|---|---|---|
| CNAME | <token-1>._domainkey.mail | <token-1>.dkim.amazonses.com | From SES · DNS only |
| CNAME | <token-2>._domainkey.mail | <token-2>.dkim.amazonses.com | From SES · DNS only |
| CNAME | <token-3>._domainkey.mail | <token-3>.dkim.amazonses.com | From SES · DNS only |
| TXT | _dmarc.mail | v=DMARC1; p=none; | Start at p=none (monitor only) |
| MX | bounce.mail | feedback-smtp.us-east-1.amazonses.com | Priority 10 · Custom MAIL FROM |
| TXT | bounce.mail | v=spf1 include:amazonses.com ~all | SPF for the MAIL FROM subdomain |
The bounce.mail pair comes from SES's Custom MAIL FROM domain setting: set MAIL FROM to bounce.mail.yourdomain.com inside the identity, then publish the MX + TXT it gives you. This aligns SPF with your own subdomain instead of Amazon's.
Trap — the Cloudflare MX form. The mail-server field takes the hostname only (feedback-smtp.us-east-1.amazonses.com) and priority goes in its own separate field (10).
Pasting a whole line like 10 feedback-smtp... "v=spf1..." into the MX box is the classic mistake — it creates a broken record and MAIL FROM stays stuck on Pending. MX and SPF are two different records: MX first, then a separate TXT for SPF.
Wait for all three greens
Back in SES, the identity should flip to:
Propagation is usually minutes on Cloudflare, but give it up to an hour before debugging. If DKIM stays Pending, re-check that all three CNAME values match SES exactly — one truncated token is enough to stall it.
SES events + IAM — bounces handle themselves
Skip this step and nothing suppresses a bad address: enough hard bounces or one spam complaint too many, and your SES reputation takes the hit. Wire it up and bounces and complaints flow back into MailCheap on their own.
1 · Create the SNS topic
- In Amazon SNS (same region,
us-east-1): create a Standard topic, e.g.mailcheap-ses-events. - Set SignatureVersion 2 on the topic — the app's webhook verification expects it.
- Copy the topic ARN for the env vars below.
2 · Create a dedicated IAM user
Create an IAM user for the app (not your root keys, ever) with a policy allowing the SES send and identity actions MailCheap needs — sending mail, and reading identity / verification status. Generate an access key for this user and store it only in Vercel env.
SES_REGION=us-east-1
SES_ACCESS_KEY_ID=AKIA...your-iam-access-key-id
SES_SECRET_ACCESS_KEY=replace-with-your-iam-secret-key
SES_AWS_ACCOUNT_ID=your-12-digit-aws-account-id
SES_SNS_TOPIC_ARN=arn:aws:sns:us-east-1:<account-id>:mailcheap-ses-events
3 · Wire SES → SNS → app
- In SES, attach the SNS topic to the identity's notifications for Bounce, Complaint and Delivery (or use a configuration set, depending on how the repo's docs wire it).
- In the app / SNS, register the HTTPS subscription to
https://your-app.vercel.app/api/webhooks/sesand confirm it. Bounces and complaints now land in the app and suppress those addresses automatically. - While you're still in the SES sandbox, verify one recipient address (your own inbox) in SES — sandbox sends only go to verified recipients.
Keep the access key out of Git, chats and screenshots. Vercel env is the only place it should exist. If it ever leaks, delete that IAM key and issue a new one — don't just rotate the app.
/api/webhooks/ses shows as confirmed, not pending — an unconfirmed subscription silently drops every bounce and complaint event.QStash + Upstash Redis
Upstash provides two things: Redis (rate limiting, small fast state) and QStash (queued / scheduled sends). Create both under the same Upstash account.
- QStash: create / open QStash and copy the token, endpoint URL, and both signing keys (
CurrentandNext— you need both for key rotation). - Redis: create a new, dedicated Redis database for MailCheap (see the trap below before reusing an old one).
- Copy the database's REST URL and REST token (not the Redis native URL).
QSTASH_TOKEN=replace-with-your-qstash-token
QSTASH_URL=https://qstash.upstash.io
QSTASH_CURRENT_SIGNING_KEY=sig_replace-with-current-key
QSTASH_NEXT_SIGNING_KEY=sig_replace-with-next-key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=replace-with-your-rest-token
Trap 1 — don't share a Redis database. I first pointed MailCheap at a Redis database that was already shared with other projects. That database got rate-limited by Upstash itself, and MailCheap's rate limiter broke with it.
Symptom: "Rate limiter unavailable" alert emails and a cryptic client-side error when you try to use the app. The fix isn't code — it's giving MailCheap its own dedicated free database. One app, one database.
Trap 2 — adding a payment card to Upstash converts the WHOLE account to Pay As You Go. Per Upstash's own billing policy, an account with a payment method cannot have free databases — your free DB silently becomes metered ($0.20 / 100K commands at the time of writing).
If you want to stay on the free plan, never add a card — not even "just to unlock free benefits." If you already did: removing the card returns databases to the Free Tier, provided your balance is $0. I learned this one live, removed the card the same morning, and the database flipped back to Free Tier with the app still healthy.
Request SES production access
Every new SES account starts in the sandbox: 200 emails/day, verified recipients only. That's fine for testing (Step 8 works in the sandbox), but you can't email real subscribers until AWS moves you to production.
Request it from the SES console (Account dashboard → Request production access). AWS will reply asking for detail — in my case via a support case asking for:
- Your app / website URL
- Email type — marketing, transactional, or both
- Expected volume — daily / monthly estimate
- How subscribers opt in (your signup form, double opt-in if you use it)
- How you handle bounces and complaints
- A sample email you'll actually send
Answer in the support case, in one complete reply. For the bounce/complaint question, your answer is Step 5: SES notifications → SNS topic → /api/webhooks/ses → automatic suppression in the app.
Submit this on day one. The decision can take up to ~96 hours, and there's nothing to speed up from your side once the case is in. Do Steps 1–6, file the request, then test in the sandbox while you wait.
Turn sending on & send your first email
- In Vercel, set
SENDING_ENABLED=trueand redeploy. - Go to
https://your-app.vercel.app/signinand sign in with your admin email. Login is passwordless — the app emails you a sign-in code. (The login email rides on the setup you just built — a decent first smoke test.) - Create your newsletter:
- From name: your name / publication name
- From email:
news@mail.pintukr.in - Reply-to: your real inbox (so replies reach a human)
- Postal address: required for a newsletter — it's printed in the footer for compliance
- Add yourself as the first contact (the same inbox you verified in Step 5, while you're in the sandbox).
- Send a test email and confirm it lands in the inbox — not spam — with the right From name and address.
SENDING_ENABLED=true
# Redeploy after changing this — env changes don't apply to running deployments.
news@ address. That's the whole stack proven end to end.That test email landing in a Gmail inbox was my end state on 9 Nov: app live, domain verified, sending working end to end. Everything after this is running a newsletter, not setting one up.
Inbound mail — receive at your @mail addresses
Out of the box, news@mail.pintukr.in and login@mail.pintukr.in are send-only. If someone replies to the wrong address or writes to them directly, it bounces into the void. Cloudflare Email Routing fixes that by forwarding those addresses to your Gmail — free.
- Verify your destination: in Cloudflare Email Routing, add your Gmail as a destination address and click the verification link Cloudflare emails you.
- Enable routing for the subdomain — dashboard (Email → Email Routing → Settings → Subdomains → add
mail) or via the API below. Cloudflare adds its MX records (route1/2/3.mx.cloudflare.net) plus an SPF TXT for the subdomain. - Create a literal rule per address — one for
news@mail.yourdomain.com, one forlogin@mail.yourdomain.com— each forwarding to your verified Gmail. There's no subdomain catch-all (see trap), so each address gets its own rule. - Test: send a mail from a account to
news@mail.pintukr.in. It should arrive in Gmail within a couple of minutes.
POST https://api.cloudflare.com/client/v4/zones/{zone_id}/email/routing/dns
Authorization: Bearer <your-api-token>
Content-Type: application/json
{"name": "mail.pintukr.in"}
An Account API token scoped to just this zone — DNS Edit + Email Routing Rules Edit + Email Routing Addresses Edit — makes the whole inbound setup scriptable, no dashboard clicking needed.
Trap — catch-all is apex-only. Cloudflare catch-all rules exist only for the apex domain, not subdomains. On mail.yourdomain.com you must route each address individually.
And never enable zone-wide routing on a domain whose MX already points at another provider (Hostinger, Google Workspace, etc.) — Cloudflare will block it (its records would replace yours) and forcing it would break that mailbox. Routing a dedicated subdomain sidesteps the whole problem — which is another reason the sending-subdomain split in Step 3 pays off.
What it actually costs
Everything except SES is a free tier. SES is the only metered piece — and it's priced per email, not per subscriber, which is the point of self-hosting it.
| Service | What for | Cost |
|---|---|---|
| Vercel | App hosting (Hobby) | $0 |
| MongoDB Atlas | Database (M0 free cluster) | $0 |
| Upstash Redis | Rate limiting — free tier: 500K commands/mo, 250 MB, 10 GB bandwidth | $0 |
| QStash | Queues / scheduled sends (free tier at small volume) | $0 |
| Cloudflare | DNS + Email Routing (inbound forwarding) | $0 |
| Amazon SES | Sending — ~$0.10 per 1,000 emails à la carte. New accounts may default to the Essentials plan at $0.16 per 1,000, no monthly fee. | ~$0.10–0.16 / 1k |
| Amazon SNS | Bounce / complaint webhooks — HTTPS deliveries | $0.60 / million |
| Worked example — 2,000 subscribers, 1 email / week (≈ 8,000 emails a month) | $0.80 – $1.30 / mo | |
≈ 8,000 emails a month. SES is the only line item that moves.
Compare that with the hosted MailCheap cloud plan at $19/month for 20,000 emails — self-hosting wins on cost the moment free tiers cover you, and it scales on pennies instead of plan jumps.
Final checklist
Run down this list before you call it done. Every item maps to a step above.
- App deployed Vercel project live,
GET /api/healthreturns{"ok":true} - Core secrets set
BETTER_AUTH_SECRET+SUPPRESSION_HASH_KEYare long, random, and different - Admin locked
ADMIN_EMAILS/ALLOWED_EMAILS= your email only - Atlas connected New project, M0 cluster, DB user created,
0.0.0.0/0allowed,MONGODB_URIset - Subdomain identity SES identity on
mail.yourdomain.com, not the apex - DKIM published All 3 CNAMEs in Cloudflare, DNS-only, values exact
- DMARC + MAIL FROM
_dmarc.mailTXT set;bounce.mailMX (priority 10) + SPF TXT set - SES all green Verified · DKIM Successful · MAIL FROM SUCCESS
- Events wired SNS topic (SignatureVersion 2) → HTTPS subscription to
/api/webhooks/sesconfirmed - IAM locked down Dedicated IAM user; keys only in Vercel env, nowhere else
- Dedicated Redis MailCheap has its own Upstash DB; QStash keys + Redis REST env set
- No card on Upstash Account still on Free Tier (adding a card converts it to Pay As You Go)
- Production requested SES case answered fully: URL, type, volume, opt-in, bounces, sample
- First send done
SENDING_ENABLED=true, newsletter created, test email in a real inbox - Inbound (optional) Cloudflare routing on the subdomain; literal rules for
news@+login@; test mail received
Next steps
- Wait on AWS, keep testing: until production access lands, stay in the sandbox and send only to verified recipients — the setup is already proven, the approval just widens who you can mail.
- Add a custom domain for the app when you're ready, and update
APP_URLto match. - Route more inbound addresses the same way as Step 9 — one literal Cloudflare rule per address on the subdomain.
- Read the source: MailCheap on GitHub (by Marc Lou) for updates to the app itself.
A closing note
Everything here is one-time setup: an afternoon, most of it clicking through consoles in the right order. The only step you don't control is the AWS production-access decision — that one is theirs, on their timeline. File it early, test in the sandbox meanwhile, and your stack is proven end to end before the approval lands.
That's the trade with self-hosting, and for a newsletter it's worth it: an afternoon of setup for a bill that stays under a coffee, and a subscriber list that lives in your own database, on your own domain.