Home servers

HAProxy configuration rules with simple examples

HAProxy can look difficult when you first open haproxy.cfg. It is mostly a list of small rules. Each rule checks something about the request and then does an action: send it to a server, redirect it, block it, or change a header.

This article shows rules which are useful on a small Debian server. The same ideas also work on a bigger setup with many servers.

The basic structure

Most HTTP configurations have these sections:

global
    # Process-wide settings

defaults
    # Settings inherited by frontends and backends

frontend public_https
    # Where clients connect

backend my_web_servers
    # Where HAProxy sends the requests

The global section is for things like logs, the user running HAProxy, and the statistics socket. The defaults section contains common timeouts and the proxy mode. A frontend accepts the connection. A backend contains one or more real servers.

A listen section can combine a frontend and a backend. It is useful for a small service, but separate frontends and backends are easier to read when the configuration grows.

global: settings for the HAProxy process

A small example:

global
    log /dev/log local0
    stats socket /run/haproxy/admin.sock mode 660 level admin
    user haproxy
    group haproxy

Why use these rules?

  • log sends HAProxy messages to syslog. This helps when you need to see why a request failed.
  • stats socket gives local tools a way to ask HAProxy about servers and connections.
  • user and group make HAProxy run with a limited system account.

Do not put passwords or private keys in this section. The configuration file is often readable by administrators and may be copied for backup.

defaults: common rules

defaults
    mode http
    option httplog
    timeout connect 5s
    timeout client 50s
    timeout server 50s

Why use this?

  • mode http lets HAProxy read the host, path, method, and headers. This is needed for most routing rules.
  • option httplog makes the access log useful for HTTP requests.
  • The timeouts stop a broken client or server from keeping a connection forever.

The timeout values depend on the application. A normal website can use short values. A long download or a slow media application may need a longer server timeout.

frontend: where traffic enters

frontend public_http
    bind :80
    default_backend web_servers

bind :80 listens on port 80 on all local addresses. You can also bind to one address, for example bind 192.168.1.10:80, if the service must not listen on another interface.

Why bind to one address? This is useful when the server has a public and a private network, and the proxy should only accept traffic on one of them.

For HTTPS, the bind can look like this:

frontend public_https
    bind :443 ssl crt-list /etc/haproxy/haproxy.crt-list alpn h2,http/1.1
    default_backend web_servers

The certificate list tells HAProxy which certificates it can use. The alpn part allows HTTP/2 and HTTP/1.1 clients.

backend: the application server

backend web_servers
    balance roundrobin
    server web1 192.168.1.21:8080 check
    server web2 192.168.1.22:8080 check

Why use balance roundrobin? Requests are shared between the servers. This is useful when one server cannot handle all traffic, or when you want to keep the service available during maintenance.

With only one server, balance is not needed, but keeping the backend is still useful because the frontend stays clean.

The check at the end of a server line enables health checking. HAProxy can then stop using a server which does not answer.

Health checks

A simple check tests if the TCP port accepts a connection. For a web service, it is usually better to check a real URL:

backend web_servers
    option httpchk GET /health
    http-check expect status 200
    server web1 127.0.0.1:8080 check

Why use /health? A process can still have its port open while the application is broken. A health URL can check that the application is really ready.

If the application has no health URL, use / and accept the normal response:

backend web_servers
    option httpchk GET /
    http-check expect status 200-399
    server web1 127.0.0.1:8080 check

Be careful with checks which require authentication. A check which receives a login page may be marked healthy even when the useful part of the application is not working.

ACLs: the main decision tool

An ACL returns true or false. It has no effect until another rule uses it with if or unless.

frontend public_https
    bind :443 ssl crt-list /etc/haproxy/haproxy.crt-list
    acl is_blog hdr(host) -i blog.example.com
    use_backend blog if is_blog
    default_backend web_servers

Why use an ACL? This lets one public IP serve several websites. HAProxy reads the Host header and sends each request to the correct application.

