Config File
One file, , read top to bottom and built out of named blocks.
| Block | What it holds |
|---|---|
| Process settings: user, chroot, log target, stats socket, SSL defaults | |
| Values inherited by every block below it: mode, timeouts, logging | |
| The lines and the rules that pick a backend | |
| The pool, the balance algorithm, health checks | |
| A frontend and a backend fused into one block | |
| DNS servers for resolving hostnames at runtime | |
| Users and groups for |
| Path | What it is |
|---|---|
| The main config on Debian, Ubuntu, and RHEL | |
| Extra files, merged in filename order (2.4+) | |
| Debian and Ubuntu: the and the unit passes | |
| The RHEL, Rocky, and Alma equivalent | |
| The packaged error pages points at | |
| Where the official Docker image looks instead |
There is no directive. Split a large config by passing a directory to (or by dropping files in ), which loads every in it in name order. A second block resets the inheritance for everything after it, which is how one file serves both HTTP and TCP sections.
Frontend, Backend & Listen
A frontend accepts traffic, a backend holds the server pool, and is both in one block. A minimal complete config:
global
log /dev/log local0
stats socket /run/haproxy/admin.sock mode 660 level admin
defaults
mode http
log global
option httplog
timeout connect 5s
timeout client 30s
timeout server 30s
frontend fe_web
bind :80
default_backend bk_web
backend bk_web
balance roundrobin
server web1 10.0.0.11:8080 check
server web2 10.0.0.12:8080 checkThe built-in stats page is the classic block:
listen stats
bind :8404
stats enable
stats uri /stats
stats refresh 10s
stats auth admin:changemeAdd to enable/disable servers from the page itself; keep the port firewalled or bound to an internal address.
Check the Config
Always check before a reload; a config error found at reload time is a config error found the hard way.
| Command | What it does |
|---|---|
| Parse the config, exit 0 when valid | |
| Same, and print "Configuration file is valid" | |
| Check every config file in a directory, loaded in name order |
Chain the check with the reload so a typo never goes live:
haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxyService Control
HAProxy runs under systemd on every mainstream distro; the systemctl cheat sheet covers the full service surface.
| Command | What it does |
|---|---|
| Apply config changes without dropping connections | |
| Full restart; drops every active connection | |
| Start the service | |
| Stop it | |
| Start now and on every boot | |
| Running or not, recent log lines, worker PIDs | |
| Confirm which ports it is actually listening on |
Reload is the one to remember: it starts new workers on the new config while the old workers finish their existing connections.
Stats Socket
Runtime admin without touching the config, also called the Runtime API. It needs one line in the section, then a reload:
global
stats socket /run/haproxy/admin.sock mode 660 level adminSend commands with socat, one-shot or interactive:
echo "show stat" | socat stdio /run/haproxy/admin.sock
socat readline /run/haproxy/admin.sock # interactive; type "prompt" to keep it open| Socket command | What it does |
|---|---|
| Version, uptime, current and max connections | |
| Full stats as CSV: every frontend, backend, and server | |
| Every backend server with its state and health | |
| Stop sending traffic to a server (maintenance) | |
| Put it back in rotation | |
| No new connections; existing ones finish | |
| Back to normal after drain or maint | |
| Change a server's load balancing weight | |
| List live sessions | |
| Kill every session on one server | |
| Recent request and response parse errors | |
| Stick table contents, e.g. rate limiting counters | |
| Reset the stats counters | |
| Change the global connection limit live | |
| List every socket command this build supports |
prints around 80 columns. This trims it to name, status, and weight:
echo "show stat" | socat stdio /run/haproxy/admin.sock | cut -d, -f1,2,18,19 | column -s, -tLogs
HAProxy logs through syslog, not to a file of its own. On Debian and Ubuntu the packaged rsyslog rule writes traffic to .
| Command | What it does |
|---|---|
| Follow live traffic logs | |
| Process events only: starts, reloads, config warnings | |
| Find requests that got a 503 | |
| Per-server report: errors and response times (halog ships with HAProxy) | |
| Per-URL report from the same log |
The config side that makes traffic logging work:
global
log /dev/log local0
defaults
log global
mode http
option httplog
option dontlognullis still where reload failures land; the journalctl cheat sheet covers filtering it.
Termination State
Every line ends with a four-character field, the session state at disconnection. The first character says who ended the session and the second says what it was doing, which is usually enough to tell a client problem from a backend problem.
| Code | What happened |
|---|---|
| Normal completion, nothing to see | |
| fired while waiting for response headers; the client got a 504 | |
| fired waiting for a free server slot; the client got a 503 | |
| The server refused or reset the connection; the client got a 502 or 503 | |
| The server closed mid-transfer | |
| fired during the data phase | |
| The client went away mid-transfer, which is normal for cancelled downloads | |
| The client closed before sending a complete request | |
| fired; headers never arrived in full | |
| HAProxy rejected the request itself: a deny rule or a malformed request | |
| HAProxy answered locally: a redirect, the stats page, an | |
| HAProxy ran out of a resource (sockets, memory) while connecting |
Lowercase first character means a timeout, uppercase means an abort or an explicit action. A run of points at being shorter than the app's slowest response.
Health Checks
on a server line turns on TCP health checks; upgrades them to real HTTP requests.
backend bk_web
option httpchk
http-check send meth GET uri /health hdr Host example.com
http-check expect status 200
server web1 10.0.0.11:8080 check inter 2s fall 3 rise 2
server web2 10.0.0.12:8080 check inter 2s fall 3 rise 2
server spare 10.0.0.13:8080 check backup| Server option | What it does |
|---|---|
| Enable health checks on this server | |
| Time between checks | |
| Failed checks before the server is marked down | |
| Passed checks before it comes back up | |
| Only used when every non-backup server is down | |
| Cap concurrent connections to this server |
Watch the results with on the stats socket, or on the stats page.
Command Line Options
| Command | What it does |
|---|---|
| Version | |
| Full build info: OpenSSL version, compiled features | |
| Config file; repeat the flag or pass a directory | |
| Check the config and exit | |
| Run in the foreground with output to the terminal | |
| Run as a background daemon | |
| Master-worker mode; adds systemd notify (what the unit uses) | |
| New process takes over; old one finishes its connections | |
| New process takes over; old one is killed immediately | |
| Inherit listening sockets from the old process (truly hitless reload) | |
| Write the PID file |
Balance
| Algorithm | When to use it |
|---|---|
| The default: even spread, respects weights, adjustable at runtime | |
| Long-lived connections: databases, LDAP, WebSocket | |
| Hash of the client IP, so a client keeps hitting one server | |
| Hash of the path; sends the same object to the same cache | |
| Hash of a header value | |
| Picks two servers at random and takes the less loaded one | |
| Round robin with fixed weights, no runtime weight changes | |
| Fill one server to its , then move to the next |
Cookie-based sticky sessions, which survive a server coming back better than does:
backend bk_app
balance roundrobin
cookie SRV insert indirect nocache
server app1 10.0.0.11:8080 check cookie a1
server app2 10.0.0.12:8080 check cookie a2re-hashes when a server goes down, which moves other clients too. The cookie only moves the clients on the dead server.
ACLs
ACLs name a condition; , , and friends act on it. Rules run top to bottom, first match wins.
frontend fe_web
bind :80
acl is_api path_beg /api
acl is_static path_end .css .js .png .jpg
acl host_blog hdr(host) -i blog.example.com
acl internal src 10.0.0.0/8 192.168.0.0/16
http-request deny if is_api !internal
use_backend bk_api if is_api
use_backend bk_blog if host_blog
use_backend bk_static if is_static
default_backend bk_webAnonymous ACLs inline in braces work too, as in .
Headers
The backend sees HAProxy's IP, not the client's, until you say otherwise. belongs in so every backend inherits it.
| Directive | What it does |
|---|---|
| Add with the client IP | |
| Same, but skip requests from a trusted source | |
| Tell the app the client used TLS | |
| Pass the port the client hit | |
| Rewrite the Host sent to the backend | |
| Add without replacing an existing header | |
| Drop a header before it reaches the backend | |
| Drop a header before it reaches the client | |
| HSTS on every response | |
| Pass the mTLS client certificate CN |
is a fetch evaluated per request; quotes the result, which matters for anything that can contain spaces. The app still has to trust , and it should only do that when HAProxy is the only thing that can reach it.
HTTPS & SSL
HAProxy terminates TLS on the bind line. The file is the certificate, any intermediate chain, and the private key concatenated into one PEM, in that order.
frontend fe_https
bind :443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1
http-request set-header X-Forwarded-Proto https
default_backend bk_webBuilding that PEM from a Let's Encrypt certificate:
sudo cat /etc/letsencrypt/live/example.com/fullchain.pem \
/etc/letsencrypt/live/example.com/privkey.pem \
| sudo tee /etc/haproxy/certs/example.com.pem > /dev/nullPoint at a directory to load every certificate in it; HAProxy picks the right one per request by SNI. Verify the served chain with the SSL checker.
Redirects
frontend fe_web
bind :80
bind :443 ssl crt /etc/haproxy/certs/example.com.pem
http-request redirect scheme https code 301 unless { ssl_fc }
http-request redirect prefix https://example.com code 301 if { hdr(host) -i www.example.com }The first rule sends every plain HTTP request to HTTPS; the second folds www onto the bare domain while keeping the path.
Timeouts & Limits
, , and have no defaults. Leave them out and HAProxy warns at startup, then lets stuck connections sit forever.
defaults
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s
timeout http-keep-alive 10s
timeout queue 30s
timeout check 5s
timeout tunnel 1h| Setting | What it covers |
|---|---|
| Waiting for the TCP connection to a backend server | |
| Client inactivity once the request is in flight | |
| Backend inactivity while HAProxy waits for a response | |
| Time the client gets to send complete headers (slowloris guard) | |
| Idle time between requests on a kept-alive connection | |
| Wait for a free server slot before the request gets a 503 | |
| Health check timeout once the check has connected | |
| WebSocket and CONNECT tunnels, after the upgrade | |
| Process-wide connection cap in | |
| Per-server cap on a line; the rest queue | |
| Kill workers left over from a reload after this long |
Raise for WebSockets: after the upgrade, and no longer apply, so a short tunnel timeout is what cuts idle sockets.
Rate Limiting
A stick table counts what a key does; a rule reads the counter and acts.
frontend fe_web
bind :80
stick-table type ip size 100k expire 60s store http_req_rate(10s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }| Socket command | What it does |
|---|---|
| Every key in the table with its counters | |
| Only the keys over a threshold | |
| Drop one entry, which unbans that IP | |
| Empty the table | |
| Reset one counter |
Swap for to limit new connections, or for concurrent ones. For a block that outlives the request, track the source in a second table with a longer and deny on .
TCP Mode
proxies raw bytes: databases, SMTP, Redis, anything that is not HTTP. Frontend and backend must both be .
frontend fe_pg
mode tcp
bind :5432
default_backend bk_pg
backend bk_pg
mode tcp
balance leastconn
option tcp-check
server pg1 10.0.0.21:5432 check
server pg2 10.0.0.22:5432 check backupbeats for long-lived connections like database sessions.
SNI
To route TLS by hostname without terminating it (passthrough), read the SNI from the ClientHello in TCP mode:
frontend fe_tls_passthrough
mode tcp
bind :443
tcp-request inspect-delay 5s
tcp-request content accept if { req_ssl_hello_type 1 }
use_backend bk_app1 if { req_ssl_sni -i app1.example.com }
use_backend bk_app2 if { req_ssl_sni -i app2.example.com }The backends stay and the apps keep their own certificates. When HAProxy terminates TLS itself, match on instead of .
Docker
The official image reads , not , and runs as the user. Docker's default capability set includes , so binding inside the container still works.
docker run -d --name hap -p 80:80 -p 443:443 \
-v "$PWD/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro" \
haproxy:3.0
# check a config without installing HAProxy at all
docker run --rm -v "$PWD/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro" \
haproxy:3.0 haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg| Command | What it does |
|---|---|
| Reload the config in place, no container restart | |
| Build info and compiled features for that image | |
| Follow the logs | |
| Pick up a changed mount under Compose |
There is no syslog socket in the container, so send logs to stdout instead:
global
log stdout format raw local0 infoThe docker cheat sheet covers the container side.
Install
| Command | What it does |
|---|---|
| Debian and Ubuntu, usually a release or two behind | |
| Ubuntu PPA with current builds, then | |
| RHEL, Rocky, and Alma from AppStream | |
| Alpine | |
| Confirm which version you ended up with | |
| Version plus the build date and OpenSSL it links |
The distro package is fine for most sites; the PPA is for when you need a feature from a newer branch. The syntax used above needs 2.2 or newer, so check before copying config off the internet.
Keepalived
One HAProxy box is a single point of failure. Keepalived runs on two of them and moves a floating IP (VRRP) to whichever is alive, so clients only ever see one address.
vrrp_script chk_haproxy {
script "killall -0 haproxy"
interval 2
}
vrrp_instance VI_1 {
state MASTER
interface eth0
virtual_router_id 51
priority 101
virtual_ipaddress {
203.0.113.50
}
track_script {
chk_haproxy
}
}The backup node uses and a lower . Set in sysctl on both so HAProxy can bind the floating IP while it lives on the other box.
Gotchas
- drops every active connection. After a config edit, is what you want, and only after passes.
- Stats socket changes (, , ) vanish at the next reload. Anything permanent belongs in the config file.
- The PEM must contain the private key. Certbot's alone fails at startup with "unable to load SSL private key".
- No traffic logs usually means a missing or rsyslog rule; only ever shows process events. With enabled, the syslog socket must exist inside the chroot (the Debian package sets this up).
- A 503 from HAProxy itself, with the backends supposedly fine, means no server in the chosen backend passed its health check. on the stats socket says why.
- must match between a frontend and the backends it routes to; catches the mismatch, another reason to run it every time.
- "cannot bind socket (permission denied)" on or means HAProxy is no longer root at bind time. The packaged unit binds as root and then drops to the user; a hardened unit or a container needs instead. The same message with a bind on a floating IP usually needs .
- and the timeouts come from the nearest block above a section, not from the first one in the file. Add a second for the TCP sections and everything after it inherits that one, HTTP sections included.