Adds a second runtime next to Cloudflare Workers. One Node process serves the Next app, a realtime WebSocket hub, the job queues, the backup schedule and an SMTP listener; SQLite and local files replace D1 and R2. The application code is unchanged: the runtime builds an object with the same binding interfaces (`server/runtime/`) and publishes it before Next starts, so `getEnv()` hands route handlers the same shape on both platforms. Runtime (server/) - `sqlite-database.ts`: the D1 API over better-sqlite3, so Drizzle's D1 driver, the raw `env.DB.prepare` callers, the FTS5 index and its triggers all work as-is. Migrations are applied from drizzle/migrations at start, recorded in `d1_migrations` like Wrangler does. - `file-bucket.ts`: the R2 API over a directory, with metadata sidecars and byte ranges. `mailer.ts`: the send_email builder over nodemailer (any SMTP relay) or Cloudflare's Email Sending REST API; generates the Message-ID so threading matches Workers. `queue.ts`: in-process queues with the same retry policy. `realtime.ts`: per-user WebSocket fan-out in place of the Durable Object. `misc.ts`: rate limiter and ASSETS fetcher. - `smtp.ts`: inbound SMTP on port 25 via smtp-server; reject rules answer 550, forward rules relay the raw message through the outbound SMTP. - `index.ts`: the custom server; `scripts/build-server.mjs` bundles it with esbuild to dist/server.mjs. Application changes - `src/lib/runtime.ts` + `getEnv()` prefer the Node env when present. - `src/lib/email/intake.ts` factors the Worker email handler's logic so the SMTP listener and the new signed `/api/inbound` webhook share it. - Domains without Cloudflare credentials are recorded with zone id "manual": Cloudflare calls no-op, the domain is active at once, and the DNS page lists MX/SPF/DMARC records to create. Setup checks, the domains page copy and the self-update button adapt to the runtime; `/api/auth/me` exposes `runtime` and `managesDns`. - `APP_URL` overrides the origin for reset links and the JMAP session behind a proxy. The direct `getCloudflareContext` import in the update route is replaced with `getEnv()`. Deployment - Dockerfile (multi-stage, 1.1 GB, Workers toolchain pruned), compose file, `.env.docker.example`, `docs/self-hosting.md`, README section. - `deploy/cloudflare-email-relay`: a Worker that keeps MX on Cloudflare Email Routing and posts messages to the server with an HMAC signature, acting on the reject/forward decision it returns. Verified on a fresh data directory and again inside the built image: migrations apply, first-run setup and registration work with no Cloudflare credentials, mail over SMTP lands in the inbox with the raw copy on disk, a reject rule answers 550, a send through SMTP_URL loops back and is stored as sent and received, an inbound reply threads with the sent copy, FTS search finds body text, a backup completes to the volume, the WebSocket delivers a push, the relay webhook rejects a bad signature and returns store/reject decisions, JMAP serves a session with the APP_URL origin. Container healthcheck reports healthy. tsc (11) and eslint (105) unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h
4.7 KiB
Self-hosting Mailflare (Docker)
Mailflare can run as a single container on any host instead of Cloudflare Workers. The same code serves both; the container provides its own database (SQLite on a volume), blob storage (files on the same volume), job queue, realtime WebSocket, backup schedule and an SMTP listener for inbound mail.
Quick start
git clone https://github.com/hieunc229/mailflare && cd mailflare
cp .env.docker.example .env.docker # edit: how to receive and send mail
docker compose up -d --build
Open http://your-host:3000/setup, create the admin account and add your
domain. All data lives in the mailflare-data volume (/data in the
container): the SQLite database, raw messages, attachments and backups.
Behind a reverse proxy, set APP_URL=https://mail.example.com so links in
password-reset mail and the JMAP session point at the public address, and
forward WebSocket upgrades for /api/realtime.
Receiving mail
Pick one; both can be on at once.
Built-in SMTP listener (default). The container listens on port 25 and
accepts mail for every domain you add. Point the domain's MX record at the
host, set MAIL_HOSTNAME to that host's name, and make sure port 25 is
reachable from the internet (several clouds block it by default; Hetzner and
most VPS providers do not). The domain page in the app lists the MX, SPF and
DMARC records to create. Domain routing rules work as on Cloudflare: reject
rules answer the sender with a 550 during delivery, forward rules relay the
message through your outbound SMTP.
Optional: SMTP_TLS_KEY and SMTP_TLS_CERT (paths inside the container)
enable STARTTLS with your own certificate. Without them STARTTLS is not
offered, which is safe but means transport encryption depends on the sender.
Cloudflare Email Routing relay. Keep MX on Cloudflare and deploy the
Worker in deploy/cloudflare-email-relay. It posts each message to
/api/inbound on your server, signed with INBOUND_WEBHOOK_SECRET, and acts
on the reject or forward decision the server returns. Set
SMTP_INBOUND_PORT=0 if you do not want the listener at all.
Sending mail
Any SMTP relay. SMTP_URL=smtps://user:pass@host:465 (or smtp://
with STARTTLS). Works with your hosting provider's relay, Amazon SES,
Postmark, Mailgun, or a Postfix you run. Set
SMTP_TLS_REJECT_UNAUTHORIZED=false only for a relay with a self-signed
certificate on a private network.
Cloudflare Email Sending. CF_ACCOUNT_ID plus a CF_TOKEN with Email
Sending: Edit. The domain must be a Cloudflare zone with Email Sending set
up; Mailflare calls the REST API, no Workers plan needed.
Cloudflare zone management (optional)
If CF_TOKEN can also edit DNS and Email Routing on your zones, adding a
domain configures Email Routing and the sending subdomain automatically,
exactly as on Workers. Without it, domains are recorded as manually managed
and the DNS page shows what to set by hand.
Configuration reference
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
HTTP port |
DATA_DIR |
/data |
SQLite database, blobs and backups |
APP_URL |
request origin | Public URL behind a proxy |
SMTP_INBOUND_PORT |
25 |
Inbound SMTP; 0 disables |
MAIL_HOSTNAME |
mail.<domain> |
Host the MX record points at; SMTP banner |
SMTP_MAX_SIZE |
25 MiB | Largest inbound message |
SMTP_TLS_KEY, SMTP_TLS_CERT |
unset | STARTTLS certificate for the listener |
SMTP_URL |
unset | Outbound relay |
SMTP_TLS_REJECT_UNAUTHORIZED |
true |
Trust self-signed relay certificates when false |
CF_ACCOUNT_ID, CF_TOKEN |
unset | Cloudflare Email Sending, and zone management if the token allows |
INBOUND_WEBHOOK_SECRET |
unset | Enables /api/inbound for the relay Worker |
TURNSTILE_SECRET_KEY |
unset | Bot protection on login and reset forms (NEXT_PUBLIC_TURNSTILE_SITE_KEY at build time) |
Operations
- Backups. The daily 02:00 UTC backup and the admin Backups page work
unchanged; files land under
/data/blobs/backups. Back up the whole volume for a full copy. - Updates. Pull the new image and recreate the container; migrations run at start. The in-app update button is disabled on self-hosted installs.
- Logs.
docker compose logs -f mailflare. - Queues. Jobs are held in memory. Inbound mail is written to the volume before it is queued, so a restart never loses a message; at worst one stays unparsed until it is re-imported.
Running without Docker
npm ci
npm run build:node
MAILFLARE_RUNTIME=node NODE_ENV=production DATA_DIR=./data node dist/server.mjs
Port 25 needs root or a capability (setcap cap_net_bind_service=+ep); use
SMTP_INBOUND_PORT=2525 behind a port forward otherwise.