Files
haproxy/README.md
T
bitdeals 1f9452b773
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 37s
feat: generate XFF_HMAC_KEY when none is passed
An empty key switches the pseudonym off: no X-Client-Id is sent, and a
rate limit downstream falls back to one bucket shared by every visitor.
That is the one state nobody chooses on purpose and the easiest to reach
by forgetting a line in a .env — so the entrypoint now fills the key in
with `openssl rand -base64 32` when nothing else did.

Nobody picks this value, nothing outside the container needs to know it,
and no two deployments need the same one, which is what makes generating
it the right default rather than a convenience. A fresh key per container
start costs a reset of the downstream rate-limit buckets — invisible
against a one-minute window — and makes pseudonyms from before and after
unlinkable, which is the property the key exists for rather than a loss.
Passing one explicitly still wins, for whoever wants pseudonyms stable
across restarts or identical on two proxies.

base64 and not hex, deliberately: HAProxy's hmac() decodes the key as
base64, and hex would be accepted and silently decoded into something
else — a usable key, but it would quietly cost the guarantee that a
malformed one stops the container at configuration parsing.

The base image's entrypoint is the haproxy binary with no shell in
between, so the wrapper is the whole chain and execs the same binary with
the same arguments. CMD is restated rather than inherited.

Verified by building the image and checking the config in all three
states: unset (wrapper reports it generated one, config parses), set and
valid (wrapper silent, config parses), set and not base64 (`[ALERT]
invalid args in converter 'hmac' : failed to parse key`, container
refuses to start).

READMEs updated in both languages, and "address" is spelled "IP address"
throughout — it was never anything else.
2026-08-10 14:50:57 +00:00

13 KiB

Intro

Русская версия: README.ru-RU.md

HAProxy is a TCP/HTTP load balancer and reverse proxy. Here it is the public edge of a BitDeals site: it terminates TLS on 443, forwards everything to the web container, and routes ACME challenges to certbot.

HAProxy running in a docker container with a baked-in configuration.

This repository covers the docker deployment only. The image is bitnami/haproxy with one file copied into it.

Usage

The container has two ports, 80 and 443, and both are the public site.

The third channel is not a port: the HAProxy runtime API listens on a unix socket at /var/lib/haproxy/admin.sock, on a volume shared with the certbot container, which uses it to install a renewed certificate into the running process without a restart. The API is level admin and has no authentication, so who can open it is decided by file permissions — see Notes.

There is one environment variable, XFF_HMAC_KEY, and it needs no setting: the entrypoint generates one when it is absent. Everything else is in docker/haproxy.cfg, which is copied into the image at build time, so changing the routing means rebuilding and redeploying.

The certificate is read from /usr/local/etc/haproxy/certificates/site.pem, mounted read-only from a volume shared with certbot. It must exist before the container starts — see Notes.

docker-compose

services:
  haproxy:
    build:
      context: https://git.bitdeals.org/private/haproxy.git
      dockerfile: ./docker/Dockerfile
    image: registry.bitdeals.org/haproxy
    restart: unless-stopped
    depends_on:
      - nginx
      - certbot
    volumes:
      - certificates:/usr/local/etc/haproxy/certificates:ro
      - haproxy_admin:/var/lib/haproxy   # runtime API socket — certbot only
    ports:
      - "80:80"
      - "443:443"

volumes:
  certificates:
  haproxy_admin:

The two backends are named after the services they reach: nginx:80 for the site and certbot:380 for ACME challenges. Both names have to resolve inside the compose project, so those services must share a network with this one.

docker cli

docker run -d \
  -p 80:80 \
  -p 443:443 \
  -v certificates:/usr/local/etc/haproxy/certificates:ro \
  registry.bitdeals.org/haproxy

Anything after the image name replaces the daemon's own arguments, so a config check against a mounted file needs no new image:

docker run --rm -v "$PWD/docker/haproxy.cfg:/tmp/haproxy.cfg:ro" \
  registry.bitdeals.org/haproxy -c -f /tmp/haproxy.cfg

build and publish

A push to main builds and publishes the image (.gitea/workflows/build.yaml), tagging it three ways: <version>.<sha7> to deploy by, <version> to read, and latest for compose and Watchtower. A nightly cron rebuilds from the same sources. By hand, when the registry credentials are at hand:

