Skip to content

Manual Installation

Ra's al Ghul edited this page Aug 9, 2026 · 1 revision

This advanced guide shows the commands behind npm run setup. Prefer the interactive setup for new installations because it generates secure defaults, validates choices, protects configuration permissions, and avoids printing generated secrets.

This manual example sets up Mailbridge with:

  • Inbound mail through Cloudflare Email Routing, encrypted R2 storage, Cloudflare Queues, and delivery to your local SMTP server.
  • Outbound mail through the Mailbridge SMTP relay on port 2525.
  • Cloudflare Email Service as the outbound provider.
  • An unauthenticated SMTP relay restricted by your trusted network CIDR.

Warning

The SMTP relay in this setup does not require a username or password. Any client connecting from an allowed CIDR can relay mail through Mailbridge. Set SMTP_RELAY_ALLOWED_CIDRS narrowly and do not expose port 2525 to the public internet.

Prerequisites

Before starting, you need:

  • Docker with Docker Compose.
  • Node.js and npx for Wrangler.
  • A Cloudflare account with Email Routing, Workers, R2, Queues, and Email Service available.
  • A local SMTP server that will accept inbound mail from Mailbridge.
  • A public hostname routed to Mailbridge port 3090, through a Cloudflare Tunnel as well as the connection token.

Run all commands from the Mailbridge repository root.

1. Set deployment variables

Edit these values for your deployment:

export MAILBRIDGE_HOSTNAME="mailbridge.example.com"
export WORKER_NAME="mailbridge-worker"

export LOCAL_MAIL_HOST="mail.internal.example"
export LOCAL_MAIL_PORT="25"

export RELAY_FROM_FALLBACK="postmaster@example.com"

# Set this to the trusted network permitted to use the unauthenticated SMTP relay.
# Examples:
#   Single application host: 10.0.10.25/32
#   Trusted LAN subnet:      192.168.1.0/24
export SMTP_RELAY_ALLOWED_CIDRS="192.168.1.0/24,127.0.0.1/32,::1/128"

export R2_BUCKET_NAME="mailbridge-inbound"
export QUEUE_NAME="mailbridge-inbound"

# Mailbridge will send outbound relay requests to this Worker endpoint.
export WORKER_SEND_URL="https://${WORKER_NAME}.example.workers.dev/api/send/email"

# Set true only when this Mailbridge container should run cloudflared itself.
export CLOUDFLARED_ENABLED="true"
export CLOUDFLARED_TUNNEL_TOKEN="YOUR_CONNECTION_TOKEN_FROM_CF"

For Docker Desktop on macOS, SMTP connections originating from the host may appear to the container from a Docker Desktop subnet such as 192.168.65.0/24. Add it only when needed:

export SMTP_RELAY_ALLOWED_CIDRS="192.168.1.0/24,192.168.65.0/24,127.0.0.1/32,::1/128"

2. Generate secrets and encryption keys

Create the local runtime directories:

mkdir -p data/queue secrets

Generate the Mailbridge queue encryption key and webhook secrets:

export QUEUE_MASTER_KEY="$(openssl rand -base64 32 | tr -d '\n')"
export WEBHOOK_SECRET="$(openssl rand -base64 48 | tr -d '\n')"
export CLOUDFLARE_SEND_WEBHOOK_SECRET="$(openssl rand -base64 48 | tr -d '\n')"

Generate the RSA keypair used to encrypt inbound mail before it is stored in R2:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
  -out secrets/mailbridge-r2-private.pem

openssl rsa -pubout \
  -in secrets/mailbridge-r2-private.pem \
  -out secrets/mailbridge-r2-public.pem

chmod 600 secrets/mailbridge-r2-private.pem

The private key must remain on the Mailbridge host. Only the public key is uploaded to the Cloudflare Worker.

3. Write the Mailbridge .env file

This configuration enables the unauthenticated SMTP relay on port 2525. Relay access is restricted by SMTP_RELAY_ALLOWED_CIDRS.

cat > .env <<EOF
# Node App Configuration
PORT=3090
SMTP_RELAY_PORT=2525
SMTP_RELAY_SOCKET_TIMEOUT_MS=120000
SMTP_RELAY_MAX_MESSAGE_BYTES=52428800
MAILBRIDGE_VERBOSE_LOGGING=true
MAILBRIDGE_HOSTNAME=${MAILBRIDGE_HOSTNAME}
QUEUE_MAX_ATTEMPTS=20
DATA_DIR=/app/data
SECRETS_DB_PATH=/app/secrets/secrets.db
QUEUE_MASTER_KEY=${QUEUE_MASTER_KEY}
MAILBRIDGE_PRIVATE_KEY_PATH=/app/secrets/mailbridge-r2-private.pem
AUDIT_LOG_RETENTION_DAYS=1

