A self-contained Docker image that acts as a TLS-terminating reverse proxy for self-hosted infrastructure. It wraps HAProxy 3.4 (built with QUIC/HTTP3 support via quictls) with YAML-driven config generation and fully automated Let's Encrypt certificate management.
Note
Credits to the HAProxy team and all contributors.
- How It Works
- Quick Start
- SSL Modes
- YAML Configuration Reference
- Environment Variables
- Certificate Management
- Architecture
- Troubleshooting
On container startup the following happens in order:
- acme-setup — installs acme.sh, downloads DH params, registers with Let's Encrypt
- haproxy-config — reads
/config/haproxy.yaml, runsgenerate_haproxy_config.shto produce/config/haproxy.cfg - rsyslog — creates the log socket HAProxy writes to
- haproxy — starts with the generated config
- acme — issues/renews certificates for every domain in
domain_mappings, hot-reloads HAProxy when certs change (no restart)
The http frontend (port 80) is fully managed. It handles ACME HTTP-01 challenges via an internal stick table and redirects everything else to HTTPS. For multi-server setups where an upstream manages its own certificates, use MIXED_SSL_MODE=true — see Mixed SSL Mode.
# docker-compose.yml
services:
haproxy:
image: docker.io/brycelarge/haproxy:latest
restart: unless-stopped
network_mode: host
volumes:
- ./config:/config
- ./config/logs:/var/log/haproxy
- ./config/certs:/etc/haproxy/certs
environment:
- ACME_EMAIL=you@example.com
- ACME_CHALLENGE_TYPE=http
- CONFIG_AUTO_GENERATE=trueCreate /config/haproxy.yaml (see YAML Configuration Reference) and start the container. Certificates are issued automatically on first boot.
MIXED_SSL_MODE=false (default) — HAProxy binds directly to :443 and terminates TLS there. HTTP/3 QUIC also binds to UDP :443.
client → :443 TCP/UDP → https-offloading frontend → backend
MIXED_SSL_MODE=true — A TCP passthrough frontend binds to :443, inspects SNI, and routes traffic by domain. This enables two things:
- Client IP preservation — traffic destined for HAProxy-managed backends is forwarded to an internal unix socket via PROXY protocol (
send-proxy-v2-ssl-cn) where TLS is terminated - Upstream TLS passthrough — traffic for domains managed by an upstream server (e.g. a WordPress VM running its own Let's Encrypt) is passed through directly, allowing the upstream to terminate TLS and handle its own ACME challenges
# HAProxy-managed domains:
client → :443 TCP → frontend https (tcp, SNI routing)
→ unix socket → https-offloading (TLS termination) → backend
# Upstream-managed domains (e.g. WordPress VM):
client → :443 TCP → frontend https (tcp, SNI routing) → upstream:443 (upstream handles TLS + its own certs)
HTTP/3 QUIC binds to UDP :8443 internally in this mode.
When MIXED_SSL_MODE=true, set a default_backend on both frontends pointing to the upstream server. The upstream handles its own TLS and Let's Encrypt — HAProxy just routes traffic to it:
frontend:
http:
raw:
- default_backend upstream-http
https:
raw:
- default_backend upstream-httpsHAProxy's stick table is checked first on port 80 — domains this container manages are answered directly; everything else falls through to upstream-http.
FRONTEND_IP_PROTECTION=true — Adds a second SSL offloading frontend (https-offloading-ip-protection) on a separate unix socket. Use it to restrict certain domains to specific source IPs. Add restriction rules via frontend.https-offloading-ip-protection.raw and map domains to it in domain_mappings.
Place your config at /config/haproxy.yaml. Use haproxy.yaml.example as a starting point:
cp haproxy.yaml.example /config/haproxy.yamlRaw HAProxy global directives appended to the generated global section.
global:
- maxconn 10000
- tune.bufsize 32768
- tune.ssl.cachesize 100000Raw HAProxy defaults directives.
defaults:
- option http-keep-alive
- timeout client 30s
- timeout connect 5s
- timeout server 240s
- timeout tunnel 43200sOptional raw directives injected into the generated frontend sections. The frontends themselves are fully generated — you only inject additional lines via raw.
| Key | Injected into |
|---|---|
frontend.https-offloading.raw[] |
frontend https-offloading |
frontend.https-offloading-ip-protection.raw[] |
frontend https-offloading-ip-protection |
frontend.https.raw[] |
frontend https (MIXED_SSL_MODE only) |
frontend:
https-offloading:
raw:
- acl is_websocket hdr(Upgrade) -i WebSocket
- http-request set-header Connection upgrade if is_websocket
- http-request set-header Upgrade websocket if is_websocket
https-offloading-ip-protection:
raw:
- acl allowed_src src 10.0.0.0/8
- http-request deny unless allowed_src
domains:
- backend: frontend-offloading-ip-protection
patterns:
- admin.example.comMaps domains to frontends and backends. This is also the source of truth for which certificates acme.sh will issue — every domain listed here gets a Let's Encrypt certificate automatically.
domain_mappings:
- domains:
- example.com
- www.example.com
frontend: https-offloading
backend: my-app
- domains:
- admin.example.com
frontend: https-offloading-ip-protection
backend: admin-appDefines upstream servers.
Backend fields:
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Referenced by domain_mappings |
mode |
string | http |
http or tcp |
ssl |
bool | false |
Connect to upstream over SSL |
ssl_verify |
bool | false |
Verify upstream SSL cert |
enable_h2 |
bool | false |
Enable HTTP/2 (alpn h2 check-reuse-pool idle-ping 30s) |
use_send_proxy |
bool | false |
Send PROXY protocol to upstream |
cache |
bool | false |
Enable response cache for images |
options |
list | — | Raw option directives |
http_check |
list | — | Raw http-check directives |
raw |
list | — | Raw HAProxy directives injected into the backend block |
hosts |
list | required | Upstream addresses |
Host formats — simple string:
hosts:
- "10.0.0.10:8080"
- "10.0.0.11:8080 backup"Host formats — object with per-host health check:
hosts:
- host: "10.0.0.10:8080"
enable_h2: true
check:
type: http # http | ssl | tcp
uri: /health
interval: 2000
fall: 3
rise: 2
slowstart: "10s"Full backend example:
backends:
- name: api
mode: http
enable_h2: true
options:
- "httpchk GET /health"
- "allbackups"
http_check:
- "expect status 200"
raw:
- "http-request set-header Host api.example.com"
hosts:
- host: "10.0.0.10:8080"
check:
type: tcp
interval: 1000
fall: 2
rise: 1
slowstart: "10s"
- "10.0.0.11:8080 backup"| Variable | Required | Default | Description |
|---|---|---|---|
ACME_EMAIL |
Yes | — | Email for Let's Encrypt registration |
ACME_CHALLENGE_TYPE |
No | dns_cf |
http or dns_cf |
CONFIG_AUTO_GENERATE |
No | false |
Regenerate config from YAML on startup |
MIXED_SSL_MODE |
No | false |
Enable TCP passthrough + unix socket SSL offloading |
FRONTEND_IP_PROTECTION |
No | false |
Enable IP-restricted SSL offloading frontend |
HAPROXY_BIND_IP |
No | 0.0.0.0 |
IP address HAProxy binds to |
HAPROXY_THREADS |
No | — | Number of HAProxy threads |
H3_29_SUPPORT |
No | false |
Include h3-29 in alt-svc header |
QUIC_MAX_AGE |
No | 86400 |
alt-svc max-age for HTTP/3 |
HA_DEBUG |
No | false |
Enable debug logging in config generation |
TZ |
No | EST |
Container timezone |
Certificates are stored in /etc/haproxy/certs/ and managed by acme.sh. HAProxy is hot-reloaded when certificates change — no downtime.
Every domain listed in domain_mappings gets a certificate automatically. No extra configuration needed.
Port 80 must be reachable from the internet. HAProxy handles the ACME HTTP-01 challenge internally via a stick table — no separate backend or pre-hook needed.
ACME_CHALLENGE_TYPE=http
ACME_EMAIL=you@example.comUse when port 80 cannot be exposed. On first boot, /config/acme/acme.sh.env is created. Shut down the container, add your credentials, then restart:
# /config/acme/acme.sh.env
export CF_Token=your_token
export CF_Account_ID=your_account_id
export CF_Zone_ID=your_zone_idACME_CHALLENGE_TYPE=dns_cf
ACME_EMAIL=you@example.comNote
Cloudflare API token needs Zone:DNS:Edit permission.
| Volume | Purpose |
|---|---|
/config |
YAML config, generated haproxy.cfg, ACME state |
/var/log/haproxy |
HAProxy access and error logs |
/etc/haproxy/certs |
Deployed TLS certificates (symlinked from acme.sh output) |
| Port | Protocol | Purpose |
|---|---|---|
| 80 | TCP | HTTP + ACME HTTP-01 challenges |
| 443 | TCP | HTTPS |
| 443 | UDP | HTTP/3 QUIC |
acme-setup → haproxy-config → rsyslog → haproxy → acme
- acme-setup — installs acme.sh, downloads DH params, registers with Let's Encrypt
- haproxy-config — generates
haproxy.cfgfromhaproxy.yaml - rsyslog — creates
/var/lib/haproxy/dev/logsocket - haproxy — starts the proxy
- acme — issues/renews certs, hot-reloads HAProxy via
haproxy -sf
generate_haproxy_config.sh reads haproxy.yaml and:
- Renders static sections (
global,defaults,cache, frontends) from template files in/scripts/templates/usingenvsubst - Injects
global[]anddefaults[]YAML entries viasedplaceholders - Injects
frontend.*.raw[]entries viasedplaceholders - Generates
backendblocks dynamically frombackends[]anddomain_mappings[]
Config parse error on startup:
docker exec haproxy haproxy -c -f /config/haproxy.cfgCertificate not issuing:
docker exec haproxy cat /var/log/acme-renewals.logHAProxy not starting:
docker exec haproxy cat /var/log/haproxy/haproxy.logEnable debug logging for config generation:
HA_DEBUG=true