Multi-Protocol Load Balancing with HAProxy and Ansible
HAProxy is one of those tools that seems simple until you need it to do four different things at once. I use HAProxy as an SSL passthrough for some services, SSL termination for others, raw TCP load balancing on arbitrary ports, and syslog forwarding — all through the same instance.
Managing this through hand-edited configs across multiple hosts is painful. This guide walks through an Ansible role that makes the whole thing easier to manage: define your proxies in YAML, run the playbook, and it will restart the service only if something changed.
The Problem
A typical homelab accumulates services that need different proxy behaviors:
- Splunk SH Cluster — SSL passthrough (Splunk handles its own TLS) - More Secure
- Splunk HEC — SSL termination at the proxy, plain HTTP backends, Less Secure
- Custom TCP services — Raw TCP load balancing on weird ports
- SYSLOG load balancing — Log forwarding to Cribl or Syslog-NG
Managing all this in one HAProxy config is doable, but maintaining it across multiple proxy hosts manually causes drift. One host gets updated, the other doesn’t, and you spend an hour figuring out why traffic is broken. In certain scenarios, reloading the config in prod automatically might not be the best approach, so this is a personal dilemma. My recommendation: automatic deploy via runner, manual reload. This gives you the ability to restart HAProxy on the backup VIP, trigger a failover from the main, test the config, then either proceed or bring the main back up and return the VIP to the old config.
Role Structure
roles/haproxy/
├── defaults/main.yml # package and service defaults
├── files/
│ ├── certs/fullchain.pem # SSL certificate chain
│ └── dhparam # Diffie-Hellman parameters
├── tasks/
│ ├── main.yml # orchestrator (imports below)
│ ├── install.yml # package install/upgrade
│ ├── config.yml # template + cert deployment
│ └── restart.yml # conditional service restart
├── templates/
│ └── haproxy.cfg.j2 # the config template
└── vars/
└── main.yml # all proxy definitions go here
The key insight is that vars/main.yml contains all proxy definitions. The Jinja2 template iterates over them. Adding a new service means adding a YAML block — no template editing required.
Traffic Flow
The template renders a config with this flow:
Client HTTP :80 ──→ 301 redirect to HTTPS
Client HTTPS :443 ──→ tcp443 frontend (SNI inspection)
├── SNI match? ──→ TCP backend (passthrough)
└── no match? ──→ recir_https backend
└── abns loopback
└── https frontend
└── SSL termination
└── HTTP backend
The tcp443 frontend inspects the TLS ClientHello SNI field. If the hostname matches an entry in tcpvars, traffic passes through as raw TCP (the backend handles TLS). Otherwise, traffic loops back through an abstract namespace socket to the https frontend for SSL termination.
TCP frontends and syslog forwarders bind on their own dedicated ports.
Configuration
All proxy definitions live in roles/haproxy/vars/main.yml. Four sections, four proxy types.
General Settings
1 2 | proxyconfig_server_bind: 1.2.3.34 # IP HAProxy binds to proxyconfig_server_bind_interface: ens192 # network interface |
SSL Tunneling (tcpvars)
TCP passthrough on port 443 using SNI-based routing. HAProxy doesn’t decrypt anything — the backend handles TLS.
1 2 3 4 5 6 7 8 | tcpvars: hec: fqdn: hec.example.com description: cribl hec endpoint backend: rhel9-cribl: addr: rhel9-cribl.example.com port: 8088 |
For multiple backends:
1 2 3 4 5 6 7 8 9 10 11 12 | tcpvars: hec: fqdn: hec.example.com description: cribl hec cluster balance: roundrobin backend: cribl-01: addr: rhel9-cribl-01.example.com port: 8088 cribl-02: addr: rhel9-cribl-02.example.com port: 8088 |
SSL Termination (httpvars)
HAProxy terminates TLS and forwards plain HTTP. Host-based ACLs route to the right backend.
1 2 3 4 5 6 7 8 9 | httpvars: heimdall: host: heimdall fqdn: heimdall.example.com balance: roundrobin backend: server1: addr: 10.0.0.19 port: 80 |
The https frontend adds HSTS headers (2-year max-age) and HTTP/2 ALPN support automatically.
TCP Frontends (tcpfrontends)
Raw TCP load balancing on arbitrary ports. Each entry creates its own frontend and backend pair.
1 2 3 4 5 6 7 8 9 | tcpfrontends: archer_syslog: listen: 1515 backend_name: crbl_lcon_1515 balance: leastconn backend: cbl_t00_s1: addr: 1.2.3.11 port: 1515 |
Syslog Forwarding (syslogvars)
Uses HAProxy’s log-forward feature. Supports TCP and UDP binds, optional SSL, and RFC 5424.
1 2 3 4 5 6 7 8 9 | syslogvars: cribl_rrb_514: name: syslog_514 listen: 514 balance: roundrobin backend: cribl_rrb_514-02: addr: rhel9-cribl.example.com port: 9519 |
Optional fields for syslog:
1 2 3 4 5 6 7 8 9 10 11 12 13 | syslogvars: secure_syslog: name: syslog_tls listen: 6514 dgram_bind: false # TCP only, no UDP log_len: 65535 # max message length log_format: rfc5424 bind_options: "ssl verify none crt /etc/haproxy/ssl/syslog.pem" server_options: "ssl verify none" backend: target: addr: log-server.example.com port: 6514 |
Disabling Without Deleting
Every entry supports enabled: false to exclude it from the rendered config without removing the YAML:
1 2 3 4 5 6 7 8 9 | tcpvars: soar: enabled: false fqdn: soar.example.com description: soar frontend - disabled for maintenance backend: soar-01: addr: soar-01.example.com port: 8443 |
Task Flow
The role orchestrates three task files:
install.yml
Handles HAProxy package management. I use Zenetys builds (haproxy32z from packages.zenetys.com) for newer features. The task checks the installed version, backs up the existing config if switching versions, removes the old package, and installs the new one. The service is enabled but left stopped.
config.yml
Renders the template to /etc/haproxy/haproxy.cfg, copies SSL certificates to /etc/haproxy/certs/ with mode 0400, and deploys the DH parameters file. Each task registers a result variable.
restart.yml
Restarts HAProxy only if any of the config, certs, or dhparam changed. This makes the playbook idempotent — run it 100 times, HAProxy only restarts when something actually changed.
SSL Certificates
Place your certificate chain in roles/haproxy/files/certs/. The file should contain the private key and full chain concatenated. HAProxy loads all .pem files from /etc/haproxy/certs/ automatically.
DH parameters go in roles/haproxy/files/dhparam. Both are deployed with mode 0400 (root read-only).
Generate dhparam if you don’t have one:
1 | openssl dhparam -out dhparam 2048 |
Running It
The playbook:
1 2 3 4 5 | # playbooks/haproxy_config.yml - hosts: haproxy become: yes roles: - haproxy |
Inventory:
1 2 3 4 5 6 | all: children: haproxy: hosts: rhel9-prox-01: {} rhel9-prox-02: {} |
Run:
1 | ansible-playbook playbooks/haproxy_config.yml
|
Dry run with diff:
1 | ansible-playbook playbooks/haproxy_config.yml --check --diff |
Quick Reference
Adding a new proxy:
- Edit
roles/haproxy/vars/main.yml - Add a block to the appropriate section
- Run the playbook
- HAProxy restarts automatically (only if config changed)
The sections:
| Section | Purpose |
|---|---|
tcpvars |
SSL passthrough on 443 (SNI routing) |
httpvars |
SSL termination with HTTP backends |
tcpfrontends |
Raw TCP on arbitrary ports |
syslogvars |
Syslog forwarding (log-forward) |
Common options available in all sections:
| Field | Purpose |
|---|---|
enabled |
Set false to disable without deleting |
description |
Comment rendered in haproxy.cfg |
balance |
Load balancing algorithm (roundrobin, leastconn, etc.) |
Why This Approach
The alternative is templating individual proxy blocks, but that gets messy fast when you have four different proxy types with different requirements. Making everything data-driven means the template handles the complexity once, and adding services is just YAML.
Both proxy hosts receive identical configs, so failover works. Run the playbook and both get updated in the same run — or, following the advice above, push the config everywhere and reload one node at a time.
The conditional restart is the key quality-of-life feature. Nothing’s worse than a playbook that restarts services on every run. This only restarts when something actually changed, which makes it safe to run frequently or on a schedule.