Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

HAProxy Load Balancing: Config and TLS

By Kokil Thapa | Last reviewed: September 2026

Your Laravel app outgrows a single server, and traffic spikes during Dashain sales or booking surges on a travel portal. HAProxy Load Balancing: Config and TLS is the layer that keeps requests flowing when one PHP-FPM node fails or a deploy goes wrong. I use HAProxy on Linux production stacks when a client needs predictable HTTP routing, fast health checks, and TLS termination without bolting on a full reverse-proxy stack they cannot maintain. This guide walks through a working config you can deploy on Ubuntu 24 with PHP 8.4 or Laravel 12 backends behind it.

What is HAProxy load balancing and when should you use it?

HAProxy is a dedicated layer-4 and layer-7 load balancer. It accepts client connections, picks a healthy backend server, and forwards the request. Unlike Nginx used as a general web server, HAProxy focuses on routing, queuing, and observability.

On real client projects I reach for HAProxy when the team wants one stable entry point for multiple app nodes. A common pattern: two Ubuntu servers running Apache plus PHP-FPM 8.4, with HAProxy on a small third instance or co-located on each node in a pair. For custom Laravel applications, that front door also centralises TLS, rate limits, and request logging.

HAProxy fits well when you need:

  • Sub-millisecond health checks without full HTTP page loads
  • Clear stats at /stats for ops teams with limited Kubernetes experience
  • TLS termination before traffic hits slower PHP workers
  • Graceful drain during zero-downtime deploys with Deployer symlink swaps
  • HTTP/2 or HTTP/3 at the edge while backends stay on plain HTTP
HAProxy Load Balancing: Config and TLSClientsHTTPS :443HAProxyTLS + routingApp Node 1App Node 2App Node 3Backends speak HTTP on :8080Health checks, sticky sessions, stats UI
HAProxy Load Balancing: Config and TLS — clients hit one HTTPS endpoint; HAProxy terminates TLS and forwards to healthy PHP-FPM nodes.

When HAProxy is the wrong tool, say so early. A single small WordPress site on shared hosting does not need it. A three-node Laravel cluster with scheduled deploys and payment webhooks does.

CriteriaHAProxyNginx as LBCloud LB (ALB/NLB)
Health check speedExcellent, native TCP/HTTPGoodGood, vendor-managed
TLS terminationStrong, SNI, ALPNStrongManaged certs
Static file servingNo — use app or CDNYesVaries
Cost on own VPSFree, low RAMFreeMonthly fee per LB
Ops on small Nepal teamsOne config fileFamiliar if already usedLess server access
Best fitDedicated LB tierLB + static in one boxCloud-native scale

For deeper Nginx comparisons, see the Nginx vs Apache performance guide. HAProxy often sits in front of either.

How do you install and configure HAProxy for HTTP load balancing?

Start with a supported package on Ubuntu 24.04. HAProxy 2.8 or 2.9 is typical in 2026 repos. Pin the version in your runbook so staging matches production.

Install HAProxy on Ubuntu

sudo apt update
sudo apt install -y haproxy
haproxy -v
sudo systemctl enable haproxy

The main config lives at /etc/haproxy/haproxy.cfg. Back it up before edits. Validate every change:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy

Minimal global and defaults block

These settings apply to every frontend and backend. Tune maxconn to your RAM and expected concurrent users.

global
    log /dev/log local0
    maxconn 4096
    user haproxy
    group haproxy
    daemon

defaults
    log     global
    mode    http
    option  httplog
    option  dontlognull
    timeout connect 5s
    timeout client  50s
    timeout server  50s
    option  forwardfor
    option  http-server-close

Define frontend and backend pools

A frontend binds to a port. A backend lists real servers. This example balances three Laravel nodes on internal port 8080.

frontend http_front
    bind *:80
    redirect scheme https code 301 if !{ ssl_fc }

frontend https_front
    bind *:443 ssl crt /etc/haproxy/certs/site.pem alpn h2,http/1.1
    default_backend laravel_nodes

