maurer.gg

Getting started with HAProxy

by Joe Maurer on 23 April 2026

updated 15 May 2026

Table of Contents

HAProxy

Haproxy is a popular load balancer and reverse proxy which has expanded it’s native ACME support in recent versions, with more to come. Configuration is intuitive and has several programmatic interfaces to expand it’s capabilities.

Scenario

You have one or more services hosted on your network that you’d like to serve securely over the internet. Individual services may be a mix of HTTP and HTTPS, but you’d like to serve everything that’s public over HTTPS. You’d like certificates to renew automatically.

Goals

Assumptions

This guide assumes you already have HAProxy & DataPlaneAPI v3.3.4+ installed on your system and are running it via systemd service. Please see the appropriate install documentation for your system. Your network firewall should be configured to forward TCP traffic on 80443 to your HAProxy instance. Any A and CNAME records required for your services should be configured through your DNS provider to point to the public address of your firewall.

Building haproxy.cfg

Let’s work in our text editor for the moment while we get everything together.

Global

The global section sets global configuration options for the HAProxy runtime.

global
  chroot /var/lib/haproxy
  user haproxy
  group haproxy

  # maximum number of concurrent connections per HAPRoxy process
  maxconn 4000

  # turn on stats unix socket for dataplaneapi
  stats socket /var/run/haproxy.sock mode 660 level admin
  
  # to have these messages end up in /var/log/haproxy.log you will
  # need to:
  # 1) configure syslog to accept network log events.  This is done
  # by adding the '-r' option to the SYSLOGD_OPTIONS in
  # /etc/sysconfig/syslog
  # 2) configure local2 events to go to the /var/log/haproxy.log
  # file. A line like the following can be added to
  # /etc/sysconfig/syslog
  # local2.*                       /var/log/haproxy.log
  log 127.0.0.1 local2
  
  # support ACME renewal
  expose-experimental-directives
  httpclient.resolvers.prefer ipv4

We start with the chroot to jail the HAProxy process in an isolated directory, as well as user and group to run HAProxy as a non-privileged user. You can also set the maxconn to control the maximum number of concurrent connections for the entire process.

The stats socket allows for programmatic communication with the HAProxy server. This enables the DataPlaneAPI to listen for events and handle portions of the ACME renewal process.

You can also configure your system for logging based on the provided instructions, or another applicable logging daemon such as rsyslog.

Since we’re using new features, we expose-experimental-directives to support ACME renewal.

Defaults

You can set up one or more default configurations to use with frontends and backends.

defaults my_default
  mode http
  log global
  option httplog
  option dontlognull
  option http-server-close
  option forwardfor except 127.0.0.0/8

my_default is my chosen name to reference this default configuration. HAProxy can run in either HTTP (layer 7) or TLS (layer 4) mode. We’ll set mode http as out default for now. This allows protocol-specific features to be enabled such as message inspection and rewrites. We’ll also ensure logging is enabled by default, along with some other options.

ACME

A few different sections are required to accomodate the native ACME support.

Create the user for DataPlaneAPI to access the stats socket

# allow dpapi access
userlist dataplaneapi
  user admin insecure-password adminpwd

The next section will depend on if you’d like to use an HTTP-01 or DNS-01 challenge type. Each has its advantages and disadvantages, but the gist is an HTTP-01 can be handled locally by HAProxy, but requires that the load balancer is externally accessible. DNS-01 requires API access to the DNS provider for your domain, but can handle wildcard certs and issuance prior to the server being accessible. We’ll be using LetsEncrypt as our CA, be sure to use the staging API when testing.

HTTP-01 Challenge

acme letsencrypt-prod
  bits 2048
  contact [email protected]
  directory https://acme-v02.api.letsencrypt.org/directory
  keytype RSA
  challenge http-01
  map virt@acme

DNS-01 Challenge

acme letsencrypt-prod
  bits 2048
  contact [email protected]
  directory https://acme-v02.api.letsencrypt.org/directory
  keytype RSA
  challenge dns-01
  provider-name cloudflare
  acme-vars api_token=mycloudflaretoken

You may configure multiple acme directives as needed, be sure to name them uniquely and consistently.

Note: In testing, I found documentation for the DNS-01 challenge to be scant as of April 2026. I was able to get the configuration for Cloudflare DNS to “work” after some digging through the source code to find the appropriate acme-vars. However, I hit another possible bug due to the DataPlaneAPI using a default TTL of 30 seconds. Cloudflare limits non-enterprise customers to a minimum of 60 seconds - causing an HTTP 400 error when resolving the challenge. I was able to work around the issue by manually updating my DNS with the TXT record found in the haproxy log and running the provided command(s) via the Runtime API/stats socket to complete the challenges.

