HAProxy
| Use | Image | Status |
|---|---|---|
| HAProxy | haproxy:3.4.6-trixie | default |
| HAProxy | haproxy:3.2.25-trixie | allowed (previous LTS) |
Pinned, as every compose file and Dockerfile writes them:
haproxy:3.4.6-trixie@sha256:e273915691a8eab3f105bf3e39fd4c29a4ecf334e33421903b5589d25e14db26
haproxy:3.2.25-trixie@sha256:89c31eabc1ce443a13a42725a04df034b992c9d60580f4e6ef8b7bf5b15deddfThe tags and digests come from the approved images table; when they disagree, the table wins. HAProxy is the blue/green switch in front of a service's HTTP roles: release.md is how a deploy uses it, and this page is how it runs. Only LTS lines are approved (every even minor: 3.0, 3.2, 3.4); an odd minor is supported for months, not years.
The config
HAProxy reads every file in a folder, in name order: one global file, then one per service. The folder is mounted read-only (see config mounts); a deploy never rewrites it, it only changes a slot's state over the runtime socket.
global
log stdout format raw local0 info
# The deploy script's way in: `set server` needs level admin. The folder
# is a bind mount, so the host reaches the socket too.
stats socket /var/run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
# Which slot is live survives a restart only through this file: the deploy
# script writes `show servers state` here after every switch.
server-state-file /var/run/haproxy/server-state
defaults
log global
mode http
option dontlognull
load-server-state-from-file global
timeout connect 5s
timeout client 60s
timeout server 60s
# Docker's embedded DNS, so a recreated slot container is found again.
resolvers docker
nameserver dns 127.0.0.11:53
resolve_retries 3
timeout resolve 1s
timeout retry 1s
hold valid 10s
hold nx 1s
hold other 30s
hold refused 30s
hold timeout 30s
# HAProxy's own health, for the container health check.
frontend health
bind :8405
monitor-uri /health
# Every backend, slot and its state. Published on the loopback only.
listen stats
bind :8404
stats enable
stats uri /
stats refresh 5sfrontend api_front
bind :8080
option httplog
option forwardfor
default_backend api_back
backend api_back
balance roundrobin
option httpchk GET /health
http-check expect status 200
default-server inter 3s fall 3 rise 2 resolvers docker resolve-prefer ipv4 init-addr last,libc,none
# Both slots exist for good. `disabled` is only the cold-start state;
# every deploy sets the state over the runtime socket.
server blue api-blue:8080 check
server green api-green:8080 check disabledapi-blue and api-green are the two slot services in the same compose file.
Local development
From the folder that holds conf.d/:
docker run -d --name haproxy-dev \
-p 127.0.0.1:8080:8080 -p 127.0.0.1:8404:8404 \
-v "$PWD/conf.d:/usr/local/etc/haproxy/conf.d:ro" \
--tmpfs /var/run/haproxy:uid=99,gid=99 \
--health-cmd "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8405 && printf \"GET /health HTTP/1.0\r\n\r\n\" >&3 && grep -q \" 200 \" <&3'" \
--health-interval 5s --health-timeout 5s --health-retries 10 \
haproxy:3.4.6-trixie@sha256:e273915691a8eab3f105bf3e39fd4c29a4ecf334e33421903b5589d25e14db26 \
haproxy -f /usr/local/etc/haproxy/conf.ddocker exec haproxy-dev haproxy -c -f /usr/local/etc/haproxy/conf.dThe stats page is on http://127.0.0.1:8404. To reach your own api-blue/api-green containers by name, run it on their network with --network <network>; on the default bridge the slots show as down. Remove it with docker rm -f haproxy-dev.
On a server
Prepare the host once, next to the compose file: put the two files above in haproxy/conf.d/, and give the socket folder to the haproxy user (99).
mkdir -p haproxy/conf.d haproxy-run
mv conf.d/*.cfg haproxy/conf.d/
sudo chown 99:99 haproxy-runservices:
haproxy:
image: haproxy:3.4.6-trixie@sha256:e273915691a8eab3f105bf3e39fd4c29a4ecf334e33421903b5589d25e14db26
command: ["haproxy", "-f", "/usr/local/etc/haproxy/conf.d"]
volumes:
- ./haproxy/conf.d:/usr/local/etc/haproxy/conf.d:ro
- ./haproxy-run:/var/run/haproxy
ports:
- "8080:8080"
- "127.0.0.1:8404:8404"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8405 && printf 'GET /health HTTP/1.0\\r\\n\\r\\n' >&3 && grep -q ' 200 ' <&3"]
interval: 10s
timeout: 5s
retries: 3
stop_grace_period: 30s
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"docker compose exec haproxy bash -c 'exec 3<>/dev/tcp/127.0.0.1/8404 && printf "GET /;csv HTTP/1.0\r\n\r\n" >&3 && grep -q "^api_back,blue," <&3'The deploy script talks to ./haproxy-run/admin.sock on the host, as root or as a member of group 99, since the socket is 0660 and owned by 99: echo 'show servers state' | sudo socat stdio ./haproxy-run/admin.sock.
Why each line is there:
- No capabilities at all. The image runs as the
haproxyuser (99) from the start, and every port it binds is above 1024. Binding 80 or 443 inside the container would needNET_BIND_SERVICE; publish80:8080instead. ./haproxy-runis a bind mount, not a volume. The host's deploy script needs the socket, and the server-state file must survive a recreate, or a cold start puts traffic on whichever slot the config happens to name.resolvers dockerandinit-addr ... none. Without them HAProxy resolves a slot's name once and keeps a dead address after the slot is recreated, and refuses to start when a slot does not exist yet, which is every first deploy.monitor-urifor the health check. The image has no curl; bash's/dev/tcpis enough to ask HAProxy itself, without depending on any backend being up.read_only. HAProxy writes only to the socket folder.- Stats on
127.0.0.1. It lists every backend, slot and its health: operational detail, not something to publish. The traffic port (8080) is the one meant for the host's edge.
Upgrading
Default 3.4, allowed 3.2. A move between LTS lines is an image change and a restart: the config language is stable within 3.x, but check the current config with the new image before switching: docker run --rm -v "$PWD/haproxy/conf.d:/c:ro" <new image> haproxy -c -f /c. It fails on a keyword the new line removed.