The -i flag makes the host comparison case-insensitive. You can list more than one value:

acl is_blog hdr(host) -i blog.example.com www.blog.example.com

This is useful when both the main domain and the www name should reach the same backend.

Match a URL path

acl is_api path_beg /api/
use_backend api_servers if is_api

Why use this? One domain can have a normal website and a separate API service. For example, /api/ can go to a Python application and / can go to Apache.

Other useful path matches are:

acl is_login path -i /login
acl is_static path_beg /assets/ /images/
acl is_exact_file path -i /robots.txt /favicon.ico

Use path_beg when a whole path area is needed. Use path when only the exact path should match.

Match the HTTP method

acl read_method method GET HEAD OPTIONS
http-request deny deny_status 405 if !read_method

Why use this? A read-only service, such as an OPDS library endpoint, may not need POST, PUT, or DELETE. Blocking other methods reduces mistakes and noise.

Do not copy this example for an application which needs POST, such as a login page or a contact form.

Match the client IP

acl local_network src 127.0.0.0/8 192.168.1.0/24
http-request deny deny_status 403 if !local_network

Why use this? It is a simple way to keep an admin page available only from the home network.

Do not rely on this alone for an important service. Check the real network ranges first, and consider client certificates, VPN access, or another strong authentication method.

Match a file of values

acl blocked_ips src -f /etc/haproxy/blocked-ips.txt
http-request deny deny_status 403 if blocked_ips

Why use this? You can update the list without putting a long list of addresses inside the main configuration. It is useful for a small blocklist or an allow list.

The file still has to be protected. A bad blocklist can block real users or allow traffic that should be blocked.

Routing to different backends

The usual order is: define ACLs, use the more specific backends first, and leave a safe default at the end.

frontend public_https
    bind :443 ssl crt-list /etc/haproxy/haproxy.crt-list

    acl host_main hdr(host) -i example.com
    acl host_media hdr(host) -i media.example.com
    acl path_api path_beg /api/

    use_backend api_servers if host_main path_api
    use_backend main_site if host_main
    use_backend media_server if host_media
    default_backend reject_unknown_host

Why use a safe default? A request with an unknown or wrong Host header does not accidentally reach an application. This matters when many domains share one IP address.

The conditions on one line are combined with AND. Therefore the API rule needs both host_main and path_api to be true.

Redirect HTTP to HTTPS

frontend public_http
    bind :80
    acl acme_challenge path_beg /.well-known/acme-challenge/
    http-request redirect scheme https code 301 if !acme_challenge
    default_backend certbot_http

Why use this? Visitors who type http:// are moved to the encrypted URL.

The exception for the ACME path is needed when Certbot uses the HTTP-01 challenge. Without it, certificate renewal may fail. If another service handles the challenge, adapt the backend and path to that setup.

For a domain change, use a fixed redirect instead:

http-request redirect location https://new.example.com%[path]?%[query] code 301

Test redirects with curl -I before making them permanent. A wrong redirect can create a loop.

Block or reject requests

acl private_area path_beg /private/
http-request deny deny_status 403 if private_area !local_network

Why use this? The backend may contain a private area which should never be public. Rejecting it at HAProxy saves work for the application and gives one central rule.

You can return a simple status without sending the request to a backend:

acl old_path path -i /old-page
http-request redirect location /new-page code 301 if old_path

Use 403 when the resource exists but the client is not allowed. Use 404 if you do not want to reveal that the path exists. Use 405 when the URL is valid but the HTTP method is not allowed.

Add or change headers

frontend public_https
    option forwardfor
    http-request set-header X-Forwarded-Proto https
    http-request set-header X-Forwarded-Host %[req.hdr(host)]

Why use these headers? The backend can know the original client IP, host, and scheme. This is important for correct links, secure cookies, application logs, and redirects.

The backend must be configured to trust these headers only from HAProxy. Do not let a public client choose a trusted X-Forwarded-For value.

You can also add a response header:

http-response set-header X-Content-Type-Options nosniff

