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

ProblemCauseSolution
haproxy -c -f fails validationSyntax error in configCheck template rendering with --check --diff
Backend servers marked DOWNHealth check failingVerify health endpoint returns 200
502 Bad GatewayBackend not listeningCheck backend service is running on correct port
Connection refused on port 443Certificate missingVerify combined PEM file exists and is readable
High latencyToo many connectionsIncrease maxconn and tune kernel parameters

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.