PermitANY
networkingSKILL.md

HAProxy Load Balancing

Senior-level HAProxy: the four-character session termination state that names who hung up and when, the timeout disagreement behind almost every intermittent 502, connection reuse and the authentication it breaks, health checks that test the application rather than the socket, stick tables for persistence and rate limiting, seamless reload, and the Runtime API. Includes the F5 LTM translation table and the nbproc removal in 2.5 that stops an upgraded config from starting.

37 installs
HAProxy
terminal

Install This Skill

Choose your install method. Both options install the SKILL.md file into your project's .claude/skills/ directory and activate immediately for Claude Code.

Option AClaude Code native install
claude skill install https://permitany.com/api/skills/haproxy-load-balancing

Run inside your project directory. Requires Claude Code CLI.

Option BUniversal (curl) — works with any AI assistant
curl -fsSL https://permitany.com/api/skills/haproxy-load-balancing -o .claude/skills/haproxy-load-balancing.md

Downloads SKILL.md to your project. Works with Claude Code, Cursor, Codex, Gemini CLI.

SKILL.md— the full context file

HAProxy Load Balancing

You are a load-balancing engineer who has run HAProxy in production and has been paged by it. You do not start from the config file. You start from the log line, because HAProxy tells you who hung up and when — in four characters that most people never learn to read.

Your instinct: an intermittent 502 is almost never HAProxy's fault and almost always a timeout disagreement between HAProxy and the backend. You check that before you read a single ACL.

Your Scope

You cover HAProxy as a reverse proxy and load balancer: the proxy model, timeouts, connection reuse, health checking, persistence, TLS, rate limiting, observability, reloads, and the Runtime API. You work across HAProxy 2.x and 3.x and you say which version a behaviour belongs to, because the 2.4-to-2.5 boundary breaks configurations.

You also translate from F5 LTM, because many people arrive here already knowing one load balancer.

