HAProxy configuration for HTTP reverse proxying
How HAProxy sections, rule ordering, timeouts and the runtime API fit together, with a production-shaped config and a timeout-to-log-flag lookup table.
Key points
- A config is
global(process),defaults(inherited), andfrontend/backendpairs;listenis a frontend and backend fused for simple cases. - Rule evaluation order is fixed. Frontend
tcp-request, then frontendhttp-request, thenuse_backend, then backendhttp-request. Responses run backend first, then frontend. - Every timeout maps to a specific two-letter termination flag in the log, so
sHmeanstimeout serverfired waiting for response headers and produced a 504. set server be/srv state drainover the runtime API removes a server from load balancing while honouring existing and sticky sessions, with no reload.
An HAProxy configuration is a small number of sections with strict inheritance: global configures the process (privileges, tuning, the stats socket), defaults supplies values inherited by every proxy section declared after it, frontend binds sockets and decides which backend a request goes to, and backend holds the servers and the balancing policy. listen fuses a frontend and a backend into one block for cases with a single backend. The two things that most often go wrong are not syntax, they are rule evaluation order and the timeout ladder, so both get their own tables below.
Section reference#
| Section | Declared once? | What belongs in it | Common mistakes |
|---|---|---|---|
global | Yes | user, group, chroot, maxconn, stats socket, ssl-default-bind-ciphers, log target | Setting a per-proxy timeout here (rejected) |
defaults | Repeatable | mode, timeout *, option httplog, retries, option forwardfor | Placing it after the frontends it was supposed to feed |
frontend | Repeatable | bind, ACLs, http-request, use_backend, default_backend | Putting server lines in it |
backend | Repeatable | server, balance, option httpchk, cookie, backend-side http-request | Assuming frontend ACLs are visible here (they are not, ACLs are section-scoped) |
listen | Repeatable | Everything a frontend and backend may hold | Using it when you need multiple backends |
resolvers | Repeatable | nameserver, resolve_retries, hold | Forgetting resolvers <name> on the server line that needs it |
A defaults section applies to every proxy defined below it until the next defaults. That is a positional rule, not a lexical one, so a second defaults block halfway down a file silently changes the behaviour of everything after it.
mode http versus mode tcp#
mode decides how much of the byte stream HAProxy understands, and it changes far more than header access.
| Behaviour | mode http | mode tcp |
|---|---|---|
| Request parsing | Full HTTP message parsing and validation | None, opaque byte stream |
| Available rules | http-request, http-response, http-after-response, plus all tcp-request rules | tcp-request connection, tcp-request session, tcp-request content only |
| Header manipulation | Yes | Not possible |
| Load balancing granularity | Per request, so keep-alive requests can land on different servers | Per connection only |
| Default log format | option httplog gives status, timers, termination flags | option tcplog, no HTTP fields |
| Health checks | L4 connect by default, L7 with option httpchk | L4 connect, or option ssl-hello-chk and similar |
| Sticky sessions | Cookie insertion available | Source hashing or stick tables only |
| Fetches for routing | path, hdr(), url_param() | req.ssl_sni, req.payload() after tcp-request inspect-delay |
Use mode tcp when you must not terminate TLS (see TLS termination, passthrough and re-encryption) and route on SNI instead, or for non-HTTP protocols. Everything else belongs in mode http, because per-request balancing and observability are worth more than the small parsing cost.
Rule evaluation order#
This is the part that is not obvious from reading a config top to bottom, because rules run in category order first and declaration order second.
tcp-request connection(frontend, before anything is read; the only placesrcis guaranteed pre-PROXY-protocol semantics matter)tcp-request session(frontend, after the handshake, so after PROXY protocol and TLS)tcp-request content(frontend, afterinspect-delayhas buffered payload)http-request(frontend), in declaration orderuse_backendrules in declaration order, thendefault_backendtcp-request content(backend)http-request(backend), in declaration order- Server selection:
balance, stickiness, queueing - Request sent; on the way back,
http-response(backend) thenhttp-response(frontend), thenhttp-after-response
ACLs are named boolean tests evaluated lazily at the point a rule references them, and they are scoped to the section that declares them. Multiple conditions on one rule are ANDed; or must be written explicitly.
acl is_api path_beg /api/
acl is_health path /healthz
acl from_office src 203.0.113.0/24
acl is_websocket hdr(Upgrade) -i websocket
http-request deny if is_api !from_office !{ req.hdr(Authorization) -m found }
use_backend be_ws if is_websocket
use_backend be_api if is_api
default_backend be_webpath_beg /api/ is a prefix match on the path only; path is exact. Anonymous ACLs in braces ({ req.hdr(...) -m found }) are convenient inline but cannot be reused.
Header manipulation#
| Directive | Effect on an existing header of the same name |
|---|---|
http-request set-header X-A v | Removes all existing instances, then adds one |
http-request add-header X-A v | Leaves existing instances, appends another |
http-request del-header X-A | Removes all instances |
http-request replace-header X-A ^(.*)$ pre-\1 | Rewrites each instance with a regex |
http-request replace-value X-A ^(.*)$ pre-\1 | Rewrites each comma-separated value within each instance |
option forwardfor is the exception that does not follow those semantics: it appends the connection's source address to X-Forwarded-For. Two parameters matter:
if-noneadds the header only when the request has none. Use it when an upstream CDN is authoritative for the client IP.except <network>skips the header for sources in that network, typically a local health checker.
option forwardfor except 127.0.0.0/8 if-noneWhether you should append or overwrite depends entirely on whether the peer is trusted, which is the subject of configuring trusted proxies; and getting it wrong is how client IP spoofing succeeds. To overwrite unconditionally, drop option forwardfor and write http-request set-header X-Forwarded-For %[src].
Balance algorithms#
| Algorithm | Selection basis | Use it when | Notes |
|---|---|---|---|
roundrobin | Next server by weight | Short HTTP requests with similar cost | Weights adjustable at runtime; limited to 4095 active servers per backend |
static-rr | Next server by static weight | Very large server counts | No runtime weight change, no slowstart, no server-count limit |
leastconn | Fewest active connections | Long-lived or highly variable request durations, LDAP, SQL, WebSockets | Also respects weights |
first | Lowest-numbered available server with a free slot | Autoscaling on connection concentration | Requires maxconn per server to be meaningful |
source | Hash of the client address | Stickiness without cookies | Changing the server pool reshuffles most clients unless hash-type consistent |
uri | Hash of the path (optionally with query) | Cache servers, so each object has one owner | Pair with hash-type consistent to survive pool changes |
url_param <p> | Hash of a query or body parameter | Application-level affinity by user id | Falls back to round robin when the parameter is absent |
hdr(<name>) | Hash of a header value | Affinity by tenant or by Host | use_domain_only reduces Host to the domain |
random | Random draw, random(<n>) picks the least loaded of n draws | Multiple independent HAProxy instances in front of one pool | Avoids the herd effect that leastconn shows across separate proxies; available since 1.9 |
The rule of thumb: roundrobin for uniform short requests, leastconn as soon as request durations vary by more than an order of magnitude, and random(2) when several HAProxy nodes balance over the same servers, because independent leastconn instances all pick the same "least loaded" server at the same instant.
For affinity that must survive server churn, cookie-based stickiness beats hashing; see sticky sessions and session affinity.
Server options and health checks#
| Option | Default | Meaning |
|---|---|---|
check | off | Enable health checking |
inter | 2000ms | Interval between checks when the server is UP |
fastinter | value of inter | Interval while in a transition state (going up or down) |
downinter | value of inter | Interval while the server is DOWN |
rise | 2 | Consecutive successes before a DOWN server is marked UP |
fall | 3 | Consecutive failures before an UP server is marked DOWN |
maxconn | 0 (unlimited) | Concurrent connections; excess requests queue |
slowstart | 0 | Ramp period over which weight grows from 0 after coming UP |
weight | 1 | Relative share, 0 to 256; weight 0 means "sticky sessions only" |
backup | off | Used only when all non-backup servers are down |
With check alone the check is a TCP connect. option httpchk upgrades it to L7. Since HAProxy 2.2 the composable form is preferred and is much easier to extend:
option httpchk
http-check send meth GET uri /healthz ver HTTP/1.1 hdr Host app.internal
http-check expect status 200slowstart 30s matters more than it looks: a JIT-compiled or cache-cold application that returns UP instantly will be handed its full share of traffic and time out, flap DOWN, and repeat. The interaction between check intervals and failover is covered in health checks and upstream failover.
Timeouts and the log flag they produce#
HAProxy has no safe defaults here: timeout connect, timeout client and timeout server have no built-in value and HAProxy warns at startup if they are missing. The table below is the diagnostic shortcut, because option httplog prints a two-character termination state (termination_state) that identifies exactly which timer fired.
| Directive | Typical value | Protects against | Termination flags | Client sees |
|---|---|---|---|---|
timeout connect | 5s | Unreachable or saturated server, TCP SYN blackhole | sC | 503 |
timeout queue | inherits timeout connect | Waiting too long for a maxconn slot | sQ | 503 |
timeout client | 30s | Idle or stalled client during body or response transfer | cD, cR | Connection closed |
timeout server | 30s to 60s | Slow application, deadlocked backend | sH (headers), sD (data) | 504 on sH |
timeout http-request | 5s to 10s | Slowloris, incomplete request headers | cR | 408 |
timeout http-keep-alive | 1s to 10s | Idle keep-alive connections consuming slots | cD on an idle connection | Connection closed |
timeout tunnel | 1h for WebSockets | Abandoned tunnels after protocol upgrade or CONNECT | cD / sD | Connection closed |
timeout check | inherits connect timings | Health check hanging after connecting | Server marked DOWN | 503 once all servers fail |
Reading the flags: the first character is who or what ended the session (c client timeout, s server timeout, C client abort, S server abort or reset, P proxy denied, R resource exhaustion, - normal), the second is which phase it was in (R reading the request, Q queued, C connecting, H reading response headers, D data transfer, L last data push, - complete). So sH is unambiguous: timeout server expired while waiting for response headers, and HAProxy synthesised a 504. SH with a capital S is a different bug entirely: the server closed or reset while sending headers, and you get a 502.
timeout http-keep-alive has a documented fallback chain worth knowing: if it is unset, timeout http-request applies to the wait for the next request, and if that is also unset, timeout client applies. That is why an unset keep-alive timeout with a generous timeout client quietly pins connections for minutes.
Worked example#
global
log /dev/log local0
user haproxy
group haproxy
chroot /var/lib/haproxy
maxconn 40000
# Runtime API. level admin is required for state changes.
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
mode http
log global
option httplog
option dontlognull
# Do not retry non-idempotent requests after the response started.
retries 2
option redispatch
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s # slowloris guard
timeout http-keep-alive 4s
timeout queue 10s
timeout tunnel 1h # WebSockets outlive timeout server
option forwardfor except 127.0.0.0/8 if-none
frontend fe_https
bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
bind :80
http-request redirect scheme https code 301 unless { ssl_fc }
# Frontend response rules apply to denials too, so they belong here.
http-response set-header Strict-Transport-Security "max-age=63072000"
http-request set-header X-Forwarded-Proto %[ssl_fc,iif(https,http)]
http-request set-header X-Request-Id %[uuid()] unless { req.hdr(X-Request-Id) -m found }
acl is_api path_beg /api/
acl is_ws hdr(Upgrade) -i websocket
acl is_metrics path /metrics
acl internal_src src 10.0.0.0/8
http-request deny deny_status 404 if is_metrics !internal_src
use_backend be_ws if is_ws
use_backend be_api if is_api
default_backend be_web
backend be_api
balance leastconn
option httpchk
http-check send meth GET uri /healthz ver HTTP/1.1 hdr Host api.internal
http-check expect status 200
# Backend rules run after the frontend's and know the chosen backend.
http-request set-header X-Backend be_api
default-server check inter 3s fastinter 1s rise 2 fall 3 maxconn 200 slowstart 30s
server api1 10.0.1.11:8080
server api2 10.0.1.12:8080
server api3 10.0.1.13:8080
backend be_ws
balance leastconn
timeout tunnel 2h
server ws1 10.0.2.11:8080 check
server ws2 10.0.2.12:8080 check
backend be_web
balance roundrobin
cookie SRV insert indirect nocache httponly secure
option httpchk GET /healthz
default-server check inter 2s maxconn 500
server web1 10.0.3.11:8080 cookie w1
server web2 10.0.3.12:8080 cookie w2Two details in that config are deliberate. timeout tunnel is set globally to 1h and raised to 2h only in the WebSocket backend, because timeout server would otherwise cut an idle upgraded connection at 30s. And option redispatch with retries 2 lets a request that failed to connect be retried on a different server; it does not retry a request whose response had already started, which is the correct default for non-idempotent traffic.
Draining a server without a reload#
Reloading HAProxy is graceful but leaves the old process alive until its connections finish, which makes reload-per-deployment a poor tool for taking one server out of rotation. The runtime API on the stats socket does it instantly and statefully.
# Inspect
echo "show stat" | socat stdio /run/haproxy/admin.sock | cut -d, -f1,2,18,19
echo "show servers state" | socat stdio /run/haproxy/admin.sock
# Stop new load-balanced traffic, keep existing and sticky sessions
echo "set server be_api/api2 state drain" | socat stdio /run/haproxy/admin.sock
# Watch it empty out (scur column)
echo "show stat" | socat stdio /run/haproxy/admin.sock | grep be_api
# Full maintenance: also drops stickiness and stops checks counting
echo "set server be_api/api2 state maint" | socat stdio /run/haproxy/admin.sock
# Kill anything still attached, then return to service
echo "shutdown sessions server be_api/api2" | socat stdio /run/haproxy/admin.sock
echo "set server be_api/api2 state ready" | socat stdio /run/haproxy/admin.sockThe drain and maint distinction is the useful one: drain removes the server from the load balancing algorithm but continues to serve requests that carry its persistence cookie, so a session-bound user finishes their work; maint removes it entirely and stops health checks from being reported. For a rolling deploy you want drain, a wait, then maint.
set weight be_api/api2 10% gives partial drains for canaries. Runtime state is lost on restart unless you dump it with show servers state into the file referenced by server-state-file and enable load-server-state-from-file global, which is how you keep a drained server drained across a reload.
Version notes#
Claims here that are version dependent:
- The composable
http-check send/http-check expectsyntax arrived in 2.2. The older single-lineoption httpchk <method> <uri> <version>still parses. - The legacy
reqrep,reqadd,rspaddheader keywords were deprecated in 1.9 in favour ofhttp-requestandhttp-response, and removed in later 2.x releases. Configurations copied from pre-1.9 material will fail to parse. balance randomwas added in 1.9.- Master-worker mode with the master CLI (
-W -S) is the supported way to run HAProxy under a supervisor from 1.8 onward, andexpose-fd listenerson the stats socket enables socket handover for seamless reloads. - HAProxy 2.8 and 3.0 are LTS branches. 3.0 introduced the
crt-storesection for managing certificates independently ofbindlines.
For how HAProxy's design choices compare with the alternatives, see nginx vs HAProxy vs Envoy vs Caddy vs Traefik.
Failure modes#
| Symptom | Cause | Fix |
|---|---|---|
| Startup warning about missing timeouts, then hung connections | timeout client / server / connect absent from defaults | Set all three explicitly |
503 with flags sC and no server ever marked DOWN | timeout connect firing before the health check notices | Lower inter, check the network path and any firewall dropping SYNs |
503 with flags sQ under load | Requests queued past timeout queue because every server hit maxconn | Raise per-server maxconn, add capacity, or shorten timeout queue to fail fast |
503 and backend be_api has no server available! in the log | All servers failed health checks | Check option httpchk path and Host header; a check hitting the default vhost often 404s |
504 with flags sH | Application slower than timeout server | Fix the application or raise the timeout, but raise the outer hop's timeout first |
502 with flags SH | Server closed or reset while sending response headers, or emitted a malformed response | Inspect the application; option http-ignore-probes does not apply here |
408 with flags cR on connections that never sent a request | Idle preconnects from browsers hitting timeout http-request | option http-ignore-probes, or accept them and filter the logs |
WebSocket drops at exactly the timeout server value | timeout tunnel unset, so the server timeout applies after the upgrade | Set timeout tunnel in the backend handling upgrades |
| Config change did not take effect | A second defaults section between the intended one and the proxy | Search for all defaults blocks; order is positional |
Frequently asked questions#
What is the difference between a frontend, a backend and a listen section?#
A frontend binds listening sockets and decides which backend handles a request; a backend holds the servers and the load balancing policy. listen combines both in one section, which is convenient when there is exactly one backend behind one bind, such as a stats page or a simple TCP relay.
In what order does HAProxy evaluate rules?#
Category first, then declaration order within a category. Frontend tcp-request rules run first, then frontend http-request, then use_backend, then the backend's tcp-request and http-request rules. Response rules run in reverse: the backend's http-response before the frontend's.
What are HAProxy's default timeout values?#
timeout connect, timeout client and timeout server have no defaults and HAProxy warns at startup when they are missing from defaults. timeout http-keep-alive falls back to timeout http-request, which in turn falls back to timeout client. Health check interval inter defaults to 2000ms, with rise 2 and fall 3.
How do I take a server out of rotation without reloading HAProxy?#
Send set server <backend>/<server> state drain to the runtime API socket. The server stops receiving new load-balanced traffic but still serves requests carrying its persistence cookie. Follow with state maint once the connection count reaches zero, and state ready to return it to service.
When should I use leastconn instead of roundrobin?#
Use leastconn when request durations vary widely, for example a mix of fast API calls and long uploads, or for WebSockets and database protocols where connections are long-lived. Use roundrobin when requests are short and uniform, because it distributes more evenly and costs less to compute.
Why is HAProxy returning 503 when the servers are up?#
The three common causes are all visible in the termination flags: sC means the connection to the server timed out, sQ means the request waited past timeout queue for a maxconn slot, and a log line naming the backend with no server available means every server failed its health check. Check the health check's request path and Host header first.
Does mode tcp let me set headers?#
No. In mode tcp HAProxy never parses HTTP, so http-request and http-response rules are not available and no header can be added, removed or inspected. Routing decisions must use connection-level fetches such as req.ssl_sni, which requires a tcp-request inspect-delay.
How does option forwardfor differ from set-header X-Forwarded-For?#
option forwardfor appends the connection source address to any existing X-Forwarded-For, preserving upstream entries, and can be limited with except and if-none. http-request set-header X-Forwarded-For %[src] deletes everything that was there and writes a single value, which is the correct behaviour when the peer is untrusted.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.