Both approaches share one hard limit: neither carries SMTP. Mail has to arrive on port 25, from arbitrary senders, over plain TCP. That path stays direct regardless of what you do with HTTP, and the section on it below is the part worth reading before you start.
What a tunnel can carry
A Cloudflare Tunnel is an outbound connection from a daemon on your box to Cloudflare’s edge. Traffic for your hostname arrives at the edge and is handed back down that connection. Your firewall needs no inbound rule and your origin IP is never published.
| Traffic | Through a tunnel? |
|---|---|
| Frontend, HTTPS | Yes. This is the case tunnels are for. |
| API, HTTPS | Yes. |
| WebSocket | Yes, and by default — but see the section below about timeouts. |
| SMTP, port 25 | No. Free-plan tunnels carry HTTP and HTTPS. Arbitrary TCP is a Spectrum feature, on paid plans only, and Spectrum does not offer port 25 at all. |
Install cloudflared
Install and authenticate
on the host running BurnerByte $ curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null$ echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \https://pkg.cloudflare.com/cloudflared any main" \ | sudo tee /etc/apt/sources.list.d/cloudflared.list$ sudo apt update && sudo apt install -y cloudflared # Opens a browser to pick the zone. Writes a cert to ~/.cloudflared/$ cloudflared tunnel loginCreate the tunnel
bash $ cloudflared tunnel create burnerbyte# Prints a tunnel UUID and writes ~/.cloudflared/<UUID>.json — the credentials. $ cloudflared tunnel listRoute the hostnames
bash # Creates the proxied CNAME records in your zone for you.$ cloudflared tunnel route dns burnerbyte mail.example.com$ cloudflared tunnel route dns burnerbyte api.example.com
Ingress rules
tunnel: burnerbytecredentials-file: /root/.cloudflared/<TUNNEL-UUID>.json ingress: - hostname: mail.example.com service: http://localhost:3000 - hostname: api.example.com service: http://localhost:8080 originRequest: # WebSocket connections are long-lived. The default 90s idle timeout # closes them, and the client reconnects in a loop — mail still arrives, # but late and with a visible stutter. connectTimeout: 30s tcpKeepAlive: 30s noTLSVerify: false # Required: a catch-all must be last, or cloudflared refuses to start. - service: http_status:404# Validate before installing it as a service$ cloudflared tunnel ingress validate $ sudo cloudflared service install$ sudo systemctl enable --now cloudflared$ sudo systemctl status cloudflaredWebSockets through a tunnel
cloudflared proxies WebSockets without configuration, and Cloudflare’s edge allows them on every plan. Two things still need attention.
- The frontend must be built for
wss://. The same build-arg trap as any other TLS deployment:NEXT_PUBLIC_WS_URLis compiled into the bundle, so a restart will not change it. - Idle timeouts close long-lived sockets. The client reconnects, so it recovers, but a mailbox open on a quiet afternoon will visibly stutter. The
tcpKeepAliveabove is what keeps it up.
$ cat >> .env <<'ENV'FRONTEND_URL=https://mail.example.comAPI_BASE_URL=https://api.example.comWS_BASE_URL=wss://api.example.comENV $ docker compose build frontend && docker compose up -d frontendEverything in the reverse proxy guide about trusted proxies applies here too: Cloudflare sets CF-Connecting-IP, and cloudflared forwards X-Forwarded-For. Set BB_RATE_LIMIT_TRUSTED_PROXIES to the loopback, since that is where cloudflared connects from.
Running it in Compose
Cleaner than a host service if BurnerByte already runs in Docker: the tunnel lives and dies with the stack, and reaches the other services by name.
services: cloudflared: image: cloudflare/cloudflared:latest command: tunnel --no-autoupdate run environment: # Dashboard-managed tunnel: Zero Trust → Networks → Tunnels → create, # then copy the token. Ingress rules are configured in the dashboard # rather than in a file. TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN} depends_on: - frontend - api restart: unless-stoppedWith the dashboard-managed form the public hostname maps to http://frontend:3000 and http://api:8080 — Compose service names, since cloudflared is on the same network.
Gating admin surfaces with Access
BurnerByte has its own authentication, roles and session management; Cloudflare Access is a second, independent gate in front of it. Worth it for the admin surfaces, which is where a compromised session does the most damage.
Create an application
Zero Trust → Access → Applications → Add an application → Self-hosted. Set the domain to
mail.example.comand the path to/admin.Add a policy
Allow, with a rule on Emails ending in
@yourcompany.com, or a specific group. Add a second Require rule for your identity provider if you have one.Leave the API alone
Do not put Access in front of the APIAccess intercepts with an HTML login page. API clients, the frontend’s own fetches and the WebSocket handshake all get that page instead of their response, and every one of them breaks. Gate paths under the frontend hostname only, and leaveapi.example.comungated — it has its own authentication, which is the layer designed for machine callers.
SMTP still needs a direct path
This is the constraint that shapes the whole design, so it is worth being blunt about it. A tunnel cannot carry port 25. Free and Pro tunnels carry HTTP and HTTPS; arbitrary TCP is Cloudflare Spectrum, which is an enterprise product and does not offer port 25 in any case — Cloudflare does not proxy SMTP.
So your options are:
| Option | Trade-off |
|---|---|
| Expose 25 directly | A single port forward to the SMTP daemon, with the web UI still on the tunnel. Your origin IP is published in DNS for the MX target, which is the thing the tunnel was hiding. For most self-hosters this is fine — the MX has to point somewhere real regardless. |
| A relay in front | A small VPS, or a provider that accepts inbound mail and forwards it to a port of your choosing. Your MX points at the relay; the relay reaches your instance over a private link. Costs a hop and a moving part. |
| Receive from inside only | No public MX at all. Useful when the inboxes exist to catch mail generated by your own systems, which is more often the case than people expect. |
Which produces a split zone: the web hostnames are CNAMEs at <tunnel-uuid>.cfargotunnel.com, created for you by cloudflared tunnel route dns and proxied, so the origin stays hidden. The SMTP host is a plain A record, unproxied, and the MX points at it. Record formats are in DNS Setup.
WireGuard
The other shape: instead of publishing anything, put yourself on the same network as the instance. Nothing listens on the public internet except the SMTP port, and the entire web UI is only reachable from the VPN.
Install and generate keys
on the server $ sudo apt install -y wireguard$ wg genkey | sudo tee /etc/wireguard/server.key | wg pubkey | sudo tee /etc/wireguard/server.pub$ sudo chmod 600 /etc/wireguard/server.keyon each client $ wg genkey | tee client.key | wg pubkey > client.pubConfigure the server
/etc/wireguard/wg0.conf [Interface]Address = 10.8.0.1/24ListenPort = 51820PrivateKey = <contents of /etc/wireguard/server.key> # Only needed if clients should reach the wider LAN through this box. For# reaching BurnerByte alone, leave these out.# PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE# PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE [Peer]# LaptopPublicKey = <contents of client.pub>AllowedIPs = 10.8.0.2/32 [Peer]# PhonePublicKey = <second client's public key>AllowedIPs = 10.8.0.3/32bash $ sudo sysctl -w net.ipv4.ip_forward=1$ echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-wireguard.conf$ sudo systemctl enable --now wg-quick@wg0$ sudo wg showConfigure a client
wg0.conf on the laptop [Interface]Address = 10.8.0.2/32PrivateKey = <contents of client.key># Optional: resolve internal names through the server.# DNS = 10.8.0.1 [Peer]PublicKey = <contents of /etc/wireguard/server.pub>Endpoint = your-public-ip:51820 # Split tunnel: only VPN traffic is routed here, the rest goes out normally.# Use 0.0.0.0/0 instead to route everything through the server.AllowedIPs = 10.8.0.0/24, 192.168.1.0/24 PersistentKeepalive = 25Forward UDP 51820 to the server. That is the only inbound port WireGuard needs, and it answers nothing to an unauthenticated probe — a port scan cannot tell it is there.
Admin-only over WireGuard
The arrangement worth considering: bind the HTTP services to a private address so they are unreachable from the public interface, and leave only SMTP exposed.
services: frontend: ports: # Bind to the WireGuard address rather than 0.0.0.0. Docker's default # port publishing punches through ufw, so restricting it here rather # than in the firewall is the reliable move. - "10.8.0.1:3000:3000" api: ports: - "10.8.0.1:8080:8080" smtpd: ports: # The one thing that must stay public. - "0.0.0.0:25:2525"# The URLs the frontend is built against become VPN addresses.FRONTEND_URL=http://10.8.0.1:3000API_BASE_URL=http://10.8.0.1:8080WS_BASE_URL=ws://10.8.0.1:8080 # Still public, because the MX record has to point somewhere real.SMTPD_PORT=25SMTP_HOSTNAME=smtp.example.comSecure flags, or for the browser to stop complaining — put a local reverse proxy in front with an internal certificate authority and use real hostnames. Which you choose depends on how much you like certificate management.Both at once
Nothing stops you running a tunnel and WireGuard together: the tunnel publishes mail.example.com for everyday users with Access in front of /admin, and WireGuard gives operators direct access to Postgres, MinIO’s console and the metrics endpoint that only answers private addresses. That last one is the strongest argument for the VPN — /metrics refuses anything that is not loopback or RFC1918, and 10.8.0.0/24 qualifies.
Choosing between them
| If you want | Use |
|---|---|
| A public URL, no open ports | Cloudflare Tunnel. Colleagues get https://mail.example.com with a real certificate and no VPN client. |
| Nothing public at all | WireGuard. The web UI does not exist to the internet. Best for an instance that only ever catches mail from your own systems. |
| Public UI, private operations | Both. Tunnel plus Access for the app; WireGuard for the database, object store and metrics. |
| The simplest thing that works | A reverse proxy and open ports. See the previous guide — it is a perfectly respectable answer. |
BB_RATE_LIMIT_TRUSTED_PROXIES has to name whatever sits in front. Those three are true on every path in this guide.