Load balancing with Apache: mod_proxy_balancer and health checks

Updated November 2026

mod_proxy_balancer turns Apache’s reverse proxy into a load balancer: one balancer:// name stands for a group of backend servers, and Apache shares requests between them. Add mod_proxy_hcheck and Apache also tests each backend in the background, takes a broken one out of rotation and puts it back once it recovers.

Modules you need

# Debian/Ubuntu (slotmem_shm is pulled in automatically; watchdog is built in)
sudo a2enmod proxy proxy_http proxy_balancer lbmethod_byrequests lbmethod_bybusyness proxy_hcheck headers status
sudo systemctl restart apache2

# RHEL/Fedora: all of these are loaded by default
sudo apachectl -M | grep -E 'balancer|lbmethod|hcheck|slotmem'

A basic balancer

Three application servers on a private network serve the same app on port 8080:

<VirtualHost *:443>
    ServerName www.example.com
    # SSLEngine and certificate lines: see the HTTPS guide

    <Proxy "balancer://app">
        BalancerMember "http://10.0.0.11:8080"
        BalancerMember "http://10.0.0.12:8080"
        BalancerMember "http://10.0.0.13:8080"
        ProxySet lbmethod=byrequests
    </Proxy>

    ProxyPreserveHost On
    ProxyPass        "/" "balancer://app/"
    ProxyPassReverse "/" "balancer://app/"
    RequestHeader set X-Forwarded-Proto "https"
</VirtualHost>

Each BalancerMember is a backend (the documentation also calls them workers). Everything that works with a single ProxyPass target also works here, including timeouts and WebSockets (upgrade=websocket on the ProxyPass line). Test and reload with sudo apachectl configtest && sudo systemctl reload apache2 (httpd on RHEL).

Choosing a method

Load balancing methods in httpd 2.4
lbmethod=ModuleHow it picks a backendGood for
byrequests (default)mod_lbmethod_byrequestsCounts requests so that each member gets its weighted shareSimilar requests, similar servers
bybusynessmod_lbmethod_bybusynessSends each new request to the member with the fewest requests in progressRequests of very different length (reports, uploads, APIs)
bytrafficmod_lbmethod_bytrafficBalances the bytes sent and received, not the number of requestsDownloads and large responses
heartbeatmod_lbmethod_heartbeatUses load reports that backends send with mod_heartbeatBackends that are themselves Apache servers

Weights. loadfactor (a number from 1 to 100, default 1) sets each member’s share. With loadfactor=1 on one server and loadfactor=3 on another, byrequests sends 1 of every 4 requests to the first and 3 to the second: out of 400 requests, 100 and 300. Only the ratio matters, so 25 and 75 would behave the same.

BalancerMember "http://10.0.0.11:8080" loadfactor=1
BalancerMember "http://10.0.0.12:8080" loadfactor=3

Sticky sessions

If your app keeps session data in the memory of one server, every request from a user must return to that server. mod_proxy_balancer does this with a cookie or a URL parameter that carries a route name. If the app does not set a suitable cookie, Apache can set one itself:

Header add Set-Cookie "ROUTEID=.%{BALANCER_WORKER_ROUTE}e; path=/" env=BALANCER_ROUTE_CHANGED
<Proxy "balancer://app">
    BalancerMember "http://10.0.0.11:8080" route=app1
    BalancerMember "http://10.0.0.12:8080" route=app2
    ProxySet stickysession=ROUTEID
</Proxy>

The cookie value .app1 starts with a dot because the balancer reads only the part after the dot as the route. The header line runs only when a route was missing or changed (BALANCER_ROUTE_CHANGED). Cookie names are case-sensitive.

Tomcat and other Java servers already add the server name to their session cookie (JSESSIONID=….node1). Set jvmRoute="node1" in Tomcat’s server.xml, use the same value as route, and cover both the cookie and the URL form:

ProxyPass "/" "balancer://tomcats/" stickysession=JSESSIONID|jsessionid scolonpathdelim=On
<Proxy "balancer://tomcats">
    BalancerMember "http://10.0.0.21:8080" route=node1
    BalancerMember "http://10.0.0.22:8080" route=node2
</Proxy>

If the backends cannot share sessions at all, add nofailover=On: then a user whose server fails gets an error instead of being sent to a server that does not know the session. Better still, store sessions in a database or cache shared by all backends; then you need no stickiness.

Failover, spares and standbys

<Proxy "balancer://app">
    BalancerMember "http://10.0.0.11:8080"
    BalancerMember "http://10.0.0.12:8080"
    BalancerMember "http://10.0.0.19:8080" status=+H
    BalancerMember "http://10.1.0.11:8080" lbset=1
    ProxySet lbmethod=bybusyness failonstatus=502,503
</Proxy>

Health checks with mod_proxy_hcheck

Without health checks, Apache only notices a dead backend when a real user’s request fails. mod_proxy_hcheck checks members on its own schedule, independently of user traffic. A member that fails gets the flag C (HcFl) and receives no requests until it passes again.

Apache balancer://app 10.0.0.11 – check OK 10.0.0.12 – check OK 10.0.0.13 – failed (C) health check only user requests
Members that fail their health check stay out of rotation until they pass again.
ProxyHCExpr ok2xx {%{REQUEST_STATUS} =~ /^2/}
ProxyHCTemplate appcheck hcmethod=GET hcuri=/healthz hcexpr=ok2xx hcinterval=10 hcfails=2 hcpasses=2

<Proxy "balancer://app">
    BalancerMember "http://10.0.0.11:8080" hctemplate=appcheck
    BalancerMember "http://10.0.0.12:8080" hctemplate=appcheck
    BalancerMember "http://10.0.0.13:8080" hctemplate=appcheck
</Proxy>

The balancer-manager

The balancer-manager page shows every member with its status, load and errors, and lets you change weights, drain or disable members and even add new ones while the server runs. Because it can change live routing, it must never be public:

<Location "/balancer-manager">
    SetHandler balancer-manager
    Require ip 10.0.0.0/24
</Location>
ProxyPass "/balancer-manager" "!"

Put the ProxyPass … "!" exclusion before your ProxyPass "/" … line; otherwise the request is sent to the backends instead of the manager. For access from outside the admin network, add a password as well (see password protection) and use HTTPS only. The page uses a random nonce against cross-site request forgery; do not turn it off with nonce=None. Changes made in the manager are lost on restart unless you set BalancerPersist On, and only balancers defined outside <Location> blocks can be managed. For read-only monitoring, mod_status is enough (see server status).

Check that it works

Add a temporary header that names the member used for each response:

Header set X-Backend "%{BALANCER_WORKER_NAME}e"
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -D - https://www.example.com/ | grep -i x-backend
done

Without stickiness you should see the members take turns, for example x-backend: http://10.0.0.11:8080, then …12…, then …13…. Stop one backend, wait for the health check interval, and confirm it disappears from the list and shows HcFl in the balancer-manager. Remove the header when you are done, since it reveals internal addresses.

Common mistakes

Sources