Skip to main content
RunBook Academy

Docker & ContainersXXIV Β· Reverse Proxies & TLSHAProxy

HAProxy β€” TCP and HTTP load balancing

Advanced⏱ ~26 mindockerhaproxysocat

What you'll learn

  • Write an HAProxy configuration with active health checks and explain the detection-time arithmetic
  • Discover container backends by DNS with `resolvers` and `server-template`
  • Drain and return a backend through the Runtime API without reloading
  • Distinguish HAProxy 502, 503 and 504 and name the cause of each
  • Preserve the client address in HTTP mode and in TCP mode, where there are no headers

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12

Not yet marked complete on this device.

nginx is a web server that learned to proxy. HAProxy is a load balancer that learned to speak HTTP. The difference is not marketing: it decides what each one does well, and it is why the previous lesson had a section admitting nginx cannot poll a backend on a timer while this one spends a third of its length on exactly that.

Three capabilities are the reason to reach for HAProxy in front of containers:

  • Active health checks in the open-source build, on a timer, independent of client traffic.
  • A Runtime API that changes backend state β€” drain, maintenance, weight β€” with no reload and no dropped connection.
  • L4 (mode tcp) as a first-class citizen, which is what you need in front of a database, a message broker, or TLS you do not want to terminate.

The shape of the file

HAProxy configuration is four kinds of section, and the ordering is conventional rather than enforced:

SectionHolds
globalprocess-wide settings: user, logging, the Runtime API socket, TLS defaults
defaultsvalues inherited by every frontend and backend below it
frontendwhat to listen on, and how to choose a backend
backendthe servers, the health check, and the balancing algorithm

defaults is the section that matters more than it looks, because timeouts have no useful default and HAProxy warns at startup if you omit them. A configuration with no timeout client is not one that picks something sensible; it is one that will hold connections until something else runs out of file descriptors.

A configuration that survives production

Configuration change/usr/local/etc/haproxy/haproxy.cfg
global
  log stdout format raw local0 info
  # The Runtime API. 'level admin' allows state changes; see the
  # drain section below. Keep this socket off the network.
  stats socket /var/run/haproxy/admin.sock mode 660 level admin
  stats timeout 30s
  ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets

defaults
  log     global
  mode    http
  option  httplog
  # None of these four are optional. Omit one and HAProxy warns.
  timeout connect 5s
  timeout client  30s
  timeout server  30s
  timeout http-request 10s
  # Long-lived upgraded connections (WebSocket) use this instead
  # of timeout client/server once the tunnel is established.
  timeout tunnel  1h
  retries 3
  option  redispatch

# Docker's embedded DNS, so backend names track container recreation.
resolvers docker
  nameserver dns1 127.0.0.11:53
  resolve_retries 3
  timeout resolve 1s
  timeout retry   1s
  hold valid    10s
  hold obsolete 5s
  hold nx       5s

frontend https_in
  # 'crt' is ONE file containing cert, chain and key concatenated.
  bind *:443 ssl crt /etc/haproxy/certs/app.example.com.pem alpn h2,http/1.1
  bind *:80

  http-request redirect scheme https unless { ssl_fc }

  # Append the client address. The backend must read the LAST
  # occurrence of this header, never the first.
  option forwardfor

  acl is_api path_beg /api/
  use_backend api_servers if is_api
  default_backend web_servers

backend web_servers
  balance roundrobin

  # Active checks: GET /healthz, accept 200 only.
  option httpchk
  http-check send meth GET uri /healthz ver HTTP/1.1 hdr host app.example.com
  http-check expect status 200

  # 'resolvers docker' re-resolves in the background.
  # 'init-addr none' lets HAProxy start with this server DOWN rather
  # than refusing to start when the container is not up yet.
  # 'resolve-prefer ipv4' because the default is ipv6.
  server-template web 3 web:8080 check inter 2s rise 2 fall 3 \
      resolvers docker init-addr none resolve-prefer ipv4

backend api_servers
  balance leastconn
  option httpchk
  http-check send meth GET uri /api/healthz ver HTTP/1.1 hdr host app.example.com
  http-check expect status 200
  server-template api 3 api:9090 check inter 2s rise 2 fall 3 \
      resolvers docker init-addr none resolve-prefer ipv4

Active health checks, and the arithmetic behind them

check inter 2s rise 2 fall 3 is four decisions in one line. The defaults, if you write only check, are inter 2000ms, rise 2, fall 3.

ParameterDefaultMeaning
inter2000msinterval between two consecutive checks
fall3consecutive failures before the server is DOWN
rise2consecutive successes before a DOWN server is UP again
fastinterunsetinterval while transitioning either way
downinterunsetinterval while the server is fully DOWN

