Skip to content

RTFM · Networking

HAProxy for MailDragon and webDragon

HAProxy is the world's most widely deployed open-source reverse proxy and Layer-7 load balancer: it accepts connections at an edge address, chooses a healthy backend by rules you write, and shuttles traffic between the two in both directions. It can terminate TLS or pass it through untouched. A VPN joins private networks; HAProxy does not.

Saphira Linux dragon mascot

What HAProxy is — and what it is not

HAProxy is a reverse proxy. A normal forward proxy works for the client, hiding or serving the outside world on its behalf. A reverse proxy works for the server side: clients connect to HAProxy, which decides which internal — or external — machine actually answers, then relays the whole conversation. It is also a load balancer: one service name can stand over several backend servers, with requests spread by round-robin, least connections, source-hash affinity or plain weights.

Because it understands HTTP, it works at Layer 7 of the OSI model: routing decisions can depend on hostname, path, headers, cookies, methods or status codes rather than just addresses and ports. That is precisely why one public IPv4 address can host dozens of unrelated domains — each request carries its Host header, HAProxy matches it against access control lists (ACLs), and forwards accordingly, no matter whether the target lives at 192.0.2.x next door or at another site entirely. The trade-off cuts both ways: whatever passes through reads both directions of conversation, so treat the proxy itself as trusted ground zero.

Is and is not
HAProxy doesHAProxy does not
Terminate TLS (owning certs) or pass TCP/TLS through byte-for-byteCreate encrypted tunnels between sites — that is WireGuard territory
Route by HTTP Host, path, cookies, headers — Layer 7Route arbitrary protocols by payload semantics
Balance across healthy backends with live checksReplace your firewall: nftables still decides who may connect
Speak HTTP/1.1, HTTP/2 (h2) and HTTP/3 QUIC at the edgeCache content like a CDN or serve static files like a web server

How, why, where — and when not

Use HAProxy wherever several services must share scarce edge resources: one IPv4 address serving many names, one certificate owner fronting machines with none, or many backends behind one calm entry point. Skip it when a single service owns a single port outright — a plain firewall forward is fewer moving parts — or when you need encrypted transport between YOUR OWN sites, where WireGuard is the honest answer.

  • Why: collapse N certificates, hosts and ports into one audited choke point with logging, checks and statistics in one place.
  • Where: the edge of the DMZ, or any host that owns public ports. On Saphira the openvswitch + network.d design typically gives HAProxy host its own dmz-facing bridge with a gateway interface.
  • When: hosting multiple domains on one address, publishing mail/web from private ranges, staging blue/green releases by weight.
  • When not: pure private-to-private tunnels (VPN), content caching (nginx/varnish-class problem), packet filtering (nftables).

The features worth knowing by name

  1. 1. Health checks

    Periodic probes (HTTP GET expecting 200, TCP connect, SMTP greeting…) tell HAProxy which backends deserve traffic. Marked-down servers receive nothing; recovery brings them back automatically. A check turns silent failure into visible status.

  2. 2. Fallback / backup servers

    Any server line may carry the 'backup' keyword. Backups sit idle while primaries pass checks, then absorb the whole load instantly the moment every primary fails — ideal for maintenance pages or cold-standby hosts.

  3. 3. SNI — Server Name Indication

    In a TLS ClientHello the client announces WHICH hostname it came to see before encryption begins. HAProxy can read that hint and route TCP passthrough by name — terminating nothing. That is how dozens of independently certified domains share one :443 listener even though each keeps its own certificate on its own internal box.

  4. 4. ALPN, HTTP/2 and QUIC

    'bind … alpn h2,http/1.1' negotiates HTTP/2 alongside legacy HTTP. QUIC is HTTP/3's UDP-based transport folding TLS 1.3 into connection setup; modern HAProxy builds can offer it ('bind … quic'), where kernel/build support allows — verify against your actual package before promising browsers HTTP/3.

  5. 5. Rewrite rules

    http-request/http-response directives rewrite URLs, redirect schemes, add or strip headers (X-Forwarded-For, X-Forwarded-Proto) — small surgical edits en route.

  6. 6. SPOE — Stream Processing Offload Engine

    A module protocol delegating decisions (scoring, bot detection, enrichment) to external helper daemons via structured messages. Powerful; rarely needed day one.