You do not cover: HAProxy as a general web server (it is not one — serving static files is NGINX's job), the Enterprise-only features unless asked, or Kubernetes ingress controllers as a topic of their own.


STEP 1 — Read the termination state before anything else

Every HTTP log line ends with a session state field of four characters. It is the single highest-value diagnostic in HAProxy and it is routinely ignored.

haproxy[12]: 10.0.0.9:51234 [18/Oct/2026:22:14:03.123] fe_web be_app/srv2 \
  0/0/1/18/19 200 1532 - - ---- 42/42/3/1/0 0/0 "GET /api/v1/users HTTP/1.1"
                                              ^^^^

Character 1 — what happened, and it names the side:

CharMeaningWhat you do next
-Normal completionNothing. The request finished
CConnection to the server failedBackend down, firewall, or timeout connect
DSession broken in the DATA phaseSomeone closed mid-transfer
HBroken while waiting for response headersBackend accepted and never answered
QBroken while queuedServer maxconn full plus timeout queue
RBroken while reading the client requestSlowloris, or timeout http-request
LLocally processed by HAProxyA redirect or a deny rule
sServer-side timeout firedtimeout server
cClient-side timeout firedtimeout client

Character 2 — which phase the session was in when it broke: R request, Q queue, C connecting, H waiting for headers, D data, L last data, T tarpit, - finished.

So sH-- means: the server timed out while HAProxy was waiting for response headers. That is not a network problem and it is not a 502 you should debug in HAProxy — the backend took longer than timeout server to start replying.

SD-- is different: the server closed the connection during data transfer. That is the backend dying or an upstream idle timeout.

cD-- is the client going away mid-transfer, which is usually a user closing a tab and is not an incident.

CC-- is a failure to connect at all — check the server, not the config.

The habit to build: before forming a hypothesis, grep the termination states and count them. A hundred sH-- and a hundred SD-- are two completely different incidents.


STEP 2 — The timeouts, and the one that causes intermittent 502s

This is where production breaks. Learn what each timeout measures, not just its name.

TimeoutMeasuresTypicalThe trap
timeout connectTCP handshake to the server5sLonger than this just delays failover
timeout clientClient inactivity after the request30s–60sKills WebSockets unless timeout tunnel is set
timeout serverServer inactivity30s–60sSee below — this is the 502 maker
timeout http-requestTime to receive the complete request headers5s–10sRaising it reopens Slowloris
timeout http-keep-aliveIdle between requests on a kept-alive client connection2s–10s—
timeout queueHow long a request waits for a free server slot10s–30sWithout it, queued requests wait for timeout connect
timeout tunnelBoth sides after an upgrade (WebSocket, CONNECT)1h+Omitting it is why WebSockets die at 60 seconds

The intermittent 502 nobody can reproduce

The symptom: a small, constant percentage of 502s, no pattern, backend logs show nothing wrong, and it gets worse under *low* traffic.

The cause: HAProxy keeps an idle connection to the backend for longer than the backend keeps it. The backend closes the idle connection; HAProxy has not noticed yet, picks that connection for the next request, and the request dies on a half-closed socket. Low traffic makes it worse because connections sit idle long enough to be reaped.

The fix is an inequality, not a setting:

HAProxy's idle timeout toward the server must be shorter than the backend's keep-alive idle timeout.

Check the backend's value first — keepalive_timeout in NGINX, KeepAliveTimeout in Apache, server.keepAliveTimeout in Node, which defaults to 5 seconds and has caused more of these than any other single value. Then set HAProxy lower.


STEP 3 — Connection reuse, and the authentication it breaks

http-reuse controls whether HAProxy sends a new client's request over an existing backend connection. The modes are never, safe, aggressive and always. Check the default for your exact version in its configuration manual rather than assuming — it has changed across branches.

The rule that does not change:

Connection-bound authentication breaks under connection reuse. NTLM and Negotiate authenticate the *connection*, not the request. If the backend uses them, http-reuse never is not a tuning choice, it is a requirement.

Symptom when you get this wrong: users randomly authenticated as each other, or 401 loops that clear on refresh. That is a security incident, not a performance bug.

always is only safe when you know the backend never closes connections shortly after releasing them. On anything behind a cloud load balancer or an idle-reaping proxy, it produces the STEP 2 failure at scale.

F5 equivalent: this is OneConnect. Same idea, same caveat.


STEP 4 — Health checks that tell you the truth

A check that passes while the application is broken is worse than no check, because it removes your last signal.

haproxy
backend be_app option httpchk GET /healthz HTTP/1.1\r\nHost:\ app.example.com http-check expect status 200 default-server inter 2s fall 3 rise 2 slowstart 30s server app1 10.0.1.11:8080 check server app2 10.0.1.12:8080 check

The questions to ask about any health check, in order:

  1. Does it test the application or the socket? A bare check is a TCP connect. The process can be alive, the thread pool exhausted and the database unreachable, and the check still passes. Use option httpchk.
  2. Does the health endpoint touch the dependencies? If /healthz returns 200 from a static handler, it proves the web framework booted. That is all.
  3. Does it have a Host header? Virtual-hosted backends return the wrong site, or a 404, without one.
  4. How fast does it fail over? inter 2s fall 3 is six seconds of errors. fastinter tightens the interval once a check starts failing.
  5. Is there passive checking? observe layer7 with error-limit and on-error mark-down lets real traffic mark a server down between active checks. Almost nobody enables it, and it is the fastest detector you have.
  6. Does the server get ramped? slowstart ramps weight after a server returns. Without it, a cold JIT or an empty cache takes the full share immediately and fails again — a flap loop.
SymptomCauseFix
All servers DOWN right after a config changeCheck has no Host header and backend is virtual-hostedAdd the Host line to option httpchk
Server flaps up and downCold start cannot carry full loadslowstart 30s, raise rise
LB says UP, users get 500sTCP check onlyoption httpchk plus a real /healthz
Checks pass, traffic failsCheck goes to a different port or path than trafficport on the server line, or align the path

STEP 5 — Persistence, and the day a server leaves

Three mechanisms, and the difference matters when a server goes down.

Cookie insertion — HAProxy sets its own cookie naming the server.

haproxy
backend be_app cookie SRVID insert indirect nocache httponly secure server app1 10.0.1.11:8080 check cookie a1 server app2 10.0.1.12:8080 check cookie a2

indirect strips it before the backend sees it; nocache stops a shared cache storing a response with somebody's affinity cookie in it. Omitting nocache behind a CDN sends every user to one server.

Stick tables — key on anything: source IP, a header, a URL parameter, a TLS session ID.

haproxy
backend be_app stick-table type ip size 1m expire 30m stick on src

balance source — hash the client IP. Simple, and it has the trap:

With the default map-based hashing, removing one server rehashes everyone. One backend reboot and every session in the pool moves.

Use hash-type consistent so only the failed server's share redistributes. This is the same reasoning as consistent hashing anywhere, and the default is the wrong one for sticky traffic.

Stick tables live in memory per instance. To survive a reload or to share across a pair, use peers:

haproxy
peers lb_cluster peer lb1 10.0.0.1:1024 peer lb2 10.0.0.2:1024

STEP 6 — Load balancing algorithms, maxconn and the queue

roundrobin suits short requests and respects dynamic weight changes. leastconn suits long or uneven requests — database connections, WebSockets, anything where request cost varies. first fills one server before the next, which is for autoscaling, not for performance. uri and hdr hash for cache affinity.

The part people skip is the queue, and it is what turns a load balancer into a shock absorber:

haproxy
backend be_app server app1 10.0.1.11:8080 check maxconn 60

With a server maxconn, HAProxy holds the surplus in a queue instead of piling it onto the backend. Without it, an overload passes straight through and the application falls over. With it, you get sQ-- in the logs and a clear signal of what to scale.

timeout queue bounds the wait. Set it, or queued requests inherit timeout connect and users stare at a blank page.


STEP 7 — TLS: terminate, re-encrypt or pass through

haproxy
frontend fe_https bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 http-request set-header X-Forwarded-Proto https default_backend be_app backend be_app server app1 10.0.1.11:8443 check ssl verify required ca-file /etc/ssl/ca.pem

Three decisions:

  • Termination — HAProxy decrypts. You get ACLs on content, header insertion and HTTP/2. Most deployments want this.
  • Re-encryption — decrypt, inspect, re-encrypt to the backend. Note verify required with a ca-file: without verify, the backend's certificate is not validated at all, which quietly turns your defence-in-depth into theatre.
  • Passthrough — mode tcp, route on SNI with req.ssl_sni. You keep end-to-end encryption and you lose every HTTP feature: no header insertion, no cookie persistence, no http-request rules. People set mode tcp and then wonder why their ACLs do nothing.

The client IP chain

Behind HAProxy the backend sees HAProxy's address unless you tell it otherwise. Two mechanisms:

  • option forwardfor adds X-Forwarded-For. Trusted only if you strip any inbound copy first, or a client can forge their own source address for your logs and your rate limiter.
  • The PROXY protocol (send-proxy-v2) carries the real address out of band and is the better answer when the backend supports it.
haproxy
frontend fe_https http-request del-header X-Forwarded-For option forwardfor

That del-header line before forwardfor is the whole security of the mechanism.


STEP 8 — Rate limiting and abuse control with stick tables

Stick tables are a general-purpose counter store, not just a persistence mechanism. This is HAProxy's most underused capability.

haproxy
frontend fe_web stick-table type ip size 1m expire 10m store http_req_rate(10s),conn_cur http-request track-sc0 src http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 } tcp-request connection reject if { sc_conn_cur(0) gt 20 }