backend laravel_nodes
    balance roundrobin
    option httpchk GET /health HTTP/1.1\r\nHost:\ site.example\r\n
    http-check expect status 200
    server app1 10.0.1.11:8080 check inter 3s fall 3 rise 2
    server app2 10.0.1.12:8080 check inter 3s fall 3 rise 2
    server app3 10.0.1.13:8080 check inter 3s fall 3 rise 2

Your Laravel app should expose a cheap /health route. It can return JSON and skip database hits unless you want deep checks. On booking platforms I have shipped, a lightweight health endpoint saved us from routing to nodes mid-deploy.

HAProxy HTTP Request Flow1. Bind :4432. ACL match3. Pick backend4. ForwardHealth check loop (every 3s)GET /health — mark DOWN after 3 failsUP serversDOWN serversDRAIN
HAProxy evaluates ACLs, selects a backend, and only routes to servers that pass recurring HTTP health checks.

Balance algorithms that matter in production

  1. roundrobin — even rotation; fine for homogeneous Laravel nodes
  2. leastconn — sends to the node with fewest active connections; better for long-polling or Livewire
  3. source — hash by client IP; crude session affinity when cookies are not available
  4. uri — hash by URL path; useful for cache-friendly read-heavy APIs

For REST API backends, I often pair leastconn with short PHP-FPM timeouts. Payment callback paths may need sticky sessions instead.

How do you set up TLS termination with HAProxy?

TLS at HAProxy means the load balancer holds the certificate. Backend nodes run plain HTTP on a private network. That keeps cert renewals in one place and reduces CPU load on app servers.

Build a PEM bundle for HAProxy

HAProxy expects cert and key in one file. Concatenate full chain plus private key:

sudo mkdir -p /etc/haproxy/certs
sudo cat /etc/letsencrypt/live/site.example/fullchain.pem \
         /etc/letsencrypt/live/site.example/privkey.pem \
    | sudo tee /etc/haproxy/certs/site.pem > /dev/null
sudo chmod 640 /etc/haproxy/certs/site.pem
sudo chown root:haproxy /etc/haproxy/certs/site.pem

Renew with Certbot, then reload HAProxy. Automate via a deploy hook:

#!/bin/bash
cat /etc/letsencrypt/live/site.example/fullchain.pem \
    /etc/letsencrypt/live/site.example/privkey.pem \
    > /etc/haproxy/certs/site.pem
systemctl reload haproxy

Official guidance lives in the HAProxy 2.8 documentation. For cipher hardening, cross-check the Mozilla SSL Configuration Generator.

Enable TLS 1.2 and TLS 1.3 with sane ciphers

Match your compliance needs. A practical 2026 baseline:

bind *:443 ssl crt /etc/haproxy/certs/site.pem alpn h2,http/1.1
    ssl-min-ver TLSv1.2
    ssl-max-ver TLSv1.3
    ciphers ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!MD5:!DSS
    ssl-prefer-server-ciphers off

Read the TLS 1.3 vs 1.2 configuration guide for cipher rationale. The same trade-offs apply here.

Pass client IP and scheme to Laravel

Laravel must trust HAProxy so TrustProxies reads correct HTTPS URLs. Set headers in HAProxy:

frontend https_front
    bind *:443 ssl crt /etc/haproxy/certs/site.pem
    http-request set-header X-Forwarded-Proto https
    http-request set-header X-Forwarded-Port 443
    default_backend laravel_nodes

In Laravel 12, configure trusted proxies in bootstrap/app.php or middleware. Wrong trust settings break signed URLs, Sanctum cookies, and eSewa return URLs on Nepali payment flows.

Multi-domain SNI with one HAProxy instance

Bind multiple PEM files for law-firm sites sharing one LB:

bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1

acl host_notary hdr(host) -i notary.example.com
acl host_law   hdr(host) -i law.example.com
use_backend notary_pool if host_notary
use_backend law_pool    if host_law

I have used this pattern on sister legal-tech sites that share EC2 but keep separate TLS identities. It beats running three separate load balancers at Rs 3,000–5,000/month (~USD 22–37) each.