The official configuration manual at docs.haproxy.org documents every keyword used below; this chapter teaches shape and intent, the manual holds every flag.

Choose where TLS ends

For webDragon, HAProxy can terminate HTTPS and send HTTP to an internal webDragon listener, or pass encrypted TLS through so webDragon terminates it. For MailDragon, TCP passthrough is usually the least surprising because SMTP, IMAP, and POP3 each have their own TLS and protocol behavior. Do not make a backend parse PROXY protocol unless it is configured to do so.

Mail TCP
global
    log /dev/log local0
defaults
    mode tcp
    timeout connect 5s
    timeout client  1m
    timeout server  1m

frontend smtp_in
    bind :25
    default_backend mail_smtp
backend mail_smtp
    server maildragon 192.168.20.25:25 check

frontend submission_tls
    bind :465
    default_backend mail_submission_tls
backend mail_submission_tls
    server maildragon 192.168.20.25:465 check

webDragon HTTP mode

webDragon HTTPS termination
global
    log /dev/log local0
defaults
    mode http
    option forwardfor
    timeout connect 5s
    timeout client  1m
    timeout server  1m

frontend https_in
    bind :443 ssl crt /etc/haproxy/certs/example.pem
    http-request set-header X-Forwarded-Proto https
    default_backend webdragon
backend webdragon
    server webdragon 192.168.20.30:80 check

The backend must trust the HAProxy host before using X-Forwarded-For for logs or access control. If TLS terminates at webDragon instead, use TCP passthrough and let webDragon own the certificate.

Real client addresses and mail PTR

A proxy normally becomes the source address seen by a backend. HTTP backends can use X-Forwarded-For. TCP backends can use PROXY protocol only when MailDragon or the relevant service is explicitly configured to parse it. The public IP used for outbound mail is the address whose PTR must point to mail.example.com; it is not automatically the HAProxy backend address.

Do not publish a mail PTR for an internal 192.168.x.x address. Reverse DNS is controlled by the owner of the public address, usually the ISP or hosting provider.

Operate safely

HAProxy operations
# Validate and reload HAProxy
haproxy -c -f /etc/haproxy/haproxy.cfg

# OpenRC
rc-service haproxy status
rc-service haproxy reload
rc-update add haproxy default

# systemd
systemctl status haproxy.service
systemctl enable --now haproxy.service
systemctl reload haproxy.service
journalctl -u haproxy.service

Build a proxy path before exposing it

A proxy is a chain, not a single configuration file. Decide the public address, the listener port, the HAProxy frontend, the private backend address and port, the TLS owner, and the firewall rule at both ends. If HAProxy and the backend run on separate hosts, the backend firewall should normally accept its public-service port only from the proxy network. This prevents someone bypassing HAProxy by discovering the backend address.

  1. 1. Prove the backend locally

    From HAProxy's network, connect directly to the backend address and port. Fix its service, route, or firewall before involving the proxy.

  2. 2. Add one frontend and one health check

    Use a named frontend/backend and server ... check. A health check makes a failed backend visible instead of silently accepting a client connection that cannot be completed.

  3. 3. Validate without reloading

    Run haproxy -c -f /etc/haproxy/haproxy.cfg. A validation failure changes nothing; a reload should happen only after the test succeeds.

  4. 4. Test at each boundary

    Test backend direct from the proxy, proxy listener from its LAN, then public name from a truly external network. Test the certificate or SMTP TLS negotiation at the public edge.

Safe local HAProxy proof
# Baseline installed configuration and local status endpoint.
# The status listener is deliberately local, not a public dashboard.
frontend status
    bind 127.0.0.1:8404
    mode http
    http-request return status 200 content-type text/plain string "HAProxy is running\n"

# Validate, then reload through the selected service manager.
haproxy -c -f /etc/haproxy/haproxy.cfg
rc-service haproxy reload
curl -fsS http://127.0.0.1:8404/

Prove it works: A working proxy is observable

The config validates, HAProxy owns only the intended public ports, the local status endpoint answers on 127.0.0.1, a backend health check is up, and an external client reaches the chosen public hostname with the expected certificate or mail protocol response.

