bitdeals a66944077d docs: add README in English and Russian
Same structure as the bitmessage and bitcoind repositories: intro, usage with
compose and cli examples, a parameter table, and notes carrying the traps —
why the certificate must exist before start-up, why the runtime API is a unix
socket, why there are two loggers, and why HSTS is one day rather than a year.
2026-08-07 13:38:54 +00:00
2026-03-06 13:02:41 +03:00

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 is optional. 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. Set, the visitor's address is replaced by an HMAC of it in X-Client-Id and never passed on; empty, no such header is sent. Generate with openssl rand -base64 32 — 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 addresses. option httplog is never used: its default format opens with %ci:%cp, which would put every visitor's 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 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 address it was never given. X-Client-Id carries a pseudonym instead — HMAC-SHA256 of the address under XFF_HMAC_KEY. Being one-to-one with the address 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 address is brute-forced in seconds.
  • XFF_HMAC_KEY is optional, and an empty one disables the feature rather than weakening it. Unset, no X-Client-Id is sent at all and a rate limit downstream falls back to one bucket shared by every visitor; set, each visitor gets their own. What never happens is a pseudonym derived from an empty key. A value that is not valid base64 stops the container at configuration parsing — it cannot degrade quietly. Rotating the key resets rate-limit buckets (invisible to users) and changes every pseudonym, so activity either side of a rotation cannot be linked.
  • 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 address — nginx's $binary_remote_addr, ДС's request.client.host — sees this proxy 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 address; 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.
S
Description
haproxy dockerfile and config
Readme
62 KiB
Languages
Dockerfile 100%