The number that matters operationally is not any of them alone:

Worst-case detection time = inter Γ— fall. With the defaults that is 2s Γ— 3 = 6 seconds during which requests are still being sent to a dead backend.

Six seconds of errors is fine for a web application and unacceptable for a payment path. Shorten it with fastinter, which applies the moment a check first fails rather than to every check:

Configuration changefaster detection without more idle load
server web1 web-a:8080 check inter 5s fastinter 500ms downinter 5s \
  rise 2 fall 3

That is 5-second polling while everything is healthy, and worst-case detection of roughly 500ms + 2 Γ— 500ms = 1.5 seconds once the first check fails β€” at a tenth of the steady-state check load of inter 1s.

Discovering containers by DNS

This is HAProxy’s answer to the problem that made the previous lesson 502: a backend name whose address changes on every deploy.

Read-only / Safeprove discovery is working
PROXY=proxy
SOCK=/var/run/haproxy/admin.sock

# Which slots exist and what address is in each one
docker compose exec -T "$PROXY" sh -c "echo 'show servers state' | socat stdio $SOCK"

# HAProxy's own view of the resolvers section: sent, valid, nx, timeout
docker compose exec -T "$PROXY" sh -c "echo 'show resolvers docker' | socat stdio $SOCK"

A non-zero and climbing nx counter with a flat valid counter means the name is not resolving β€” the backend is on a different network, or the service name is misspelled. That distinction is invisible from the error pages, which say 503 either way.

502, 503, 504 β€” HAProxy tells you which

HAProxy is unusually precise here, and the manual is explicit about what each code means:

CodeHAProxy’s definitionWhat to go and look at
502the server returned an empty, invalid or incomplete responsethe application crashed mid-response, or is speaking something that is not HTTP on that port
503no server was available to handle the requestevery server is DOWN β€” a health-check or discovery problem, not an application one
504the response timeout struck before the server respondedthe server is alive and too slow; compare against timeout server

The one that gets misread is 503. It is not β€œthe backend returned an error”; it is β€œthere was no backend to ask”. When you see 503 the application logs will be empty, because no request ever reached it, and half an hour disappears looking for an error that was never written. Go straight to show stat instead.

Read-only / Safewhich backends are up
PROXY=proxy
SOCK=/var/run/haproxy/admin.sock

docker compose exec -T "$PROXY" sh -c "echo 'show stat' | socat stdio $SOCK" | awk -F, 'NR==1 || $2!~/^(FRONTEND|BACKEND)$/ {print $1, $2, $18, $37, $38, $39}'
Read-only / Safereading check_status
$ echo 'show stat' | socat stdio /var/run/haproxy/admin.sock | cut -d, -f1,2,18,37,38
# pxname,svname,status,check_status,check_code
web_servers,web1,UP,L7OK,200
web_servers,web2,DOWN,L7STS,503
web_servers,web3,DOWN,L4CON,
api_servers,api1,UP,L7OK,200
api_servers,api2,DOWN,L7TOUT,

Illustrative output

The check_status column is the answer, and each value points somewhere different:

ValueThe checkGo and look at
L7OKanswered with an accepted statusnothing
L7STSanswered, status rejected β€” check_code has itthe application; it is up and unhealthy
L4CONthe TCP connection was refusedthe container is down, or on the wrong port
L4TOUTthe connection attempt timed outnetworking β€” no route, or packets dropped
L7TOUTconnected, then no response within timeout checkthe application is hanging, not erroring

L4CON and L7STS are the two that get conflated, and they are opposite problems: one means nothing is listening, the other means something is listening and telling you it is not well.

Timeouts

The four in defaults above cover four genuinely different waits, and mismatching them against the application is what produces confusing 504s.

DirectiveBounds
timeout connectestablishing the TCP connection to the server
timeout clientclient-side inactivity
timeout serverserver-side inactivity β€” most importantly, how long the server may take to start sending response headers
timeout http-requestreceiving a complete request from the client (slow-loris defence)
timeout tunnelinactivity once a connection has been upgraded, e.g. WebSocket

timeout server is the one to size deliberately: the manual’s advice is to start from what you would consider an unacceptable response time and check the log distribution. Setting it far above the application’s own request timeout means HAProxy waits patiently for a request the application has already abandoned; setting it below means HAProxy returns 504 for requests that would have succeeded, and the application log shows a completed 200 for a request the user saw fail. That disagreement β€” 504 at the edge, 200 in the application β€” is the signature of a proxy read timeout shorter than the application’s work.

Draining without a reload

This is HAProxy’s headline advantage over nginx, and it is one line.