Update May 2026: The bug has been fixed as of dataplaneapi v3.3.4 - I was able to successfully configure certificate renewal. See here for more details.

Certificates

Next we’ll configure certificates for any domains required.

crt-store my_crts
  crt-base /etc/haproxy/ssl
  key-base /etc/haproxy/ssl
  load crt "mysite.com.pem" acme letsencrypt-prod alias "crt_mysite" domains "mysite.com"
  load crt "myblog.net.pem" acme letsencrypt-prod alias "crt_myblog" domains "myblog.net,posts.myblog.net"

We’ll be using the crt-store directive to configure the desired directory and map our certificates. You’ll see how to reference them in the next section.

Note: You’ll notice the acme directive which indiciates the cert is managed via our prior ACME configuration(s). This allows HAProxy to start without the certificate existing on disk. A temporary key-pair is created until the reissuance process completes.

Frontend

For our purposes, we’ll create one shared frontend to handle all of our traffic, using my_defaults as a base.

frontend shared-https from my_defaults 
  bind :80
  bind :443 ssl strict-sni


  # ACLs for mysite.com
  acl host_mysite_root hdr(host) -i -m beg mysite.com
  # ACLs for myblog.net
  acl host_myblog_root hdr(host) -i -m beg myblog.net
  acl host_myblog_posts hdr(host) -i -m beg posts.myblog.net

  http-request redirect scheme https code 301 unless { ssl_fc }

  # Backend selection logic
  use_backend mysite-root if host_mysite_root
  use_backend myblog-root if host_myblog_root
  use_backend myblog-posts if host_myblog_posts

  ssl-f-use crt "@my_crts/crt_mysite"
  ssl-f-use crt "@my_crts/crt_myblog"

  # Default backend (optional)
  # default_backend no_match_backend

The first thing you may notice is that despite one of our goals being to serve all public services over HTTPS, we’re still listening for traffic on port 80. The motivation is twofold - one two ensure users find our site, and to gracefully redirect them to a secure connection. To achieve this, we use the http-request directive to redirect insecure traffic to HTTPS.

I used -m beg as the match type in my ACLs because I was having trouble when using an exact match. My intuition is that HAProxy is receiving something like mysite.com:443, where the port causes an exact match to fail.

You’ll also note several other things - the strict-sni option in our bind :443 directive, the ssl-f-use directives loading our certs, and the lack of default backend. You might wonder how HAProxy shows the appropriate certificate and the answer is Server Name Indication (SNI). Essentially, clients (browsers) will include the server name as part of the request and HAProxy will use that to locate the appropriate cert from those available. By including strict-sni we require this behavior from clients and by not having a default backend we only attempt to serve domains we have certificats for.

Backends

Wrapping up this section, lets configure our backends

backend mysite-root from default
  option httpchk OPTIONS /
  server mysite-root-01 10.10.10.180:80 check inter 2s

backend myblog-root from default
  option httpchk GET /
  server myblog-root-01 10.10.10.200:80 check inter 2s

# myblog-posts is already serving HTTPS with a self-signed certificate
backend myblog-posts from default
  option httpchk OPTIONS /web
  # ensure verify none if cert is self-signed
  server myblog-posts-01 10.10.10.220:443 ssl verify none check inter 2s

Create an entry for each real server. Use the option directive to enable health checks for your services. Configure the type of check and the type of request the service will answer. Set a check interval of 2 seconds to balance pinging servers too frequently with check responsiveness.

If your backend service is already serving traffic via HTTPS, you can configure that as well by including the ssl option, along with verify none if the certificate is self-signed.

Putting it all together

# =============================================================================
# Global
# =============================================================================
global
  chroot /var/lib/haproxy
  user haproxy
  group haproxy

  # maximum number of concurrent connections per HAPRoxy process
  maxconn 4000

  # turn on stats unix socket for dataplaneapi
  stats socket /var/run/haproxy.sock mode 660 level admin
  
  # to have these messages end up in /var/log/haproxy.log you will
  # need to:
  # 1) configure syslog to accept network log events.  This is done
  # by adding the '-r' option to the SYSLOGD_OPTIONS in
  # /etc/sysconfig/syslog
  # 2) configure local2 events to go to the /var/log/haproxy.log
  # file. A line like the following can be added to
  # /etc/sysconfig/syslog
  # local2.*                       /var/log/haproxy.log
  log 127.0.0.1 local2

  # support ACME renewal
  expose-experimental-directives
  httpclient.resolvers.prefer ipv4

# =============================================================================
# Defaults 
# =============================================================================
defaults my_default
  mode http
  log global
  option httplog
  option dontlognull
  option http-server-close
  option forwardfor except 127.0.0.0/8