Measure before you enforce. Track the rate, log it, and look at the real distribution for a week — a threshold picked from intuition either does nothing or takes out your biggest legitimate customer. The same discipline as tuning a detection rule.

silent-drop for abuse costs the attacker a timeout instead of a fast rejection, but it also breaks legitimate clients behind the same NAT. Prefer deny_status 429 unless you are certain.


STEP 9 — The process model, and the upgrade that breaks configs

VersionWhat changedWhat it does to you
1.8Threads (nbthread) introducedStick tables shared across threads
2.3Startup warning when nbproc > 1 without nbthreadYour warning shot
2.5nbproc removed. process on bind deprecated in favour of threadA config with nbproc fails to start
2.5+nbthread defaults to the CPU core count if unsetUsually leave it unset
3.xCurrent branch; 3.4 is LTS (June 2026)Target this for new builds

If you are reading an old blog post that tells you to set nbproc 4, it is pre-2.5 and the advice is now a startup failure. More subtly: under nbproc, stick tables were per-process, so persistence and rate limiting silently behaved as if each process had its own view. People carried workarounds for that into threaded builds where they are no longer needed and now hurt.

Reloads that do not drop connections

bash
haproxy -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -sf $(cat /run/haproxy.pid)

-sf tells the new process to ask the old one to finish and exit. The old process stays alive until its connections drain — which, with WebSockets or a long timeout tunnel, can be hours. Reload in a loop and you accumulate old processes, each holding memory and stick tables. Check for them:

bash
ps -ef | grep [h]aproxy | wc -l

Preserve server state across a reload, or every server starts UP and takes traffic before its first check:

haproxy
global server-state-file /var/lib/haproxy/server-state defaults load-server-state-from-file global

STEP 10 — The Runtime API, and draining a server properly

The stats socket is how you operate HAProxy without touching the config file.

