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:

  1. Edit roles/haproxy/vars/main.yml
  2. Add a block to the appropriate section
  3. Run the playbook
  4. 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.