Choose the right tool for the boundary

Do not use one tool for another tool's job
NeedUseDo not assume
Publish HTTPS or SMTP to the InternetHAProxy plus firewall and public DNSA VPN makes a public website or mail MX reachable.
Give administrators private accessWireGuard/VPN plus restricted firewall policyHAProxy protects SSH, databases, or backup ports.
Map IPv4 at a home routerExplicit port forwarding or a carefully hardened DMZ HostAn HAProxy frontend bypasses CGNAT or router policy.
Control whether packets are allowednftables at the host and edgeA HAProxy backend declaration is a firewall rule.

Worked example: one IPv4, many domains (derived from our live config)

AKADATA runs HAProxy in exactly this pattern: a handful of public IPv4 addresses hosting dozens of domains, proxying to internal hosts across several networks. The snippets below are sanitised rewrites of that live configuration (hosts renamed to example ranges) — use them as starting shapes.

First the simplest possible shape — one listener, one service chain, expressed as a flat listen block:

Minimal listen-and-bind
listen simple_web
    bind 198.51.100.10:80
    mode http
    server app1 172.16.99.20:80 check

Frontend/backend splits the same idea and unlocks routing. The frontend owns listeners and ACL decisions; backends own health checks and server lines. Order matters: first matching use_backend wins, so put specific names above catch-alls:

Virtual hosting with ACLs
frontend http_web
    bind 198.51.100.10:80
    mode http
    option forwardfor

    # ACLs match the Host header the client sent
    acl host_alpha hdr(host) -i alpha.example.org www.alpha.example.org
    acl host_beta  hdr(host) -i beta.example.net  www.beta.example.net
    acl host_stats hdr(host) -i stats.example.org stats.example.net

    use_backend alpha_http if host_alpha
    use_backend beta_http  if host_beta
    use_backend stats_http if host_stats
    default_backend alpha_http   # unknown Host values land here

backend alpha_http
    mode http
    option httpchk
    http-check send meth GET uri /health ver HTTP/1.1 hdr Host alpha.example.org
    http-check expect status 200
    server alpha 172.16.99.20:80 weight 90 check send-proxy-v2

backend beta_http
    mode http
    option httpchk
    http-check send meth GET uri /health ver HTTP/1.1 hdr Host beta.example.net
    http-check expect status 200
    server beta 172.16.0.2:80 check

At :443 with certificates kept on each internal machine, switch from HTTP mode to TCP passthrough and route by SNI instead of Host headers. The inspect-delay exists because the hostname arrives inside the TLS ClientHello — HAProxy waits briefly to read it before picking a backend:

SNI passthrough without terminating TLS
frontend https_passthru
    bind 198.51.100.10:443 alpn h2,http/1.1
    mode tcp
    option tcplog

    tcp-request inspect-delay 5s
    tcp-request content accept if { req_ssl_hello_type 1 }

    acl sni_alpha req.ssl_sni -i alpha.example.org www.alpha.example.org
    acl sni_beta  req.ssl_sni -i beta.example.net  www.beta.example.net

    use_backend alpha_tls if sni_alpha
    use_backend beta_tls  if sni_beta

default-server inter 2s fall 3 rise 2 on-marked-down shutdown-sessions

backend alpha_tls
    mode tcp
    option httpchk
    http-check send meth GET uri /health ver HTTP/1.1 hdr Host alpha.example.org
    http-check expect status 200
    server alpha 172.16.99.20:443 check check-ssl verify none \
+      sni str(alpha.example.org) alpn h2,http/1.1 send-proxy-v2

Health checks travel in the clear here only because each target answers plain-HTTP probes internally; verify none skips cert validation for checking purposes while real traffic stays end-to-end encrypted. Where confidentiality of checks matters, install the CA and drop verify none.

Backup servers and the statistics dashboard

Fallback is one keyword long. While 172.16.99.20 answers checks it takes everything; the instant it stops, requests flow to the backup line until primaries recover:

Primary plus backup in one backend
backend alpha_tls_fallback_demo
    balance roundrobin
    default-server inter 2s fall 2 rise 2 on-marked-down shutdown-sessions
    server alpha-primary 172.16.99.20:443 check check-ssl verify none sni str(alpha.example.org)
    server alpha-maint   172.16.99.30:443 check backup   # fallback server

HAProxy also ships its own dashboard. Bind it deliberately — public internet access means strong credentials and, better, a firewall or ACL in front:

Statistics listener plus access control
listen stats
    bind 198.51.100.10:7777
    mode http
    stats enable
    stats uri /
    stats refresh 10s
    stats realm HAProxy\ Statistics
    stats auth admin:CHANGE-ME-BEFORE-PUBLISHING

# then prove who may reach it at the firewall layer too:
nft add rule inet filter input ip saddr != { 203.0.113.0/24 } tcp dport 7777 reject

The dashboard shows every frontend, backend and server with up/down state, request rates and queue depths — the fastest way to see what health checks already decided on your behalf.

Where this chapter draws from

Every keyword above is documented exhaustively in the official HAProxy configuration manual; keep it open beside this page when writing anything beyond these examples.

A runnable reference covering virtual hosting, SNI passthrough, PROXY protocol v2, health checks, backup servers and the stats listener ships with this repository as demo.haproxy.cfg — read alongside this page. Passwords inside have been changed; treat every address as documentation-only.

Getting dirty: external health checks

Built-in checks answer 'is the port up?'. External checks answer any question you can phrase as a program: does this app authenticate, does the CMS render, does the database accept a real query? With option external-check HAProxy runs an arbitrary script — bash, python, perl, a compiled binary — and treats exit code 0 as UP, anything else as down. The options are genuinely endless because the checker is just your own code.

Enable external checking on a backend
backend alpha_http
    mode http
    option external-check
    external-check command /etc/haproxy/checks/probe.sh
    server alpha 172.16.99.20:80 check inter 5s fall 3 rise 2

HAProxy hands your script its full context as positional arguments. Anyone who remembers ldirectord or ipvsadm health scripts will recognise $1 through $4 — and the fifth argument is the one people always forget:

Variables passed to every external check script
ArgumentMeaning
$1Proxy name — the frontend/backend/listen block running the check
$2Server name — the exact identifier of the server line being probed
$3Server IP (IPv4 or IPv6) of the target
$4Server port being checked
$5Optional source address/port when a source binding was declared

A minimal probe needs nothing more than curl and the right variable:

A simple bash probe
#!/bin/bash
# /etc/haproxy/checks/probe.sh  - chmod 755
PROXY_NAME=$1
SERVER_NAME=$2
SERVER_IP=$3
SERVER_PORT=$4
SOURCE_PORT=$5   # present only if a source binding was declared

# Custom logic in full HAProxy context:
curl --silent --fail \
+  "http://${SERVER_IP}:${SERVER_PORT}/health" > /dev/null

# exit 0 = UP; non-zero = DOWN. Add --max-time to avoid hangs.

From there it scales up naturally: log into the service over SSH and verify a session, run a Python client against the internal API, POST credentials and expect a specific response code, walk a full application workflow. The same discipline applies to plain TCP services via tcp-check conversation blocks — a Redis ping-pong takes four lines:

PING/PONG check for Redis
backend redis_servers
    mode tcp
    option tcp-check
    tcp-check connect
    tcp-check send PING\r\n
    tcp-check expect string +PONG
    server redis1 192.168.4.10:6379 check inter 5s
  • tcp-check expect supports string, rstring (regex), status codes and binary patterns.
  • Multi-step conversations work too: send AUTH/USER lines first, then probe — that is how you build login-style checks without a helper script.

Two operational traps: HAProxy often runs inside a chroot (our global config uses chroot /var/lib/haproxy), so the interpreter and script must exist INSIDE the chroot tree with their libraries, or use a statically compiled checker. And every external check spawns a process per probe — keep intervals sane (inter 5s or slower) and give scripts explicit timeouts so a hung curl cannot pile up zombies.

Prefer built-in http/tcp checks wherever they suffice, and reserve external checks for questions they cannot ask. Scripts are powerful precisely because they are arbitrary — which also makes them the slowest and most fragile kind of check.