HAProxy is the industry-standard open-source load balancer used by companies like GitHub, Reddit, and Stack Overflow. Automating HAProxy deployment with Ansible ensures consistent, repeatable configurations across environments.
Why Automate HAProxy with Ansible
Managing HAProxy manually creates drift between environments and makes scaling painful. Ansible solves this by:
- Templating configurations from inventory variables
- Rolling deployments that update backends without downtime
- Consistent SSL/TLS certificate management
- Health check validation after every change
Prerequisites
- Ansible 2.14+ installed on controller
- Target servers running Ubuntu 22.04+ or RHEL 8+
- SSH access with sudo privileges
- SSL certificates (for HTTPS frontends)
HAProxy Installation Playbook
---
- name: Deploy HAProxy Load Balancer
hosts: loadbalancers
become: true
vars:
haproxy_version: "2.8"
haproxy_stats_port: 9000
haproxy_stats_user: "admin"
haproxy_stats_password: "{{ vault_haproxy_stats_password }}"
haproxy_frontends:
- name: http_front
bind: "*:80"
default_backend: web_servers
mode: http
- name: https_front
bind: "*:443 ssl crt /etc/haproxy/certs/site.pem"
default_backend: web_servers
mode: http
haproxy_backends:
- name: web_servers
mode: http
balance: roundrobin
options:
- httpchk GET /health
- forwardfor
servers: "{{ groups['webservers'] }}"
server_port: 8080
check_params: "check inter 5s fall 3 rise 2"
tasks:
- name: Install HAProxy PPA (Ubuntu)
ansible.builtin.apt_repository:
repo: "ppa:vbernat/haproxy-{{ haproxy_version }}"
state: present
when: ansible_os_family == "Debian"
- name: Install HAProxy
ansible.builtin.package:
name: haproxy
state: present
- name: Create certificate directory
ansible.builtin.file:
path: /etc/haproxy/certs
state: directory
owner: haproxy
group: haproxy
mode: "0700"
- name: Deploy SSL certificate bundle
ansible.builtin.copy:
src: "{{ haproxy_ssl_cert_path }}"
dest: /etc/haproxy/certs/site.pem
owner: haproxy
group: haproxy
mode: "0600"
notify: Reload HAProxy
when: haproxy_ssl_cert_path is defined
- name: Deploy HAProxy configuration
ansible.builtin.template:
src: haproxy.cfg.j2
dest: /etc/haproxy/haproxy.cfg
owner: root
group: root
mode: "0644"
validate: "haproxy -c -f %s"
notify: Reload HAProxy
- name: Enable and start HAProxy
ansible.builtin.systemd:
name: haproxy
enabled: true
state: started
- name: Configure kernel parameters for HAProxy
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
sysctl_set: true
reload: true
loop:
- { key: "net.ipv4.ip_nonlocal_bind", value: "1" }
- { key: "net.core.somaxconn", value: "65535" }
- { key: "net.ipv4.tcp_max_syn_backlog", value: "65535" }
handlers:
- name: Reload HAProxy
ansible.builtin.systemd:
name: haproxy
state: reloaded
HAProxy Configuration Template
Create templates/haproxy.cfg.j2:
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin
stats timeout 30s
user haproxy
group haproxy
daemon
maxconn 50000
tune.ssl.default-dh-param 2048
defaults
log global
mode http
option httplog
option dontlognull
option http-server-close
option redispatch
retries 3
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s
timeout http-keep-alive 10s
errorfile 400 /etc/haproxy/errors/400.http
errorfile 403 /etc/haproxy/errors/403.http
errorfile 408 /etc/haproxy/errors/408.http
errorfile 500 /etc/haproxy/errors/500.http
errorfile 502 /etc/haproxy/errors/502.http
errorfile 503 /etc/haproxy/errors/503.http
errorfile 504 /etc/haproxy/errors/504.http
# Stats page
listen stats
bind *:{{ haproxy_stats_port }}
stats enable
stats uri /haproxy?stats
stats realm HAProxy\ Statistics
stats auth {{ haproxy_stats_user }}:{{ haproxy_stats_password }}
stats refresh 10s
{% for frontend in haproxy_frontends %}
frontend {{ frontend.name }}
bind {{ frontend.bind }}
mode {{ frontend.mode | default('http') }}
{% if frontend.mode == 'http' %}
option forwardfor
http-request set-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Real-IP %[src]
{% endif %}
{% if frontend.redirect_https | default(false) %}
redirect scheme https code 301 if !{ ssl_fc }
{% endif %}
{% if frontend.acls is defined %}
{% for acl in frontend.acls %}
{{ acl }}
{% endfor %}
{% endif %}
default_backend {{ frontend.default_backend }}
{% endfor %}
{% for backend in haproxy_backends %}
backend {{ backend.name }}
mode {{ backend.mode | default('http') }}
balance {{ backend.balance | default('roundrobin') }}
{% for option in backend.options | default([]) %}
option {{ option }}
{% endfor %}
{% if backend.cookie is defined %}
cookie {{ backend.cookie }} insert indirect nocache
{% endif %}
{% for server in backend.servers %}
server {{ hostvars[server]['inventory_hostname_short'] | default(server) }} {{ hostvars[server]['ansible_host'] | default(server) }}:{{ backend.server_port }} {{ backend.check_params | default('check') }}
{% endfor %}
{% endfor %}
SSL Termination with Let's Encrypt
- name: Configure SSL termination
hosts: loadbalancers
become: true
vars:
domain_name: "app.example.com"
tasks:
- name: Install certbot
ansible.builtin.package:
name:
- certbot
state: present
- name: Obtain certificate (standalone)
ansible.builtin.command:
cmd: >
certbot certonly --standalone
--non-interactive --agree-tos
--email admin@example.com
-d {{ domain_name }}
--preferred-challenges http
creates: "/etc/letsencrypt/live/{{ domain_name }}/fullchain.pem"
- name: Combine cert and key for HAProxy
ansible.builtin.shell:
cmd: |
cat /etc/letsencrypt/live/{{ domain_name }}/fullchain.pem \
/etc/letsencrypt/live/{{ domain_name }}/privkey.pem \
> /etc/haproxy/certs/{{ domain_name }}.pem
creates: "/etc/haproxy/certs/{{ domain_name }}.pem"
notify: Reload HAProxy
- name: Set certificate permissions
ansible.builtin.file:
path: "/etc/haproxy/certs/{{ domain_name }}.pem"
owner: haproxy
group: haproxy
mode: "0600"
- name: Create renewal hook for HAProxy
ansible.builtin.copy:
content: |
#!/bin/bash
cat /etc/letsencrypt/live/{{ domain_name }}/fullchain.pem \
/etc/letsencrypt/live/{{ domain_name }}/privkey.pem \
> /etc/haproxy/certs/{{ domain_name }}.pem
systemctl reload haproxy
dest: /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.sh
mode: "0755"
Rolling Backend Updates
Update backend servers without dropping connections:
- name: Rolling backend update
hosts: webservers
serial: 1
vars:
haproxy_host: "{{ groups['loadbalancers'][0] }}"
haproxy_socket: /run/haproxy/admin.sock
backend_name: web_servers
tasks:
- name: Disable server in HAProxy
ansible.builtin.command:
cmd: >
socat unix-connect:{{ haproxy_socket }} -
args:
stdin: "disable server {{ backend_name }}/{{ inventory_hostname_short }}\n"
delegate_to: "{{ haproxy_host }}"
- name: Wait for connections to drain
ansible.builtin.wait_for:
timeout: 30
- name: Perform application update
ansible.builtin.include_role:
name: app_deploy
- name: Wait for application health check
ansible.builtin.uri:
url: "http://localhost:8080/health"
status_code: 200
register: health
until: health.status == 200
retries: 10
delay: 5
- name: Re-enable server in HAProxy
ansible.builtin.command:
cmd: >
socat unix-connect:{{ haproxy_socket }} -
args:
stdin: "enable server {{ backend_name }}/{{ inventory_hostname_short }}\n"
delegate_to: "{{ haproxy_host }}"
- name: Verify server is UP
ansible.builtin.command:
cmd: >
socat unix-connect:{{ haproxy_socket }} -
args:
stdin: "show servers state {{ backend_name }}\n"
delegate_to: "{{ haproxy_host }}"
register: server_state
until: "inventory_hostname_short in server_state.stdout and '2' in server_state.stdout"
retries: 5
delay: 5
Multi-Tier Load Balancing
Layer 4 + Layer 7 configuration for complex architectures:
haproxy_frontends:
# Layer 4 TCP for database connections
- name: mysql_front
bind: "*:3306"
mode: tcp
default_backend: mysql_servers
# Layer 7 HTTP with ACL routing
- name: https_front
bind: "*:443 ssl crt /etc/haproxy/certs/"
mode: http
default_backend: web_default
acls:
- "acl is_api path_beg /api"
- "acl is_ws hdr(Upgrade) -i websocket"
- "use_backend api_servers if is_api"
- "use_backend ws_servers if is_ws"
haproxy_backends:
- name: web_default
mode: http
balance: leastconn
options:
- httpchk GET /health
servers: "{{ groups['web'] }}"
server_port: 8080
- name: api_servers
mode: http
balance: roundrobin
options:
- httpchk GET /api/health
servers: "{{ groups['api'] }}"
server_port: 3000
- name: ws_servers
mode: http
balance: source
servers: "{{ groups['websocket'] }}"
server_port: 8765
- name: mysql_servers
mode: tcp
balance: first
options:
- mysql-check user haproxy
servers: "{{ groups['database'] }}"
server_port: 3306
Rate Limiting and Security
- name: HAProxy security hardening
ansible.builtin.blockinfile:
path: /etc/haproxy/haproxy.cfg
insertafter: "frontend https_front"
block: |
# Rate limiting
stick-table type ip size 200k expire 30s store http_req_rate(10s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
# Security headers
http-response set-header X-Frame-Options DENY
http-response set-header X-Content-Type-Options nosniff
http-response set-header Strict-Transport-Security "max-age=63072000"
# Block suspicious paths
acl is_blocked path_beg /.env /wp-admin /phpMyAdmin
http-request deny if is_blocked
notify: Reload HAProxy
HAProxy Monitoring with Prometheus
- name: Enable Prometheus metrics endpoint
ansible.builtin.lineinfile:
path: /etc/haproxy/haproxy.cfg
insertafter: "^frontend"
line: |
frontend prometheus
bind *:8405
mode http
http-request use-service prometheus-exporter if { path /metrics }
no log
notify: Reload HAProxy
- name: Verify HAProxy metrics endpoint
ansible.builtin.uri:
url: "http://localhost:8405/metrics"
status_code: 200
return_content: true
register: metrics
failed_when: "'haproxy_process_current_connections' not in metrics.content"
Complete Inventory Example
[loadbalancers]
lb01 ansible_host=10.0.1.10
lb02 ansible_host=10.0.1.11
[webservers]
web01 ansible_host=10.0.2.10
web02 ansible_host=10.0.2.11
web03 ansible_host=10.0.2.12
[loadbalancers:vars]
haproxy_keepalived_vip=10.0.1.100
haproxy_keepalived_interface=eth0
Keepalived for Active-Passive HA
- name: Deploy Keepalived for HAProxy HA
hosts: loadbalancers
become: true
tasks:
- name: Install Keepalived
ansible.builtin.package:
name: keepalived
state: present
- name: Deploy Keepalived configuration
ansible.builtin.template:
src: keepalived.conf.j2
dest: /etc/keepalived/keepalived.conf
mode: "0644"
notify: Restart Keepalived
- name: Deploy HAProxy health check script
ansible.builtin.copy:
content: |
#!/bin/bash
/usr/bin/killall -0 haproxy
dest: /etc/keepalived/check_haproxy.sh
mode: "0755"
- name: Enable Keepalived
ansible.builtin.systemd:
name: keepalived
enabled: true
state: started
handlers:
- name: Restart Keepalived
ansible.builtin.systemd:
name: keepalived
state: restarted
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
haproxy -c -f fails validation | Syntax error in config | Check template rendering with --check --diff |
| Backend servers marked DOWN | Health check failing | Verify health endpoint returns 200 |
| 502 Bad Gateway | Backend not listening | Check backend service is running on correct port |
| Connection refused on port 443 | Certificate missing | Verify combined PEM file exists and is readable |
| High latency | Too many connections | Increase maxconn and tune kernel parameters |
Related Articles
- Ansible Keepalived VRRP Failover
- Ansible Let's Encrypt ACME Certificates
- Ansible Traefik Reverse Proxy
- Ansible Nginx Web Server
Conclusion
Automating HAProxy with Ansible gives you reproducible, version-controlled load balancer configurations. Combined with Keepalived for failover and Prometheus for monitoring, you get a production-grade load balancing stack that scales with your infrastructure.