TLS Termination at HAProxyBrowserTLS 1.3HAProxy :443Cert + decryptLet's Encrypt PEMPHP-FPM nodeHTTP :8080EncryptedPlain HTTPHeaders added after decryptX-Forwarded-For, X-Forwarded-ProtoX-Forwarded-Port, Host
HAProxy Load Balancing: Config and TLS — certificates terminate at the edge; backends stay on fast internal HTTP with forwarded client metadata.

How do you enable health checks, sticky sessions, and safe deploys?

Health checks separate a load balancer demo from production reliability. Session persistence matters when PHP stores sessions on local disk instead of Redis.

Layer-7 checks vs TCP checks

TCP checks only verify a port is open. HTTP checks validate your app responds correctly. For Laravel, always prefer HTTP:

option httpchk GET /health HTTP/1.1\r\nHost:\ site.example\r\n
http-check expect status 200
http-check expect string ok

During deploys, mark a server in maintenance before stopping PHP-FPM:

echo "set server laravel_nodes/app1 state maint" | sudo socat stdio /run/haproxy/admin.sock
/* deploy */
echo "set server laravel_nodes/app1 state ready" | sudo socat stdio /run/haproxy/admin.sock

Enable the stats socket in global for runtime changes:

stats socket /run/haproxy/admin.sock mode 660 level admin
stats timeout 30s

When Redis is not yet in the stack, stick users to one node:

backend laravel_nodes
    balance roundrobin
    cookie SERVERID insert indirect nocache
    server app1 10.0.1.11:8080 check cookie app1
    server app2 10.0.1.12:8080 check cookie app2

Sticky sessions hide broken session storage. Fix the root cause with Redis session and cache config when traffic grows.

Rate limiting abusive traffic

HAProxy can throttle before requests hit PHP. Track client IPs in a stick table:

frontend https_front
    stick-table type ip size 100k expire 30s store http_req_rate(10s)
    acl abuse table_http_req_rate(https_front) gt 100
    http-request deny deny_status 429 if abuse

Pair this with app-level limits on API rate limiting for defence in depth.

Zero-Downtime Deploy via HAProxy1. MAINT modeDrain sessions2. Deploy codeSymlink swap3. Health OKGET /health 2004. READYCommon gotcha: stale PHP opcacheReload PHP-FPM after symlink changeVerify health before READY stateRepeat per node — never all at once
Rolling deploys with HAProxy: drain one backend, deploy, confirm health, return to service — same pattern used on shared GitLab CI pipelines.

How do you monitor, load-test, and troubleshoot HAProxy in production?

If you cannot see backend state, you are guessing. HAProxy ships a built-in stats UI and structured logs.

Enable stats and Prometheus export

listen stats
    bind *:8404
    stats enable
    stats uri /stats
    stats refresh 10s
    stats admin if LOCALHOST

Restrict port 8404 with UFW. Never expose it publicly without auth. For deeper dashboards, pair with Netdata server monitoring or export via the Prometheus module.

Load-test through HAProxy, not around it

Run k6 against the public hostname so TLS and routing are in the path:

k6 run --vus 50 --duration 5m script.js

See the load testing with k6 guide and the PHP-focused variant for Laravel endpoints. Watch HAProxy stats during the run. Rising queue depth means you need more nodes or lower PHP-FPM pm.max_children per box.

Typical production failures

  • 502 Bad Gateway — all backends down or wrong port; check systemctl status haproxy and app logs
  • SSL handshake errors — expired PEM, wrong permissions, missing intermediate chain
  • Redirect loops — Laravel thinks request is HTTP; fix X-Forwarded-Proto and trusted proxies
  • Uneven load — sticky cookies or one slow node; try leastconn
  • Cert renewals fail — hook did not rebuild PEM; HAProxy still serves old file until reload

Validate config syntax before every reload. A broken config can drop every site behind the LB. Use regex tester tools when debugging ACL patterns, and JSON formatter utilities when health endpoints return structured payloads.

For Kubernetes workloads, ingress controllers often replace HAProxy. The concepts overlap with Kubernetes ingress and TLS with cert-manager. On bare VPS clusters common in Nepal, HAProxy stays the simpler choice.