Why use this? It tells browsers not to guess a different content type. Add security headers only after checking that they do not break the application.

Change a path before sending it to the backend

backend files_app
    http-request replace-path ^/files/?(.*) /\1
    server files 127.0.0.1:8080 check

Why use this? The public URL can be /files/photo.jpg, while the application receives /photo.jpg. This is useful when an application does not know that it is mounted under a subdirectory.

Path rewrites and cookie rewrites can be difficult to debug. Start with one simple path, test it, and check the HAProxy log and the backend log together.

Rate limiting with a stick table

For a small API, a basic per-IP limit can look like this:

backend api_servers
    stick-table type ip size 100k expire 10m store http_req_rate(10s)
    http-request track-sc0 src
    http-request deny deny_status 429 if { sc_http_req_rate(0) gt 50 }
    server api 127.0.0.1:8080 check

Why use this? A client which sends too many requests gets 429 Too Many Requests. This can reduce damage from a noisy script or a simple HTTP flood.

This is not a complete DDoS solution. Many users can share one public IP, and an attacker can use many IP addresses. Login endpoints may need a stricter limit than normal pages.

Simple load balancing choices

backend web_servers
    balance roundrobin

Common choices are:

  • roundrobin: share requests in order. Good for similar servers.
  • leastconn: send the next request to the server with fewer connections. This can help when requests take different amounts of time.
  • first: fill the first server before using the next one. This can save power on servers which can sleep.

Start with roundrobin unless you have a real reason to use another method. The best choice depends on the application and on whether sessions are stored locally.

Sticky sessions

Some old applications keep login state only on one server. A cookie can keep a client on the same server:

backend web_servers
    cookie SERVERID insert indirect nocache
    server web1 192.168.1.21:8080 check cookie web1
    server web2 192.168.1.22:8080 check cookie web2

Why use this? The user can stay logged in even when the application does not share sessions between servers.

Shared sessions in the application are usually better. Sticky sessions can make maintenance and failure recovery less smooth.

A small complete example

This example sends one domain to an Apache site, sends /api/ to an API, and redirects old HTTP requests to HTTPS:

frontend http_in
    bind :80
    acl acme path_beg /.well-known/acme-challenge/
    http-request redirect scheme https code 301 if !acme
    default_backend certbot

frontend https_in
    bind :443 ssl crt /etc/haproxy/ssl/example.pem alpn h2,http/1.1
    option forwardfor
    http-request set-header X-Forwarded-Proto https

    acl host_example hdr(host) -i example.com
    acl path_api path_beg /api/
    use_backend api if host_example path_api
    use_backend website if host_example
    default_backend unknown_host

backend website
    option httpchk GET /
    http-check expect status 200-399
    server apache 127.0.0.1:8080 check

backend api
    option httpchk GET /health
    http-check expect status 200
    server api 127.0.0.1:9000 check

backend certbot
    server certbot 127.0.0.1:8080

backend unknown_host
    http-request deny deny_status 404

The exact certificate path, ports, and health URLs must match your server.

Test before reload

Always check the configuration first:

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

If the check is successful, reload the service:

sudo systemctl reload haproxy

Then test the important paths:

curl -I https://example.com/
curl -I https://example.com/api/health
curl -I http://example.com/

Check the service and logs if something is wrong:

systemctl status haproxy --no-pager
journalctl -u haproxy -n 100 --no-pager

A good habit is to change one small rule at a time. Keep a working copy of the configuration, and do not reload a file which was not checked with haproxy -c.

Final advice

The most useful HAProxy rules are usually simple:

  • use hdr(host) to select a website;
  • use path_beg to select an application area;
  • use check and httpchk so broken servers are removed;
  • use redirects for HTTPS and domain changes;
  • use deny for admin areas and methods which are not needed;
  • use headers so the backend knows the original request;
  • use stick tables for small, local rate limits.

Start with the smallest rule which solves the problem. Complicated ACLs are harder to review and easier to get wrong.

Sources