# =============================================================================
# ACME
# Use one of the following
# =============================================================================
userlist dataplaneapi
  user admin insecure-password adminpwd

# acme letsencrypt-prod
#  bits 2048
#  contact [email protected]
#  directory https://acme-v02.api.letsencrypt.org/directory
#  keytype RSA
#  challenge http-01
#  map virt@acme

# acme letsencrypt-prod
#  bits 2048
#  contact [email protected]
#  directory https://acme-v02.api.letsencrypt.org/directory
#  keytype RSA
#  challenge dns-01
#  provider-name cloudflare
#  acme-vars api_token=mycloudflaretoken



# =============================================================================
# Certificates 
# =============================================================================
crt-store my_crts
  crt-base /etc/haproxy/ssl
  key-base /etc/haproxy/ssl
  load crt "mysite.com.pem" acme letsencrypt-prod alias "crt_mysite" domains "mysite.com"
  load crt "myblog.net.pem" acme letsencrypt-prod alias "crt_myblog" domains "myblog.net,posts.myblog.net"



# =============================================================================
# Frontend 
# =============================================================================
frontend shared-https from my_defaults 
  bind :80
  bind :443 ssl strict-sni


  # ACLs for mysite.com
  acl host_mysite_root hdr(host) -i -m beg mysite.com
  # ACLs for myblog.net
  acl host_myblog_root hdr(host) -i -m beg myblog.net
  acl host_myblog_posts hdr(host) -i -m beg posts.myblog.net
 
  # Uncomment for HTTP-01 challenge handling 
  #http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/)].%[path,field(-1,/),map(virt@acme)]\n" if { path_beg '/.well-known/acme-challenge/' }

  http-request redirect scheme https code 301 unless { ssl_fc }

  # Backend selection logic
  use_backend mysite-root if host_mysite_root
  use_backend myblog-root if host_myblog_root
  use_backend myblog-posts if host_myblog_posts

  ssl-f-use crt "@my_crts/crt_mysite"
  ssl-f-use crt "@my_crts/crt_myblog"

  # Default backend (optional)
  # default_backend no_match_backend

# =============================================================================
# Backends 
# =============================================================================
backend mysite-root from default
  option httpchk OPTIONS /
  server mysite-root-01 10.10.10.180:80 check inter 2s

backend myblog-root from default
  option httpchk GET /
  server myblog-root-01 10.10.10.200:80 check inter 2s

# myblog-posts is already serving HTTPS with a self-signed certificate
backend myblog-posts from default
  option httpchk OPTIONS /web
  # ensure verify none if cert is self-signed
  server myblog-posts-01 10.10.10.220:443 ssl verify none check inter 2s

Deploy Configuration

We’re ready to deploy! First lets stop HAProxy and DataPlaneAPI

systemctl stop haproxy dataplaneapi

DataPlaneAPI will mess with the configuration while we’re editing it and we don’t want to start serving traffic yet.

Overwrite the default config - mine was located at /etc/haproxy/haproxy.cfg - with your config. Validate config file by running cd /etc/haproxy && haproxy -c -f haproxy.cfg. If you have any errors HAProxy will let you know.

Go ahead and start haproxy systemctl start haproxy.

You’ll also want to modify the DataPlaneAPI configuration - mine was located at /etc/dataplaneapi/dataplaneapi.yml. You’ll want to make sure the name of the userlist in haproxy.cfg matches the one specified in the dataplaneapi.yml file. The master runtime socket will also need to be specified. You can run systemctl status haproxy and check the -S parameter to get the master_runtime value.

dataplaneapi:
  host: 0.0.0.0
  port: 5555
  ...
  userlist:
    userlist: dataplaneapi
    ...
  ...
haproxy:
  config_file: /etc/haproxy/haproxy.cfg
  haproxy_bin: /usr/sbin/haproxy

Once you’ve got that squared away you can systemctl start dataplaneapi.

Issuing certificates

HAProxy may need one more reboot to trigger certificate deployment. Depending on the challenge type used, the process happens differently in the background. The main HAProxy instance can handle resolving HTTP-01 challenges on it’s own. Howver, since certificates are updated in memory the DataPlaneAPI is required to automatically persist certificates to disk. In a DNS-01 challenge, the DataPlaneAPI both communicates with the DNS provider during the challenge and persists the certificates to disk once the certificates are updated in memory.

Wrapping up

At this point HAProxy should be communicating with your backend services. If you configured at a stats page you can check the health of any backends and frontends that are monitored. Clients should be able to access services over HTTPS based on the configured ACLs in our frontend.

Congratulations - you’ve now reached a bare minimum of security! If you’d like to dive further into securing our HAProxy instance, read on here. Otherwise, see below for further reading and resources.

Further Reading (TODO)

Resources