Key Takeaways

  • Terminate TLS at HAProxy with a combined PEM file and reload after every Certbot renewal.
  • Always use HTTP health checks against a real Laravel /health route, not bare TCP port checks.
  • Set X-Forwarded-Proto and configure Laravel trusted proxies so URLs and payments stay correct.
  • Drain nodes to MAINT via the admin socket before deploys; return to READY only after opcache-safe PHP-FPM reload.
  • Load-test through the public hostname with k6 so HAProxy TLS and balancing sit in the critical path.
  • Lock port 8404 stats and admin sockets behind localhost or VPN — exposed stats panels are an easy target.

People Also Ask

Does HAProxy support TLS 1.3 in 2026?

Yes. HAProxy 2.6 and later supports TLS 1.3 when built against OpenSSL 1.1.1 or newer. Set ssl-min-ver TLSv1.2 and ssl-max-ver TLSv1.3 on your bind line. Use ALPN h2,http/1.1 if you serve HTTP/2 to browsers.

Should PHP apps sit behind HAProxy or Nginx?

Use HAProxy as the dedicated load balancer and keep Nginx or Apache on app nodes for PHP-FPM. Nginx alone can load-balance, but HAProxy gives faster health checks and cleaner ops at scale. Single-server sites rarely need both.

Where do Let's Encrypt certificates live for HAProxy?

Certbot stores certs under /etc/letsencrypt/live/. HAProxy needs cert plus key merged into one PEM under /etc/haproxy/certs/. Automate the merge in a Certbot deploy hook, then reload HAProxy.

How do you debug uneven load across backends?

Check the stats page for active sessions per server. Sticky cookies, source-based balancing, or one slow node skew traffic. Switch to leastconn, remove stickiness if sessions live in Redis, and confirm all nodes run the same PHP-FPM pool sizes.

Ship HAProxy with confidence

HAProxy Load Balancing: Config and TLS is not exotic infrastructure. It is a single well-tested config file that keeps your Laravel or WordPress cluster online when a node fails or you deploy at midnight Nepal time. Start with two app servers, HTTP health checks, and automated PEM reloads. Add sticky sessions and rate limits only when metrics prove you need them.

If you want this wired into your stack — load balancer, hosting setup, Deployer pipeline, and post-launch support — review the Notary Nepal portfolio case or browse more work on the portfolio page. For performance tuning after go-live, see speed optimization services and testing and optimization. Need a second pair of eyes on ACL rules or cipher suites? Contact us with your current haproxy.cfg and backend count.

Frequently Asked Questions

HAProxy is a dedicated layer-4 and layer-7 load balancer that accepts client connections, picks a healthy backend server, and forwards the request. It focuses on routing, queuing, and observability rather than serving static files or running PHP itself.

Reach for HAProxy when you need one stable entry point for multiple app nodes with sub-millisecond health checks, TLS termination before PHP workers, and a clear stats view without Kubernetes. Nginx can load-balance but HAProxy gives faster checks and cleaner ops at scale. Cloud load balancers suit vendor-managed scale; HAProxy on your own VPS is free and low-RAM. A single small WordPress site on shared hosting does not need it. A three-node Laravel cluster with scheduled deploys and payment webhooks does.

On Ubuntu 24.04, run apt update and apt install haproxy, then enable the service with systemctl. Pin HAProxy 2.8 or 2.9 in your runbook so staging matches production. The main config lives at /etc/haproxy/haproxy.cfg — back it up before edits. Define a frontend binding to port 443 with SSL, a backend pool listing your app servers, option httpchk against a /health route, and balance roundrobin or leastconn. Validate every change with haproxy -c -f /etc/haproxy/haproxy.cfg, then reload with systemctl reload haproxy.

HAProxy on your own VPS is free with low RAM overhead. Cloud load balancers charge a monthly fee per load balancer. Running three separate load balancers can cost Rs 3,000–5,000/month (~USD 22–37) each — one HAProxy instance with SNI for multiple domains avoids that.