# Optional in-container Cloudflare Tunnel
CLOUDFLARED_ENABLED=${CLOUDFLARED_ENABLED}
CLOUDFLARED_TUNNEL_TOKEN=${CLOUDFLARED_TUNNEL_TOKEN}
CLOUDFLARED_LOGLEVEL=info

# Inbound Worker -> Mailbridge authentication
WEBHOOK_SECRET=${WEBHOOK_SECRET}

# Local Mail Server Configuration
LOCAL_MAIL_HOST=${LOCAL_MAIL_HOST}
LOCAL_MAIL_PORT=${LOCAL_MAIL_PORT}
LOCAL_MAIL_SECURE=false
LOCAL_MAIL_REQUIRE_TLS=false
LOCAL_MAIL_TLS_REJECT_UNAUTHORIZED=false
LOCAL_MAIL_TLS_SERVERNAME=
LOCAL_MAIL_TLS_CA_FILE=

# Outbound Relay Provider: Cloudflare Email Service
RELAY_UPSTREAM_PROVIDER=cloudflare
RELAY_API_KEY=
RELAY_FROM_FALLBACK=${RELAY_FROM_FALLBACK}
RESEND_BASE_URL=https://api.resend.com
MAILGUN_DOMAIN=
MAILGUN_BASE_URL=https://api.mailgun.net
CLOUDFLARE_SEND_WORKER_URL=${WORKER_SEND_URL}
CLOUDFLARE_SEND_WEBHOOK_SECRET=${CLOUDFLARE_SEND_WEBHOOK_SECRET}

# Unauthenticated SMTP Relay
SMTP_RELAY_ENABLED=true
SMTP_RELAY_VERBOSE_LOGGING=true
SMTP_RELAY_INJECT_HEADERS=true
SMTP_RELAY_REQUIRE_TLS=false
SMTP_RELAY_ALLOW_INSECURE=true
SMTP_RELAY_ALLOWED_CIDRS=${SMTP_RELAY_ALLOWED_CIDRS}
SMTP_RELAY_TLS_CERT_FILE=
SMTP_RELAY_TLS_KEY_FILE=
SMTP_RELAY_TLS_CA_FILE=

# SpamAssassin
SPAMASSASSIN_MODE=local
POSTMARK_SPAMCHECK_URL=https://spamcheck.postmarkapp.com/filter
SPAMD_HOST=127.0.0.1
SPAMD_PORT=783
SPAMD_STARTUP_ATTEMPTS=30
SPAMC_TIMEOUT_MS=10000
SPAMC_FAIL_OPEN=false
SA_BLOCK_THRESHOLD=12
SA_QUESTIONABLE_THRESHOLD=5
SPAM_SCL_SCORE=9
SPAM_SUBJECT_TAG=[SPAM]

# Optional Spamhaus checks
SPAMHAUS_ENABLED=false
SPAMHAUS_USERNAME=
SPAMHAUS_PASSWORD=
SPAMHAUS_FAIL_OPEN=true

# Optional AI screening
AI_ENABLED=false
AI_API_KEY=
AI_MODEL=gpt-5.4-nano
AI_BASE_URL=
AI_INPUT_SCOPE=headers
AI_MAX_INPUT_CHARS=20000
EOF

For production local mail delivery, replace the local SMTP TLS settings with verified TLS whenever your internal SMTP server supports it:

LOCAL_MAIL_REQUIRE_TLS=true
LOCAL_MAIL_TLS_REJECT_UNAUTHORIZED=true

4. Write wrangler.toml

Create the Worker configuration:

cat > wrangler.toml <<EOF
name = "${WORKER_NAME}"
main = "worker.js"
compatibility_date = "2026-05-19"
preview_urls = false

[vars]
NODE_APP_URL = "https://${MAILBRIDGE_HOSTNAME}/api/webhook/email"
MAIL_STORE_ENCRYPTION_VERSION = "v1"

[[r2_buckets]]
binding = "MAIL_STORE"
bucket_name = "${R2_BUCKET_NAME}"

[[queues.producers]]
binding = "MAIL_QUEUE"
queue = "${QUEUE_NAME}"

[[queues.consumers]]
queue = "${QUEUE_NAME}"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 3

[[send_email]]
name = "EMAIL"
EOF

The [[send_email]] binding must remain top-level. It enables the Worker to send outbound messages through Cloudflare Email Service.

5. Log in to Wrangler

Authenticate Wrangler with Cloudflare:

npx wrangler login

6. Create Cloudflare resources

Create the private R2 bucket used for encrypted inbound mail storage:

npx wrangler r2 bucket create "$R2_BUCKET_NAME" || true

Create the Cloudflare Queue used for inbound delivery:

npx wrangler queues create "$QUEUE_NAME" || true

7. Upload Worker secrets

Upload the shared webhook secret used for inbound Worker-to-Mailbridge requests:

printf '%s' "$WEBHOOK_SECRET" | \
  npx wrangler secret put WEBHOOK_SECRET --name "$WORKER_NAME"

