by Joe Maurer on 23 April 2026
updated 15 May 2026
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.
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.
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 80⁄443 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.
Let’s work in our text editor for the moment while we get everything together.
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.
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.
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.
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
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.
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.
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.
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.
# =============================================================================
# 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
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.
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.
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.
crt-store directive works and how to configure TLS.