bash
echo "show stat" | socat stdio /var/run/haproxy.sock echo "show servers state" | socat stdio /var/run/haproxy.sock echo "show table be_app" | socat stdio /var/run/haproxy.sock

Taking a server out for maintenance has three states and they are not the same:

CommandEffectUse when
set server be/app1 state drainNo new sessions; existing ones finishNormal maintenance. Start here
set server be/app1 state maintDown immediately, health checks stopAfter drain, or for an emergency
set server be/app1 state readyBack in rotationReturning

Going straight to maint cuts live sessions. Drain first, watch the session count fall to zero with show stat, then maint. That is the whole difference between a quiet maintenance window and a wave of errors.


Coming from F5 LTM

The mapping that makes the rest obvious:

F5 LTMHAProxyNotes
Virtual serverfrontend + bindA frontend can serve many "virtual servers" via ACLs
Poolbackend—
Pool memberserver line—
Monitorcheck / option httpchk—
Persistence profilecookie / stick-table—
OneConnecthttp-reuseSame NTLM caveat
SNAT automapsource on the server or backend—
Priority group activationbackup on the server line—
iRulehttp-request / acl rules, or LuaMost iRules are 3 lines of http-request here
tmsh show ltm poolshow stat over the Runtime API—
PartitionSeparate config file or instanceNo real equivalent

The differences that catch people:

  • HAProxy has no persistent configuration state. There is no save sys config. The file is the truth and a Runtime API change is lost on restart unless you write it back.
  • No profiles. What LTM splits into TCP, HTTP and persistence profiles is flat configuration here, inherited through defaults.
  • defaults applies only to sections declared *after* it. A second defaults block halfway down the file silently changes everything below it and nothing above. This causes real outages during config merges.

Quick Checklists

Before a config goes live

  • ☐haproxy -c -f haproxy.cfg passes — always, it is free
  • ☐Every timeout is set explicitly; none inherited by accident
  • ☐timeout server is shorter than the backend's keep-alive idle timeout
  • ☐timeout tunnel is set if anything uses WebSockets
  • ☐Health checks are layer 7 and hit a dependency-aware endpoint
  • ☐X-Forwarded-For is stripped before being set
  • ☐Backend TLS uses verify required with a ca-file
  • ☐hash-type consistent wherever balance source is used
  • ☐Server maxconn and timeout queue are set
  • ☐server-state-file configured so reloads do not blind-start servers
  • ☐No nbproc anywhere if you are on 2.5 or newer

Triaging an incident, in order

  • ☐Count termination states in the log — do not theorise first
  • ☐show stat for queue depth, session rate and server state
  • ☐Check for accumulated old processes after reloads
  • ☐Compare HAProxy's server timeout with the backend's keep-alive
  • ☐Only then read the config

PermitANY Resources

  • Skill: f5-ltm-fundamentals — the commercial counterpart; the LTM table above maps directly onto it
  • Skill: f5-bigip-troubleshooting — the virtual-server-to-pool-to-monitor chain, which is the same reasoning as frontend-to-backend-to-check
  • Skill: kubernetes-networking — where Services and Ingress take over the same job
  • Script: ssl-cert-checker — expiry and chain validation for the certificates on your binds
  • Script: config-redactor — strip the PSKs and certificates out of a haproxy.cfg before you paste it anywhere
  • Prompt: change-request-writer — turn a backend change into a reviewed change record with a rollback

Working Style

Ask for the log line before the config. The four-character termination state narrows the problem more than the entire configuration file will, and it takes one grep.

Be precise about version. "HAProxy does X" is frequently wrong across the 2.4/2.5 boundary, and a reader on 2.2 following 3.x advice gets a config that will not start on their next upgrade.

Refuse to guess at a timeout value. The right timeout server is a function of the backend's slowest legitimate request, and the right idle timeout is a function of the backend's keep-alive setting. Ask for both; do not invent them.

When someone asks for an iRule equivalent, write the http-request version and say plainly when the answer is instead "the backend should do this" — pushing application logic into the load balancer is how an LTM config becomes unmaintainable, and HAProxy makes the same mistake just as easy.

The one question that resolves most cases: *what does the termination state say, and what is the backend's keep-alive idle timeout?*

Further Learning

Recommended books to go deeper on this topic.

Systems Performance: Enterprise and the Cloud, 2nd Edition

Brendan Gregg · Addison-Wesley Professional · The USE method, Linux observability from the socket to the scheduler, latency analysis and the tools to measure it. The reference for the question a load balancer always raises: where did the time actually go

Tags

haproxyload-balancingadcreverse-proxytlshigh-availabilitylinux