Upload the secret used for Mailbridge-to-Worker outbound sending:

printf '%s' "$CLOUDFLARE_SEND_WEBHOOK_SECRET" | \
  npx wrangler secret put CLOUDFLARE_SEND_WEBHOOK_SECRET --name "$WORKER_NAME"

Upload the public key used by the Worker to encrypt inbound mail stored in R2:

cat secrets/mailbridge-r2-public.pem | \
  npx wrangler secret put MAILBRIDGE_PUBLIC_KEY_PEM --name "$WORKER_NAME"

Do not upload secrets/mailbridge-r2-private.pem.

8. Deploy the Worker

Deploy the Cloudflare Worker:

npx wrangler deploy

After deployment, Wrangler should show bindings for:

env.MAIL_QUEUE
env.MAIL_STORE
env.NODE_APP_URL
env.MAIL_STORE_ENCRYPTION_VERSION
env.EMAIL

If env.EMAIL is missing, verify that wrangler.toml contains:

[[send_email]]
name = "EMAIL"

Update WORKER_SEND_URL in .env if your actual deployed Worker URL differs from the value originally exported:

nano .env

Set:

CLOUDFLARE_SEND_WORKER_URL=https://your-deployed-worker.workers.dev/api/send/email

9. Configure Cloudflare Email Routing

In the Cloudflare dashboard:

  1. Open Email Routing for the domain receiving mail.
  2. Create or edit the desired email route.
  3. Choose the Mailbridge Worker as the destination.
  4. Save the route.

Inbound mail will now follow this path:

Sender
-> Cloudflare Email Routing
-> Mailbridge Worker
-> encrypted R2 object
-> Cloudflare Queue
-> Mailbridge webhook
-> SpamAssassin
-> local SMTP server

10. Publish the Mailbridge webhook

Mailbridge must be reachable from the Worker at:

https://mailbridge.example.com/api/webhook/email

When using Cloudflare Tunnel, route the public hostname to:

http://localhost:3090

If the Mailbridge container should run cloudflared, edit .env:

CLOUDFLARED_ENABLED=true
CLOUDFLARED_TUNNEL_TOKEN=your_tunnel_token

If cloudflared is already running on the host or elsewhere, leave the container tunnel disabled.

11. Start Mailbridge

The repository Compose configuration publishes:

  • 3090 for the Mailbridge HTTP webhook.
  • 2525 for the SMTP relay.

Start the service:

docker compose up -d --build

Confirm that it is running:

docker compose ps

Follow logs:

docker logs -f mail-bridge

12. Test the HTTP webhook endpoint

Test locally:

curl -i http://127.0.0.1:3090/api/webhook/email

A webhook authentication, validation, or method response means Mailbridge is reachable. A connection-refused error means the container is not listening.

Test through the public hostname:

curl -i "https://${MAILBRIDGE_HOSTNAME}/api/webhook/email"

A webhook authentication, validation, or method response means the public route is reaching Mailbridge.

13. Test inbound mail delivery

Send an email to an address configured in Cloudflare Email Routing.

Watch the Mailbridge logs:

docker logs -f mail-bridge

Successful processing should show inbound webhook acceptance, spam scoring, and delivery to the local SMTP server.

14. Test the unauthenticated SMTP relay

Only run this test from a host inside SMTP_RELAY_ALLOWED_CIDRS.

Install swaks if needed:

brew install swaks

Send an outbound test message through Mailbridge:

swaks \
  --server 127.0.0.1 \
  --port 2525 \
  --from "${RELAY_FROM_FALLBACK}" \
  --to "recipient@example.net" \
  --header "Subject: Mailbridge SMTP relay test" \
  --body "This message was relayed through Mailbridge using Cloudflare Email Service."

Because this quick start configures an unauthenticated relay, the client does not provide SMTP credentials. Mailbridge permits the request only when the client IP is within:

SMTP_RELAY_ALLOWED_CIDRS=

Successful logs should show Mailbridge accepting the SMTP transaction and submitting the message through the configured Cloudflare outbound Worker endpoint.

Security Checklist

Before using this deployment beyond local testing:

  • Replace example hostnames and sender addresses.
  • Set SMTP_RELAY_ALLOWED_CIDRS to the smallest practical trusted network or individual host CIDR.
  • Do not allow 0.0.0.0/0 or ::/0.
  • Do not expose SMTP port 2525 publicly.
  • Keep .env out of source control.
  • Keep secrets/mailbridge-r2-private.pem only on the Mailbridge host.
  • Keep QUEUE_MASTER_KEY, WEBHOOK_SECRET, and CLOUDFLARE_SEND_WEBHOOK_SECRET private.
  • Use verified TLS for local SMTP delivery when available.
  • Use SMTP relay TLS when trusted clients and deployment networking support it.

Clone this wiki locally