bitdeals 1824e741bb
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 30s
fix: unpin the base again; the stand is what catches a bad one
ea8d5e0 pinned `FROM bitnami/haproxy` by digest after the base broke the public
door twice in three days. That is not the trade this project wants: there are no
version tags to pin to, only `latest`, so a pin means a digest raised by hand for
every security update of the base.

What actually catches a bad base is the testnet stand. Watchtower there picks up
each rebuild within seconds, which is precisely how both incidents surfaced —
loudly, on a stand, and not in production. Production runs no Watchtower and is
updated by hand, so a base that breaks the door is seen here first and never
reaches it unattended.

The `ENV OPENSSL_FIPS=no` removal from ea8d5e0 stays. Restoring it would restore
this morning's outage: on the current base "no" is what stops HAProxy loading an
ECDSA certificate. The comment above FROM keeps both measurements, because the
right value depends on the base and the next base may want the other one — that
is the first thing to check the next time this image will not start.

Verified on testnet2 with the image built from this file: config check clean
against the project config and the live certificate, curl 200, TLSv1.3.
2026-09-04 11:59:16 +00: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 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

the log

docker compose logs haproxy on a running node:

haproxy-1  | XFF_HMAC_KEY was not set — generated one for this container.
haproxy-1  | [NOTICE]   (1) : Automatically setting global.maxconn to 32745.
haproxy-1  | rlj13BvDLpJLzzHbutTiC5fjK0OR4YRlPrYnJIHpO7Y= [18/Aug/2026:10:25:25.196] http 1/1 Success
haproxy-1  | wgM6JB1uN2AGFKkl67+O7SCx+DHrGttufQw0HDQMSIU= [18/Aug/2026:10:45:10.700] https~ 1/1 SSL handshake failure
haproxy-1  | 5056er7Wogz59zFJfaNNelKpEnxzEISztzbgn/omnHg= [18/Aug/2026:10:54:38.337] https~ 1/1 Connection closed during SSL handshake
haproxy-1  | 6TPbf9FXhRxBoy+SSpFrOuTvYjZF3RjTQmdCteZWH3s= [19/Aug/2026:04:13:17.521] https~ default-backend-http/main 400 289 0/497 SSTP_DUPLEX_POST /sra_{BA195980-CD49-458b-9E23-C84EE0ADCD75}/

No line carries an IP address, and none can. The first field is the pseudonym from X-Client-Id — HMAC-SHA256 of the visitor's address under XFF_HMAC_KEY, base64 — and it is everything the log knows about who connected. Both loggers are written out by hand for exactly that reason: their defaults open with %ci:%cp, the address and the port. See Notes.

The four request lines are the two formats:

  • connection level, %[var(sess.cid)] [%tr] %ft %ac/%fc %[fc_err_str] — pseudonym, time, frontend, connection counts, how the connection ended. Everything that dies before a request exists lands here: a client that offers nothing above TLS 1.1, a scanner that hangs up mid-handshake, a probe that opened the port and left.
  • transaction level, … %ft %b/%s %ST %B %TR/%Ta %HM %HP — pseudonym, time, frontend, backend/server, status, bytes, timers, method, path. The last line is a 400 for a stray SSTP probe. The path ends where the query string starts; that part is never logged.

Successful requests write nothing at all (option dontlog-normal), so a log this quiet is the normal state of a working node, not a sign that the loggers are misconfigured.

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.
S
Description
haproxy dockerfile and config
Readme
110 KiB
Languages
Dockerfile 73.5%
Shell 26.5%