Files
mailflare/docs/self-hosting.md
T
Oday BakkourandClaude Fable 5.1 f0dc5a16fc feat(self-host): run Mailflare as a Docker container on any host
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
2026-09-12 14:34:11 +03:00

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.