HAProxy holds the certificate; backend nodes run plain HTTP on a private network. Certbot stores certs under /etc/letsencrypt/live/. HAProxy needs the full chain and private key merged into one PEM at /etc/haproxy/certs/site.pem with chmod 640 and chown root:haproxy. Reference the file in your bind line with ssl crt. Automate renewal via a Certbot deploy hook that rebuilds the PEM and runs systemctl reload haproxy. Enable TLS 1.2 and 1.3 with sane ciphers and ALPN h2,http/1.1 for HTTP/2.

Yes. HAProxy 2.6 and later supports TLS 1.3 when built against OpenSSL 1.1.1 or newer. Set ssl-min-ver TLSv1.2 and ssl-max-ver TLSv1.3 on your bind line.

roundrobin suits homogeneous Laravel nodes with even rotation. leastconn sends traffic to the node with fewest active connections — better for long-polling, Livewire, or REST APIs with longer PHP-FPM timeouts. source hashes by client IP for crude session affinity when cookies are unavailable. uri hashes by URL path for cache-friendly read-heavy APIs. Payment callback paths may need sticky sessions instead of pure roundrobin.

Prefer layer-7 HTTP checks over bare TCP port checks. Add option httpchk GET /health with a Host header matching your site, then http-check expect status 200. Your Laravel app should expose a cheap /health route that skips heavy database hits unless you want deep checks. Set check inter 3s fall 3 rise 2 on each server line. During deploys, mark a node MAINT via the admin socket before stopping PHP-FPM, deploy, confirm health, then return it to READY.

Laravel thinks the incoming request is plain HTTP because HAProxy terminates TLS at the edge. Fix it by setting X-Forwarded-Proto https and X-Forwarded-Port 443 in your HAProxy frontend, then configure trusted proxies in Laravel 12 via bootstrap/app.php or middleware. Wrong trust settings also break signed URLs, Sanctum cookies, and eSewa return URLs on Nepali payment flows.

Enable the stats socket in your global block at /run/haproxy/admin.sock with mode 660 and level admin. Before deploy, run set server laravel_nodes/app1 state maint through socat against that socket. Deploy your code — the same pattern used with Deployer symlink swaps on GitLab CI pipelines. After PHP-FPM reload and opcache settle, set the server state back to ready. HAProxy stops sending new traffic to the drained node while existing connections finish.

Use cookie-based sticky sessions when PHP stores sessions on local disk instead of Redis and you need users pinned to one node. Configure balance roundrobin with cookie SERVERID insert indirect nocache and assign a unique cookie value per server. Sticky sessions hide broken session storage — fix the root cause with Redis session and cache config when traffic grows. Payment callback paths may also need stickiness when roundrobin alone breaks session continuity.

Use HAProxy as the dedicated load balancer and keep Nginx or Apache on app nodes for PHP-FPM. HAProxy terminates TLS and routes; app servers handle PHP. Nginx alone can load-balance, but HAProxy gives faster health checks and cleaner ops at scale. Single-server sites rarely need both layers.

A 502 usually means all backends are down or HAProxy is forwarding to the wrong port. Check systemctl status haproxy and your application logs on each node. Confirm health checks pass against your /health endpoint. SSL handshake errors point to an expired PEM, wrong file permissions, or a missing intermediate chain in your combined cert file. Always validate config syntax with haproxy -c before reload — a broken config can drop every site behind the load balancer.

Enable a stats listener on port 8404 with stats uri /stats and restrict it with UFW — never expose it publicly without auth. Watch backend state, active sessions, and queue depth during tests. Run k6 against the public hostname so TLS and routing sit in the critical path: k6 run --vus 50 --duration 5m script.js. Rising queue depth during a test means you need more nodes or lower PHP-FPM pm.max_children. Pair stats with Netdata or Prometheus export for deeper dashboards.

Yes, using SNI. Bind port 443 with ssl crt pointing to a directory of PEM files, then define ACLs matching Host headers and route each domain to its own backend pool. This pattern works well for sister legal-tech sites sharing one EC2 instance but keeping separate TLS identities. It beats running a separate load balancer per domain when each would cost Rs 3,000–5,000/month (~USD 22–37).

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: