Pintu Kumar · Self-hosting guide

Tutorial · Self-hosting a newsletter app

I self-hosted my own newsletter app (MailCheap) — the full step-by-step guide

MailCheap is Marc Lou's open-source newsletter app (github.com/marclou/mailcheap): Next.js + MongoDB, billed per email sent through your own Amazon SES account, not per subscriber. The hosted version costs $19/month for 20,000 emails. Self-hosted, it runs on free tiers plus a few cents of SES. Here is the setup I ran on 9 Nov 2026, end to end.

Where you end up

App live

MailCheap running on Vercel, health check green, sign-in working.

Domain verified

mail.pintukr.in verified in SES — DKIM & MAIL FROM passing.

Sending works

First test email delivered to a real Gmail inbox, bounces handled.

Inbox too

Optional: news@ & login@ forward to Gmail via Cloudflare.

Worked example throughout: mail.pintukr.in — a dedicated sending subdomain of pintukr.in, DNS on Cloudflare. Swap in your own domain everywhere you see it. No real secrets appear in this guide — every key value is a placeholder.

What you need

Six accounts, all free to start. The only metered piece in the stack is Amazon SES, and it charges per email.

A domain on Cloudflare

You'll add DKIM, DMARC, SPF and MX records. My DNS lives on Cloudflare for pintukr.in.

GitHub account

To clone / fork marclou/mailcheap and connect it to Vercel.

Vercel Hobby · $0

Hosts the Next.js app. Hobby tier is enough at newsletter scale.

MongoDB Atlas M0 · $0

Stores subscribers, newsletters, sends and suppressions.

AWS account — for SES

Amazon Simple Email Service does the actual sending. Region used here: us-east-1.

Upstash Free

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.

1

Deploy the app to Vercel

~10 min

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.

  1. Fork or clone github.com/marclou/mailcheap to your GitHub.
  2. In Vercel: New Project → import the repo. Framework preset should detect Next.js automatically. Don't deploy yet — add env vars first.
  3. Generate two long random secrets (one for auth, one for the suppression hash). On macOS/Linux: openssl rand -hex 32 — run it twice.
  4. Add the core env vars below, then deploy.
Vercel · Environment Variables
# 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:

Health check
GET https://your-app.vercel.app/api/health

# Expected response
{"ok": true}
Note

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.

2

MongoDB Atlas — free cluster

~8 min

MailCheap stores everything in MongoDB. The free M0 cluster is plenty to start.

  1. 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.
  2. Create a free M0 cluster in the region nearest your readers (I used the closest available to India / my Vercel region).
  3. Database Access: create a database user with readWriteAnyDatabase. Use a strong, unique password — you'll paste it into the connection string once.
  4. Network Access: allow 0.0.0.0/0. Vercel serverless functions have no fixed outbound IP, so an IP allowlist will break in production.
  5. Copy the connection string into Vercel as MONGODB_URI and redeploy.
Vercel · Add after Atlas is ready
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.

Check: after the redeploy, 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.
3

Amazon SES — create the domain identity

~5 min

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.

  1. Open Amazon SES in region us-east-1 (the region matters — your DNS, IAM and env must all match it).
  2. Identities → Create identity → Domain → enter mail.yourdomain.com (mine: mail.pintukr.in).
  3. Enable Easy DKIM with RSA 2048.
  4. SES will show you 3 CNAME records to publish. Keep that tab open — you'll need them in the next step.
Note

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.

4

DNS records on Cloudflare — the exact set

~10 min + propagation

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.

TypeName (host)Value / TargetNotes
CNAME<token-1>._domainkey.mail<token-1>.dkim.amazonses.comFrom SES · DNS only
CNAME<token-2>._domainkey.mail<token-2>.dkim.amazonses.comFrom SES · DNS only
CNAME<token-3>._domainkey.mail<token-3>.dkim.amazonses.comFrom SES · DNS only
TXT_dmarc.mailv=DMARC1; p=none;Start at p=none (monitor only)
MXbounce.mailfeedback-smtp.us-east-1.amazonses.comPriority 10 · Custom MAIL FROM
TXTbounce.mailv=spf1 include:amazonses.com ~allSPF 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

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:

Domain — Verified DKIM — Successful MAIL FROM — SUCCESS

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.

Check: don't move on until all three badges above read green — Verified, DKIM Successful, MAIL FROM SUCCESS. Sending before that lands in spam or fails outright.
5

SES events + IAM — bounces handle themselves

~12 min

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

  1. In Amazon SNS (same region, us-east-1): create a Standard topic, e.g. mailcheap-ses-events.
  2. Set SignatureVersion 2 on the topic — the app's webhook verification expects it.
  3. 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.

Vercel · SES env vars
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

  1. 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).
  2. In the app / SNS, register the HTTPS subscription to https://your-app.vercel.app/api/webhooks/ses and confirm it. Bounces and complaints now land in the app and suppress those addresses automatically.
  3. While you're still in the SES sandbox, verify one recipient address (your own inbox) in SES — sandbox sends only go to verified recipients.
Note

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.

Check: the SNS subscription to /api/webhooks/ses shows as confirmed, not pending — an unconfirmed subscription silently drops every bounce and complaint event.
6

QStash + Upstash Redis

~8 min

Upstash provides two things: Redis (rate limiting, small fast state) and QStash (queued / scheduled sends). Create both under the same Upstash account.

  1. QStash: create / open QStash and copy the token, endpoint URL, and both signing keys (Current and Next — you need both for key rotation).
  2. Redis: create a new, dedicated Redis database for MailCheap (see the trap below before reusing an old one).
  3. Copy the database's REST URL and REST token (not the Redis native URL).
Vercel · Upstash env vars
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

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

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.

7

Request SES production access

Submit ~10 min · Decision up to ~96 hrs

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.

Timing

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.

8

Turn sending on & send your first email

~10 min
  1. In Vercel, set SENDING_ENABLED=true and redeploy.
  2. Go to https://your-app.vercel.app/signin and 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.)
  3. 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
  4. Add yourself as the first contact (the same inbox you verified in Step 5, while you're in the sandbox).
  5. Send a test email and confirm it lands in the inbox — not spam — with the right From name and address.
Vercel · Final switch
SENDING_ENABLED=true
# Redeploy after changing this — env changes don't apply to running deployments.
Check: the test email sits in the inbox — not spam — showing your From name and your 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.

9

Inbound mail — receive at your @mail addresses

~10 min

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.

  1. Verify your destination: in Cloudflare Email Routing, add your Gmail as a destination address and click the verification link Cloudflare emails you.
  2. 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.
  3. Create a literal rule per address — one for news@mail.yourdomain.com, one for login@mail.yourdomain.com — each forwarding to your verified Gmail. There's no subdomain catch-all (see trap), so each address gets its own rule.
  4. Test: send a mail from a account to news@mail.pintukr.in. It should arrive in Gmail within a couple of minutes.
Cloudflare API · Enable subdomain routing
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

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.

ServiceWhat forCost
VercelApp hosting (Hobby)$0
MongoDB AtlasDatabase (M0 free cluster)$0
Upstash RedisRate limiting — free tier: 500K commands/mo, 250 MB, 10 GB bandwidth$0
QStashQueues / scheduled sends (free tier at small volume)$0
CloudflareDNS + Email Routing (inbound forwarding)$0
Amazon SESSending — ~$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 SNSBounce / 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
2,000 subscribers · 1 email / week

≈ 8,000 emails a month. SES is the only line item that moves.

Total / month$0.80 – $1.30

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/health returns {"ok":true}
  • Core secrets set BETTER_AUTH_SECRET + SUPPRESSION_HASH_KEY are 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/0 allowed, MONGODB_URI set
  • 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.mail TXT set; bounce.mail MX (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/ses confirmed
  • 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_URL to 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.

PK

Written by Pintu Kumar

Full-stack developer (React, TypeScript, Node.js) based in Mumbai. I build and self-host the tools I write about — this setup is live and sending. More at pintukr.in · Code: MailCheap on GitHub (by Marc Lou).