# Relaying a self-hosted mail server through Cloudflare

> A personal mail server can receive on its own, but sending needs a relay with a reputation. Mine was rewriting my links and adding tracking pixels. Cloudflare now sells a plain SMTP relay on the five-dollar Workers plan, and the cutover is one token, six postconf lines, and a bounce domain Cloudflare maintains for you.

- Author: JR (@dniminenn)
- Published: 2026-09-12
- Category: Systems debugging
- Tags: email, postfix, cloudflare, dkim, self-hosting
- Canonical: https://dnim.dev/blog/mail-relay-cloudflare-email-service

---

I run my own mail server. Receiving is the easy half: an MX record, a box on a static address, Postfix and Dovecot, a spam filter, done. Sending is the half that never stops being a problem, and the reason is not technical. Every large receiver scores mail by the reputation of the IP that hands it over, and reputation is earned in millions of messages per month. A personal server sends a few hundred a year. Its IP will never have a reputation, so its mail goes to spam, and the accepted fix is to hand outbound to a relay that does have one.

For years mine was Mailjet. It worked, and it did something I never asked for: every link in every message I sent got rewritten through their click-tracking redirector, and an invisible open-tracking pixel got appended to the body. That is a default on their SMTP relay, not a feature you turn on. A friend clicking a link I sent them was being logged by a third party, and the URL they saw in the status bar was not the one I typed. On a relay I paid for, to send my own mail, with my own domain in the From header.

I went looking for a relay that passes bytes untouched, and found Cloudflare selling exactly that.

## Why not just send direct

Oracle Cloud, where the box lives, is usually assumed to block outbound port 25. It does not, at least not for this instance:

```bash
for mx in gmail-smtp-in.l.google.com aspmx.l.google.com; do
  timeout 10 bash -c "echo > /dev/tcp/$mx/25" && echo "$mx:25 reachable"
done
# gmail-smtp-in.l.google.com:25 reachable
# aspmx.l.google.com:25 reachable
```

The escape hatch exists but does not help. Direct delivery from an IP with no history and a cloud provider's ASN is the exact profile Gmail and Outlook throttle or junk on sight, and there is no volume I could send that would change that.

## Cloudflare Email Service

Cloudflare has had inbound Email Routing for years. Sending is new: Email Service, in beta, included with the Workers Paid plan. The part that matters for a mail server is that it speaks real authenticated SMTP, not just a Workers binding:

- Endpoint `smtp.mx.cloudflare.net:465`, implicit TLS.
- Username is the literal string `api_token`. Password is a Cloudflare API token with the Email Sending permission.
- 3,000 messages a month included on the five-dollar plan, then $0.35 per thousand. I send a few hundred messages a year.
- No link rewriting, no tracking pixels. The message that goes in is the message that comes out.
- One rule: the MAIL FROM domain must be onboarded for Email Sending on the account that owns the token, or the relay answers `550 5.7.1 Sender denied`.

Phone or laptop submits to Postfix on 587. Postfix relays to Cloudflare on 465 with the token. Cloudflare signs it twice, once with its own key and once with a key under your domain, and hands it to the recipient's MX from an IP with a real reputation. Bounces come back to a subdomain Cloudflare owns the MX for.

## 1. Onboard the sending domain

Dashboard step, documented in Cloudflare's [Send emails guide](https://developers.cloudflare.com/email-service/get-started/send-emails/#set-up-your-domain): Compute, Email Service, Email Sending, Onboard Domain, pick a zone on your account. Cloudflare writes the DNS records itself, and the [domain configuration reference](https://developers.cloudflare.com/email-service/configuration/domains/) covers verifying them and removing a domain later.

Nothing lands on the apex:

```text title="records created for example.net"
MX   cf-bounce.example.net             route1.mx.cloudflare.net
MX   cf-bounce.example.net             route2.mx.cloudflare.net
MX   cf-bounce.example.net             route3.mx.cloudflare.net
TXT  cf-bounce.example.net             "v=spf1 include:_spf.mx.cloudflare.net ~all"
TXT  cf-bounce._domainkey.example.net  "v=DKIM1; h=sha256; k=rsa; p=MIIBIjAN..."
```