Service impact possibledrain, deploy, return
PROXY=proxy
SOCK=/var/run/haproxy/admin.sock
TARGET=web_servers/web1

runtime() { docker compose exec -T "$PROXY" sh -c "echo '$1' | socat stdio $SOCK"; }

# 1. Stop sending NEW sessions here; existing ones finish.
runtime "set server $TARGET state drain"

# 2. Wait for the current session count to reach zero.
until runtime 'show stat' | awk -F, '$1=="web_servers" && $2=="web1" {exit ($5==0)?0:1}'; do
sleep 2
done

# 3. Now it is safe to replace the container.
docker compose up -d --no-deps web

# 4. Return it. 'ready' re-enables checks; it comes back UP after 'rise'.
runtime "set server $TARGET state ready"

The client address, in both modes

In mode http it is one line β€” option forwardfor in the frontend β€” with one rule attached to it that is easy to get wrong:

HAProxy appends X-Forwarded-For to the existing header list. The manual is explicit: the server must use only the last occurrence, β€œsince it is really possible that the client has already brought one”.

An application that reads the first X-Forwarded-For value is reading whatever the client chose to send. option forwardfor if-none adds the header only when one is not already present, which is the correct choice for an inner proxy that trusts the outer one β€” and exactly the wrong choice at the internet edge, where you want to overwrite. The client-identity lesson later in this part covers the trust boundary properly.

In mode tcp there is no header to add, because there is no HTTP. That is what the PROXY protocol exists for:

Configuration changeL4 in front of PostgreSQL, with client address preserved
frontend pg_in
  bind *:5432
  mode tcp
  option tcplog
  default_backend pg_servers

backend pg_servers
  mode tcp
  balance leastconn
  option pgsql-check user haproxy
  # send-proxy-v2 prepends a PROXY protocol v2 header describing the
  # original layer 3/4 addresses. The receiver must expect it.
  server pg1 pg-a:5432 check send-proxy-v2 inter 2s rise 2 fall 3
  server pg2 pg-b:5432 check send-proxy-v2 inter 2s rise 2 fall 3 backup

Note also that option pgsql-check is a real protocol-level check β€” HAProxy speaks enough of the PostgreSQL startup handshake to know the server answered as a database. A bare TCP connect would not distinguish a live PostgreSQL from a port that something else grabbed.

TLS: one file, concatenated

Configuration changebuild the combined PEM and reload
set -euo pipefail
DOMAIN=app.example.com
LIVE=/etc/letsencrypt/live/$DOMAIN
OUT=/etc/haproxy/certs/$DOMAIN.pem

sudo install -d -m 0750 /etc/haproxy/certs
sudo sh -c "cat $LIVE/fullchain.pem $LIVE/privkey.pem > $OUT.tmp"
sudo chmod 0640 "$OUT.tmp"
sudo mv "$OUT.tmp" "$OUT"

# Validate before you make it live. -c checks the config and exits.
docker compose exec -T proxy haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg

# Reload. HAProxy starts a new process and lets the old one finish.
docker compose kill -s HUP proxy

Writing to a temporary file and moving it into place is not fussiness. crt pointed at a file that is half-written when HAProxy reads it fails the same way a missing key does, and the window is exactly as long as cat takes.

When HAProxy is the right choice

SituationHAProxyWhy
L4 in front of a database or brokeryesmode tcp plus protocol-aware checks (pgsql-check, mysql-check, redis-check)
Backends must be drained on every deployyesRuntime API, no reload
You need detection under a secondyesfastinter
You want automatic certificatesnono ACME client; pair with certbot, or use Caddy or Traefik
Containers appear and disappear by labelnoTraefik reads labels; HAProxy needs DNS or a config generator
The proxy must also serve static filesnoHAProxy does not serve content
The team has never seen an HAProxy configconsider carefullythe language is precise and unforgiving, and that is a real cost

Knowledge check

Knowledge check Β· 4 questions

  1. Q1. Every request returns 503 and the application container logs show nothing at all for that period. What does that combination tell you?

  2. Q2. A backend uses `check inter 2s rise 2 fall 3` with the defaults for everything else. Worst case, how long can HAProxy keep sending requests to a backend that has just died?

  3. Q3. Which are true of `server-template web 3 web:8080 check resolvers docker init-addr none`? Select all that apply.

  4. Q4. A backend drained with `set server web_servers/web1 state drain` stays drained after the HAProxy container is restarted.

Passing score: 75%. Answers are checked in this browser.

Where next

Traefik takes the opposite approach to discovery: instead of asking DNS, it watches the Docker daemon directly. That is genuinely convenient and it is also a privilege grant, which the next lesson takes seriously.