Skip to content

HAProxy ​

UseImageStatus
HAProxyhaproxy:3.4.6-trixiedefault
HAProxyhaproxy:3.2.25-trixieallowed (previous LTS)

Pinned, as every compose file and Dockerfile writes them:

text
haproxy:3.4.6-trixie@sha256:e273915691a8eab3f105bf3e39fd4c29a4ecf334e33421903b5589d25e14db26
haproxy:3.2.25-trixie@sha256:89c31eabc1ce443a13a42725a04df034b992c9d60580f4e6ef8b7bf5b15deddf

The 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.

conf.d/00-global.cfg
haproxy
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 5s
conf.d/10-api.cfg
haproxy
frontend 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 disabled

api-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
sh
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.d
check docker run
sh
docker exec haproxy-dev haproxy -c -f /usr/local/etc/haproxy/conf.d

The 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).

prepare the host
sh
mkdir -p haproxy/conf.d haproxy-run
mv conf.d/*.cfg haproxy/conf.d/
sudo chown 99:99 haproxy-run
compose.yml
yaml
services:
  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"
check compose
sh
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 haproxy user (99) from the start, and every port it binds is above 1024. Binding 80 or 443 inside the container would need NET_BIND_SERVICE; publish 80:8080 instead.
  • ./haproxy-run is 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 docker and init-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-uri for the health check. The image has no curl; bash's /dev/tcp is 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.