The relay sets the envelope sender to `bounces@cf-bounce.example.net`, so receivers check SPF against that subdomain and bounces route to Cloudflare's MX. The DKIM selector is `cf-bounce` under your domain, so the signature is `d=example.net`, aligned with your From header. Your MX, apex SPF and DMARC stay as they were (onboarding adds a DMARC record only if you have none).

## 2. Mint the token

The token needs exactly one permission: Email Sending Write, account scope. You can do it in the dashboard, but if you are scripting an account this is the shape:

```bash title="find the permission group, then mint"
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
  "https://api.cloudflare.com/client/v4/user/tokens/permission_groups" \
  | python3 -c '
import json,sys
for g in json.load(sys.stdin)["result"]:
    if "Email Sending" in g["name"]: print(g["id"], "|", g["name"])'
# 5df633d6b41c42bcaf5b4a62b9d14b64 | Email Sending Write

curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/user/tokens" -d '{
  "name": "postfix-email-sending",
  "policies": [{
    "effect": "allow",
    "resources": {"com.cloudflare.api.account.'"$ACCOUNT"'": "*"},
    "permission_groups": [{"id": "5df633d6b41c42bcaf5b4a62b9d14b64"}]
  }]
}'
```

Set an expiry you will actually rotate on, or leave it off.

## 3. Postfix

Snapshot first. `postconf -n` is the whole effective configuration and it is your rollback. The three settings the cutover replaces:

```bash
postconf -n > /root/postconf-n.pre-cfrelay
postconf relayhost smtp_sasl_password_maps smtp_tls_wrappermode
# relayhost = in-v3.mailjet.com:588
# smtp_sasl_password_maps = static:<mailjet user>:<mailjet password>
# smtp_tls_wrappermode = no
```

The credential goes in its own map file. A separate file means the old relay's credentials stay in place until you decide to delete them:

```bash title="/etc/postfix/sasl_passwd_cloudflare"
[smtp.mx.cloudflare.net]:465 api_token:<the token>
```

```bash
umask 077
postmap /etc/postfix/sasl_passwd_cloudflare
```

Before touching the live relay, prove the credential works from the box itself. Ten lines of Python, reading the password from the map you just wrote:

```python title="auth-probe.py"
import smtplib
pw = open("/etc/postfix/sasl_passwd_cloudflare").read().split("api_token:")[1].strip()
s = smtplib.SMTP_SSL("smtp.mx.cloudflare.net", 465, timeout=20)
s.login("api_token", pw)
print("SMTP AUTH against Cloudflare: OK")
s.quit()
```

Six settings, one command, one reload:

```bash title="the cutover"
postconf -e \
  "relayhost = [smtp.mx.cloudflare.net]:465" \
  "smtp_tls_wrappermode = yes" \
  "smtp_tls_security_level = encrypt" \
  "smtp_sasl_auth_enable = yes" \
  "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd_cloudflare" \
  "smtp_sasl_security_options = noanonymous"
systemctl reload postfix
```

Two of these are where first-time setups go wrong:

- `relayhost` in square brackets means "connect to this host, do not look up its MX." Without the brackets Postfix resolves MX for `smtp.mx.cloudflare.net` and you get whatever that returns.
- `smtp_tls_wrappermode = yes` is the one people miss. Port 465 is implicit TLS: the connection is encrypted from the first byte, no STARTTLS negotiation. Postfix defaults to STARTTLS behaviour, which on 465 produces a hang followed by a timeout, not a clear error. Mailjet on 588 was STARTTLS, hence `no` above; this flag is what changes.
- `smtp_tls_security_level = encrypt` refuses to fall back to plaintext if TLS fails. On a relay that carries a credential in every session, that is the only sane setting.
- `smtp_sasl_security_options = noanonymous` prevents Postfix from picking an anonymous mechanism if the server ever offers one. Cloudflare advertises PLAIN and LOGIN.

Your first message through the new path should be visible in the log within seconds:

```text title="/var/log/mail.log"
postfix/smtp[4043402]: Trusted TLS connection established to smtp.mx.cloudflare.net[162.159.205.27]:465: TLSv1.2 with cipher ECDHE-RSA-CHACHA20-POLY1305
postfix/smtp[4043402]: 9AD89BECD0: to=<check-auth@verifier.port25.com>, relay=smtp.mx.cloudflare.net[162.159.205.27]:465, delay=2.3, status=sent
```

"Trusted" in that first line means Postfix verified the certificate chain, not just encrypted the session. If you see "Untrusted" or "Anonymous", your CA bundle or `smtp_tls_CAfile` needs attention before you rely on `encrypt`.

## 4. The sender rule

A server also sends mail on its own behalf, not just what gets submitted through 587. Cron output, fail2ban notices, unattended-upgrades reports and Postfix's own bounce notifications leave with an envelope sender like `root@mail.example.net`, the machine's hostname, and the relay rejects that with `550 5.7.1 Sender denied` unless that domain is onboarded too.

Check what your box uses for its own mail:

```bash
postconf myorigin myhostname
```

Onboard the hostname's domain if it is a zone you control, or rewrite system senders onto one that is with `sender_canonical_maps`, so `root@mail.example.net` leaves as `root@example.net`. Do this before cron, fail2ban or unattended-upgrades sends its next report.

## 5. Verify with the strictest grader available

The port25 auth verifier is the usual check; its reflector did not respond when I tried it. The better verification is a real message to a Gmail address, sent from a phone through the normal 587 submission path, then Show Original. This is what came back, with addresses swapped:

```text title="Authentication-Results from Gmail"
Return-Path: <bounces@cf-bounce.example.net>
Received-SPF: pass (google.com: domain of bounces@cf-bounce.example.net
    designates 104.30.10.49 as permitted sender)
Authentication-Results: mx.google.com;
    dkim=pass header.i=@cloudflare-email.net header.s=cf2024-1;
    dkim=pass header.i=@example.net header.s=cf-bounce;
    spf=pass smtp.mailfrom=cf-bounce.example.net;
    dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=example.net
```

SPF passes on the `cf-bounce` subdomain, where the return-path points and where Cloudflare put the include. DKIM passes twice: Cloudflare's own `cloudflare-email.net` signature, and `d=example.net` with selector `cf-bounce`. The second one is aligned with the From domain, and that is what carries DMARC under `p=reject`.

Then I decoded the base64 HTML part and compared the link in my signature with what my phone sent. Byte-identical. No redirector, no pixel.

## 6. Retire the old relay

With the new path proven by a receiver I do not control, the old provider's DNS can go:

```bash title="inventory before deleting anything"
curl -s -H "Authorization: Bearer $DNS_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?per_page=100" \
  | python3 -c '
import json,sys
for r in json.load(sys.stdin)["result"]:
    if "mailjet" in (r["name"]+r["content"]).lower():
        print(r["id"], r["type"], r["name"], "|", r["content"][:50])'
```

Mine turned up the DKIM key at `mailjet._domainkey`, a domain-validation TXT with a hash for a name, a stale key under an old `test.` subdomain, and the `include:spf.mailjet.com` in the apex SPF. All deleted, except the SPF record, which got rewritten:

```text title="apex SPF, after"
example.net  TXT  "v=spf1 -all"
```

Nothing sends with the apex as MAIL FROM any more: every legitimate outbound message leaves with `bounces@cf-bounce.example.net`, and receivers evaluate SPF against that subdomain, which has its own record. A hard-fail apex SPF rejects anyone forging the bare domain in an envelope, independent of DMARC.

Keep the `postconf -n` snapshot and the old SASL map around for a week. Rollback is one `postconf -e` away.

## What it costs and what it fixes

| Metric | Value | Note |
| --- | --- | --- |
| Monthly bill | $5 | Workers Paid plan |
| Included messages | 3,000/mo | then $0.35 per 1,000 |
| DMARC under p=reject | pass | aligned DKIM on your own domain |
| Links rewritten | 0 (was all of them) |  |

Cloudflare signs with a key under your domain, so a `p=reject` DMARC policy keeps passing, and the apex SPF can say `-all` truthfully. Postfix now delivers exactly the bytes I hand it, with Cloudflare's reputation behind them.
