A self-hosted webmail on Next.js does not have to mean running a mail server. The hard, risky part of email — accepting SMTP connections from the open internet, fighting spam, keeping an IP's reputation clean — can be handed to infrastructure that already does it well. What is left is small enough to own: a Cloudflare Email Worker catches incoming mail and hands it to my app, a transactional email provider sends my replies, a plain file-based store holds the messages, and a Next.js App Router frontend ties it together, all running in a single Docker container on a VPS behind Cloudflare. This is the mail hub that lives at /mail on this very site, and it is built for exactly one user: me.
I want to walk through why a single-user design is a feature rather than a compromise, how the pieces fit at an architecture level, and what a year of running my own mail taught me that no managed inbox ever did. I will stay deliberately at the architecture level — the parts that keep the thing secure are not interesting to read about and are not the point.
Key takeaways
- You do not need an SMTP server. Inbound mail arrives through a Cloudflare Email Worker; outbound goes through a transactional email API. The reputation-sensitive plumbing is someone else's job.
- A single-user design removes entire subsystems. No account model, no tenancy, no per-user quotas. The data model is "my mail," which is a radically smaller thing to build and secure.
- A file-based store is enough for one person's email. Messages on disk, indexed in memory, are simple to reason about, trivial to back up, and fast at this scale.
- Next.js App Router is a good fit for a mail client. Server components render the list and the reading pane; a couple of route handlers are the whole API surface.
- Self-hosting teaches you where email's real complexity lives — and how much of it you can decline to own.
Why self-host a single-user mail hub at all
The honest answer is control and understanding, not cost. A managed inbox is cheaper in hours than anything you build. What you get by building is a mail client that does exactly what you want, stores your mail where you can see it, and has no features you did not ask for. For a developer who wants to own their primary identity — their domain, their address, their archive — that is worth a weekend or three.
The decision that makes the whole project tractable is scoping it to one user. The moment a mail system has to serve more than one person, it grows an account model, authentication per account, tenancy isolation so one user cannot read another's mail, per-user quotas, and an admin surface to manage all of it. Those subsystems are most of the work and most of the risk in any multi-tenant product. When the answer to "how many users?" is "one, forever," every one of them disappears. The data model collapses to "my mail," and the security model collapses to "it is either me, or it is not."
That instinct — delete the problem instead of solving it — is the same one behind the feature, core and data module boundaries I wrote about for Android apps: the cheapest code to maintain is the code you never had to write.
The architecture at a glance
Mail flows in two directions, and the design treats them as two separate pipelines that happen to share a store.
Inbound: a message sent to my domain hits Cloudflare's email infrastructure, which routes it to an Email Worker. The Worker parses the envelope and hands the raw message to an HTTP endpoint on my Next.js app. The app writes it to the store.
Outbound: when I send a reply from the UI, the app composes a MIME message and hands it to a transactional email provider over its API. The provider does the actual SMTP delivery and carries the sender reputation.
Everything in the middle — the store, the UI, the endpoints — is a single Next.js application in one Docker container, sitting on a VPS with Cloudflare in front of it as the TLS terminator and proxy. There is no separate database service, no message queue, no mail daemon. The small surface area is the point.
Inbound mail: a Cloudflare Email Worker as the front door
Cloudflare Email Routing lets you point a domain's mail at a Worker instead of at a mailbox. The Worker exports an email() handler that receives each message as it arrives. The handler gets a message object whose raw property is a ReadableStream of the full MIME content, along with envelope fields like from and to. I read that stream once, buffer it, and fetch() it to my app:
export default {
async email(message, env, ctx) {
// raw is a single-use ReadableStream of the full MIME message.
const mime = await new Response(message.raw).arrayBuffer();
await fetch(env.INBOUND_ENDPOINT, {
method: "POST",
headers: {
"content-type": "message/rfc822",
"x-envelope-from": message.from,
"x-envelope-to": message.to,
// The Worker authenticates itself to the endpoint here.
// How it proves it is the real Worker stays out of this post.
},
body: mime,
});
},
};
Two things are worth saying out loud. First, the raw stream is single-use, so you read it exactly once — buffer it if you need both the bytes and the metadata. Second, that endpoint is exposed to the internet, so the Worker has to prove to the app that a request genuinely came from the Worker and not from anyone who found the URL. That verification exists and matters; the mechanics of it are exactly the kind of thing I will not publish. Treat "the endpoint authenticates its caller" as a requirement, design it carefully, and move on.
The app side is an ordinary App Router route handler. Route handlers give you the Web Request/Response APIs, and for a raw MIME body you reach for request.text() (or the raw bytes) rather than request.json():
// app/api/inbound/route.ts
export async function POST(request: Request) {
// Verify the caller is the Worker before doing anything else.
await assertTrustedSender(request);
const raw = await request.text();
const parsed = await parseMime(raw); // headers, parts, attachments
await store.save(parsed);
return new Response(null, { status: 204 });
}
The parsing is where incoming mail reveals how messy the format really is — nested multipart bodies, character-set declarations that lie, attachments encoded three different ways. I lean on a well-tested MIME parser rather than writing my own, because this is the one place where "roll your own" is a trap.
Outbound mail: a transactional email provider
Sending is the half that most people underestimate. Getting a message delivered to a stranger's inbox and not their spam folder depends on SPF, DKIM and DMARC alignment, on the sending IP's reputation, and on years of that IP behaving well. A fresh VPS IP has no reputation, which in practice means your mail is treated as suspicious from day one.
So I do not send mail myself. The app composes the message and hands it to a transactional email provider's API; the provider delivers it from IPs that are already trusted and signs it with DKIM for my domain. From the app's perspective, sending is one authenticated HTTPS call. All the reputation management I would otherwise have to earn and defend is rented. This is the same trade as the inbound side: let infrastructure that is good at the adversarial, internet-facing part own that part.
The message store: plain files on disk
One person's email is not big data. Even years of mail is a few gigabytes, dominated by attachments. For that scale, a plain file-based store is not a shortcut — it is the right tool. Each message is a file; folders are directories; a lightweight index built at startup (and kept warm in memory) answers the queries the UI needs. There is no database server to run, patch or back up separately.
The operational payoff is backups. Because the whole store is just files, snapshotting it is a filesystem job, which slots straight into the kind of encrypted, generational backup rotation any VPS ought to have. When your data is files, every boring, battle-tested file tool already works on it.
The limits are real and I accept them knowingly: full-text search over flat files needs its own index, concurrent writers would need coordination, and none of this would survive being pointed at a thousand mailboxes. But it is built for one mailbox, so none of those limits bite.
The UI: Next.js App Router
The frontend is where self-hosting stops being plumbing and starts being a product I use every day. Next.js App Router suits a mail client almost suspiciously well. Server components render the folder list and the message-reading pane directly from the store on the server, so the heavy lifting never ships to the browser. The interactive parts — composing, selecting, the keyboard shortcuts I cannot live without — are client components. The entire backend "API" is a handful of route handlers: receive inbound mail, send a message, move or delete one.
Because the data lives next to the app, a message list is a server component reading the in-memory index — no client-side fetch waterfall, no loading spinners for the common case. The reading pane renders sanitized HTML on the server. The result feels less like a web app calling an API and more like a local program that happens to have a browser for a window.
Running it: Docker on a VPS behind Cloudflare
Deployment is intentionally dull. The app is a single Docker image. On the VPS it runs as one container, and Cloudflare sits in front as the proxy and TLS terminator, so the origin only ever speaks to Cloudflare. A docker-compose.yml with one service, a mounted volume for the mail store, and an environment file for configuration is the whole deployment description:
services:
webmail:
image: webmail:latest
restart: unless-stopped
volumes:
- ./maildata:/data/mail # the file-based store, persisted
env_file: .env
ports:
- "127.0.0.1:3000:3000" # only Cloudflare reaches this, via the proxy
Binding the published port to 127.0.0.1 matters: the container is not exposed to the public internet directly, only through the proxy. The mail store is a mounted volume so that redeploying the image never touches the data. This is the same security-boundary thinking I apply on Android in SecureCheck, my toolkit for real-world mobile threats: decide where the trust boundary is, then make sure nothing crosses it by accident.
The trade-offs of designing for exactly one user
Single-user is liberating, but it is a design decision with consequences you should take on deliberately, not by accident.
What you gain is enormous: no account system, no tenant isolation, no per-user configuration, no admin console, a trivial data model, and a security model you can hold in your head. What you give up is everything that assumes more than one person. There is no sharing, no delegation, no "add a colleague." Scaling is vertical only — a bigger box, not more boxes — and for one mailbox that ceiling is so high it is irrelevant. The one risk you must respect is that with a single user there is no second administrator and no safety net: if you lock yourself out or lose the store, no one restores it for you. That is why the unglamorous part — backups and recovery — is the part I spent the most care on.
What self-hosting taught me
The biggest lesson is how much of email's difficulty is social and adversarial rather than technical. Parsing MIME is fiddly but finite. Earning the right to deliver mail to strangers is a years-long reputation game, and the correct engineering move is to not play it — to rent deliverability and inbound routing from infrastructure that already won. Owning the parts that are genuinely mine, and renting the parts that are an arms race, is the whole design in one sentence.
The second lesson is that constraints are a gift. "One user, forever" deleted more code and more risk than any clever abstraction could have added. That is the same lever I reach for when I pull cross-cutting concerns out of business logic with Aspectify, my annotation-driven AOP framework: the best way to make something simpler to maintain is to have less of it to maintain. A mail hub for one person, built on infrastructure that handles the hard internet-facing parts, is about as little as a working webmail can be — and running my own mail every day since has been worth every hour.