docker build . --file docker/Dockerfile --tag registry.bitdeals.org/haproxy
docker push registry.bitdeals.org/haproxy

The build context is the repository root, not docker/: the Dockerfile copies ./docker/haproxy.cfg, so a context of ./docker cannot see it and the build fails on the COPY.

Parameters

Container images are configured using parameters passed at runtime.

Parameter Function
-p 80 Plain HTTP. Redirects to HTTPS with a 301, except the ACME challenge path, which must stay reachable here for renewals to work
-p 443 HTTPS. Needs site.pem in the certificates volume before the container starts
-v /usr/local/etc/haproxy/certificates Certificate directory, read-only. Only site.pem is read, at bind time. certbot writes it through the same volume mounted read-write at /etc/certificates
-v /var/lib/haproxy Runtime API socket (admin.sock, level admin, no authentication). Mount it into certbot and nothing else — see Notes
-e XFF_HMAC_KEY Optional, base64. The visitor's IP is replaced by an HMAC of it in X-Client-Id and never passed on. Leave it unset and the entrypoint generates one per container start; pass one only to keep pseudonyms stable across restarts or identical on two proxies — see Notes

Routing, timeouts and TLS settings are not parameters: they live in docker/haproxy.cfg and ship inside the image.

Notes

  • site.pem must exist before the container starts. bind ... ssl crt is resolved while the configuration is parsed, so an empty volume is a fatal start-up error, not a warning — HAProxy exits, and without a restart policy it stays down. certbot writes a self-signed placeholder on its own first start precisely to break this circle, which is why the service ships with restart: unless-stopped; order it after certbot with depends_on in a project that defines one.
  • A certificate installed over the runtime API lives in memory only. That is why the volume is mounted read-only here: set ssl cert + commit ssl cert never write to disk. The file on the volume is certbot's copy, and it is what HAProxy re-reads after a restart — so the two paths agree without HAProxy needing write access.
  • The runtime API is a full administrative channel with no password. Anyone who can open it can install a different certificate and private key, redirect a backend to another address, or take servers out of rotation — that is, silently man-in-the-middle the site. Treat access to admin.sock as equivalent to holding the TLS private key, and mount that volume into certbot and nothing else. expose-fd listeners, which would additionally hand a client of the socket the listening sockets themselves, is deliberately not set: it exists for seamless reloads, which this image never performs.
  • A unix socket, because a port cannot be restricted. expose: publishes nothing to the host but restricts nothing either, and docker networks have no per-port rules — so a TCP runtime API is open to every container sharing a network, which here includes nginx, since HAProxy must be able to call it. A socket on a volume is reachable only by containers that mount the volume, and that is the whole access-control story. It also keeps the private key, which crosses this channel on every renewal, off the network.
  • HAProxy needs write access to the socket's directory, not just the file. It binds by creating <path>.<pid>.tmp and renaming it over the target — so the image creates /var/lib/haproxy owned by uid 1001, and docker carries that ownership onto an empty named volume mounted there. The rename is also why a stale socket left by a previous run is harmless. certbot connects as root and is unaffected by the mode 660.
  • The redirect to HTTPS carries one exception, and it is load-bearing. Port 80 answers 301 for everything except /.well-known/acme-challenge/, which Let's Encrypt validates over plain HTTP — redirect that and every renewal stops. The rule is written above use_backend because that is the order it runs in: http-request rules are evaluated before backend selection whatever the file says, and HAProxy warns when the two disagree.
  • HSTS is one day, not the customary year. It is a one-way door: a browser that has seen the header refuses plain HTTP to this host until it expires, and nothing server-side can call that back. A day keeps a lapsed certificate recoverable. Raise it in steps — 86400, 2592000, 31536000 — once renewals have been seen to work. includeSubDomains and preload are deliberately absent: the first binds names this proxy does not serve, the second is effectively permanent.
  • Backend addresses are re-resolved, and that is not the default. Both server lines carry resolvers docker, so the nginx and certbot names are looked up again while HAProxy runs. Without it a name is resolved once at boot and kept for the life of the process, and a container recreated on a new IP — which is what Watchtower does on every deploy — is never noticed. init-addr libc,none is the other half: it lets HAProxy start when a backend is not up yet, instead of refusing to parse a name it cannot resolve.
  • Logging goes to stdout, and option dontlog-normal makes it errors-only. log stdout format raw local0 needs no syslog daemon — docker logs collects it. A successful request writes nothing; a 503, a backend with no server, a refused handshake do. Drop dontlog-normal deliberately if a full access log is wanted, and understand that it is also what keeps the volume down.
  • There are two loggers, and forgetting the second one leaks IP addresses. option httplog is never used: its default format opens with %ci:%cp, which would put every visitor's IP address into docker logs and undo the pseudonym the frontends mint. A hand-written log-format puts the pseudonym in that first field instead. The trap is error-log-format, which covers what happens before a transaction exists — a refused TLS handshake, and TLS 1.2 is now the floor — and whose default opens the same way. Both are set here. The pseudonym is therefore computed by a tcp-request connection rule on accept, in sess scope, because an http-phase rule would not have run yet when a handshake fails.
  • Only the method and path are logged, never the query string. %{+Q}r would carry it, and a token that ever appeared in a URL would be written down for as long as the log is kept.
  • The visitor's IP address stops here. There is no option forwardfor: X-Forwarded-For is deleted in both frontends and never filled in, so nothing behind this proxy can log an IP it was never given. X-Client-Id carries a pseudonym instead — HMAC-SHA256 of the IP address under XFF_HMAC_KEY. Being one-to-one with the IP it is exactly as good a rate-limiting key, and without the key it is not reversible. HMAC rather than a bare digest because IPv4 is 2^32 values and an unkeyed hash of an IP address is brute-forced in seconds.
  • XFF_HMAC_KEY is generated when absent, not left empty. An empty key disables the feature — no X-Client-Id at all, and a rate limit downstream falls back to one bucket shared by every visitor, which is the one state nobody chooses on purpose and the easiest to reach by forgetting a line in a .env. So the entrypoint fills it in with openssl rand -base64 32 when nothing else did. Nobody picks this value, nothing outside the container needs to know it, and no two deployments need the same one. A fresh key per container start costs a reset of the downstream rate-limit buckets — invisible against a one-minute window — and makes pseudonyms from before and after unlinkable, which is the property the key exists for rather than a loss. Pass a value explicitly only to keep pseudonyms stable across restarts, or identical on two proxies. The config still handles an empty key, because haproxy.cfg can be run outside this image. And a value that is not valid base64 still stops the container at configuration parsing — it cannot degrade quietly, which is also why the generated one is base64 and not hex: hex would be accepted here and silently decoded as base64 into something else.
  • Both deletes are unconditional. X-Forwarded-For and X-Client-Id are dropped whether or not a key is configured, so a header a client sent can never be mistaken downstream for one this proxy minted. Same for X-Forwarded-Proto, which each frontend sets to its own scheme rather than passing on the client's claim.
  • The consumer must still be told to use it. A downstream rate limit keyed on the socket IP address — nginx's $binary_remote_addr, ДС's request.client.host — sees this proxy's IP for every request and degenerates to one shared bucket. It has to key on X-Client-Id, and trust that header only from this proxy's IP; frontend/docker/rate-limit.conf in the bitdeals-ng repository is the worked example.
  • TLS is pinned in global, not left to OpenSSL. TLS 1.2 is the floor, the cipher list is ECDHE-only in both ECDSA and RSA variants — certbot issues ECDSA, the self-signed placeholder is RSA — and session tickets are off so forward secrecy is not undone by a long-lived ticket key. alpn h2,http/1.1 on the bind offers HTTP/2 to browsers; the backend stays HTTP/1.1 and HAProxy translates. No HSTS header is sent, deliberately: it would be premature while port 80 still serves the site rather than redirecting, and it is hard to take back once browsers have cached the policy.
  • timeout http-request 10s is what bounds the header phase, and timeout client cannot stand in for it: that one is an inactivity timeout and resets on every byte received, so a client dripping a byte at a time holds a connection open indefinitely. This one is absolute.
  • The process runs as uid 1001 and still binds 80 and 443. That works because Docker sets net.ipv4.ip_unprivileged_port_start=0 in containers by default; a host or runtime that restores the traditional value will make the container fail to bind.
  • The base image is unpinned. FROM bitnami/haproxy means :latest, and the nightly rebuild cron picks up whatever that tag points at — a HAProxy minor version can change under a build nobody triggered, and Watchtower then rolls it out. Pin FROM bitnami/haproxy:<version> for reproducible builds.