HAProxy setup

Cert Camel

Setting up the HAProxy Data Plane API so certificates can be replaced without a reload — and locking it down properly, because the thing you are about to switch on can rewrite your load balancer's configuration.

1. What this is, and why not the Runtime API

HAProxy has two ways to change a certificate without restarting:

Runtime APIData Plane API
Talks overA unix socket (or a TCP socket you expose)HTTP with authentication
Certificate landsIn memory onlyOn disk and in memory
Survives a reloadNoYes
Reachable from another machineNot without exposing a raw socketYes, by design
The Runtime API on its own is a trap

A certificate pushed over the Runtime API is live immediately and gone at the next reload — a config change, a package update, a logrotate hook. HAProxy silently goes back to whatever is on disk, and nobody finds out until it expires. The Data Plane API writes the file and pushes it to the runtime socket, falling back to a reload only if the push fails. That is the combination worth having.

2. Check your version

haproxy -v

You need 2.2 or newer. 2.1 can replace a certificate HAProxy already knows about but cannot add a new one; below 2.1 there is no runtime certificate update at all.

3. Install the Data Plane API

HAProxy Enterprise (HAPEE)

It ships with the product. Install the package and skip to step 4.

sudo apt-get install hapee-<version>-lb-dataplaneapi   # Debian/Ubuntu
sudo yum install hapee-<version>-lb-dataplaneapi       # RHEL/Rocky

Community HAProxy

It is a separate download from haproxytech/dataplaneapi. Pick the release matching your HAProxy minor version where possible.

There are .deb, .rpm and .apk packages as well as a tarball. Prefer the package — it handles the paths and gives you something to uninstall:

# Debian/Ubuntu - note "amd64" for packages
curl -fsSLO https://github.com/haproxytech/dataplaneapi/releases/download/v3.3.5/dataplaneapi_3.3.5_linux_amd64.deb
sudo apt install ./dataplaneapi_3.3.5_linux_amd64.deb
dataplaneapi --version

If you would rather drop in a binary, the tarball uses x86_64 where the packages use amd64 — an easy five minutes to lose:

curl -fsSLO https://github.com/haproxytech/dataplaneapi/releases/download/v3.3.5/dataplaneapi_3.3.5_linux_x86_64.tar.gz
tar xzf dataplaneapi_3.3.5_linux_x86_64.tar.gz
sudo install -m 0755 build/dataplaneapi /usr/local/bin/dataplaneapi

4. Give HAProxy an admin-level stats socket

The Data Plane API drives HAProxy through this socket. Without level admin it can read but not change anything.

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

Note the mode: 660, not 666. Anyone who can write to this socket can reconfigure your load balancer, so it should be owned by a group only HAProxy and the Data Plane API belong to.

Two things to check before adding the line: distro packages often ship a stats socket line already (Debian and Ubuntu's uses /run/haproxy/admin.sock) — if one is there, add level admin to it rather than declaring a second socket. And the directory has to exist, or the reload fails to bind it; the packaged systemd unit creates /run/haproxy for you, so reuse that path if in doubt.

5. Create the API user — with a hashed password

The Data Plane API reads its credentials from a userlist in the HAProxy configuration. Generate a hash first:

mkpasswd -m sha-512          # from the "whois" package on Debian/Ubuntu
# or, with no extra packages:
openssl passwd -6

Then add the userlist. userlist is a top-level section — it sits alongside global, defaults, frontend and backend, not inside any of them. Anywhere at that level works; near the top keeps it findable.

# /etc/haproxy/haproxy.cfg

global
    stats socket /var/run/haproxy/admin.sock mode 660 level admin
    stats timeout 30s
    # ... rest of your global section ...

# <- top level, a sibling of global. NOT indented inside it.
userlist dataplaneapi
    user certcamel password $6$mZ8k...<the hash you generated above>

defaults
    mode http
    # ... etc ...

frontend fe_https
    bind *:443 ssl crt /etc/haproxy/ssl/example.com.pem
    default_backend be_app
Putting it inside global will not work

HAProxy rejects userlist as an unknown keyword there, and the error names the line rather than explaining the nesting, so it is easy to misread as a typo in the hash. Check the file parses before reloading anything:

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

That validates the configuration without touching the running process. Configuration file is valid means you are clear to reload — and the reload is how the socket and userlist from steps 4 and 5 actually take effect:

sudo systemctl reload haproxy

A reload is hitless: existing connections finish on the old process while new ones go to the new one. Nothing drops.

The name after userlistdataplaneapi here — is arbitrary, but it must match the userlist: value in the YAML in step 6. A mismatch there is the single most common cause of a 401.

Use password, never insecure-password

insecure-password stores the password in clear text in the configuration file — a file that is usually world-readable, often in version control, and frequently pasted into tickets. It is named the way it is for a reason. password takes a crypt hash and is the only form that belongs in a real deployment.

Give the API its own user. Do not reuse the stats page login: those credentials tend to be shared around a team and pasted into chat, and this one can rewrite your configuration.

6. Configure the Data Plane API

Create /etc/haproxy/dataplaneapi.yaml (HAPEE uses /etc/hapee-extras/dataplaneapi.yml):

dataplaneapi:
  # Bind to one address, not everything. See step 8.
  host: 127.0.0.1
  port: 5555

  transaction:
    # Not under /tmp: transaction directories hold copies of your
    # configuration, and /tmp is world-writable.
    transaction_dir: /var/lib/dataplaneapi/transactions

  resources:
    # Certificates uploaded through the API land HERE and nowhere else.
    ssl_certs_dir: /etc/haproxy/ssl
    maps_dir: /etc/haproxy/maps

haproxy:
  config_file: /etc/haproxy/haproxy.cfg
  haproxy_bin: /usr/sbin/haproxy

  reload:
    reload_cmd: systemctl reload haproxy
    restart_cmd: systemctl restart haproxy
    reload_delay: 5

  # Must match the userlist name from step 5. This is the only auth
  # mechanism you want - do NOT also add a "user:" list under
  # dataplaneapi:, which is a second, separate mechanism.
  userlist:
    userlist: dataplaneapi

Make the directories, and keep the certificate one tight — it will hold private keys:

sudo mkdir -p /etc/haproxy/ssl /var/lib/dataplaneapi/transactions
sudo chown -R haproxy:haproxy /etc/haproxy/ssl /var/lib/dataplaneapi
sudo chmod 700 /etc/haproxy/ssl

7. Run it as a service

HAPEE ships a unit already. For the community build, create /etc/systemd/system/dataplaneapi.service:

[Unit]
Description=HAProxy Data Plane API
After=network.target haproxy.service
Requires=haproxy.service

[Service]
# Not root. It needs the config file, the certificate directory and the socket.
User=haproxy
Group=haproxy
ExecStart=/usr/local/bin/dataplaneapi -f /etc/haproxy/dataplaneapi.yaml
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now dataplaneapi
sudo systemctl status dataplaneapi
Two things the haproxy user needs, and does not have by default

Write access to the configuration. The Data Plane API does not only read haproxy.cfg, it rewrites it — and that file is usually root-owned. Without write access the first change fails with a 500 that does not obviously say why:

sudo chown root:haproxy /etc/haproxy/haproxy.cfg && sudo chmod 640 /etc/haproxy/haproxy.cfg

Permission to reload HAProxy. Grant exactly that one command through sudoers, or use a polkit rule — whichever your organisation already does for service reloads.

8. Lock it down

Treat this like SSH access, because that is roughly what it is

The Data Plane API has no granular permissions. A user who can upload a certificate can also rewrite backends, change ACLs and read the certificates already on disk. There is no read-only mode and no per-endpoint scoping. Everything below is therefore about reducing who can reach it, not about limiting what they could do once there.

Bind to one address

The default listens on every interface. On a load balancer, "every interface" includes the public one. Bind it to the management address, or to 127.0.0.1 with an SSH tunnel if Cert Camel runs elsewhere.

Make a certificate for the API

The API needs its own certificate, and Cert Camel cannot issue this one — it deploys certificates through the API, so the API has to be reachable before the tool can do anything. Chicken and egg. Make it by hand, once; it is an internal certificate that no browser ever sees.

sudo mkdir -p /etc/haproxy/ssl

# One command, valid 10 years. Replace the IP with the address Cert Camel
# will actually connect to - that is what has to match the SAN.
sudo openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
  -keyout /etc/haproxy/ssl/dataplaneapi.key \
  -out    /etc/haproxy/ssl/dataplaneapi.crt \
  -subj   "/CN=lb1" \
  -addext "subjectAltName = IP:10.0.0.11, DNS:lb1"

# The key must not be world-readable. Match the user the API runs as.
sudo chown haproxy:haproxy /etc/haproxy/ssl/dataplaneapi.*
sudo chmod 600 /etc/haproxy/ssl/dataplaneapi.key
sudo chmod 644 /etc/haproxy/ssl/dataplaneapi.crt

Each node gets its own, with its own address in the SAN. Do not copy one between nodes — a certificate naming 10.0.0.11 presented by 10.0.0.12 is exactly the mismatch this is meant to catch.

A long expiry here is the right call

Ten years on an internal API certificate is deliberate. Nothing renews it automatically — Cert Camel cannot, for the reason above — so a short one becomes an outage on a date nobody has written down. If you would rather it were shorter, put the renewal in your own calendar first.

-addext needs OpenSSL 1.1.1 or newer, which covers anything currently supported. On something older, use a config file with a [v3_req] section instead.

Because it is self-signed, Cert Camel will not trust it by default. Tick allow self-signed / private certificates on the load balancer group — see section 9. That accepts this connection being unverified; it does not weaken anything else.

Put TLS on it

Basic authentication over plain HTTP sends the password, base64-encoded, on every single request. Base64 is not encryption. Terminate TLS on the API:

dataplaneapi:
  # Both of these are required. Without "scheme", no TLS listener starts
  # at all - see the warning below.
  scheme:
    - https
  tls:
    tls_host: 10.0.0.11
    tls_port: 5555
    tls_certificate: /etc/haproxy/ssl/dataplaneapi.crt
    tls_key: /etc/haproxy/ssl/dataplaneapi.key
Get these key names exactly right — wrong ones fail silently

The keys are tls_host and tls_port, not host and port (they mirror the --tls-host / --tls-port flags). And scheme is mandatory: without it no TLS listener starts, whatever is in the tls block. scheme must also be nested under dataplaneapi: — at the top level of the file it is ignored.

Get any of that wrong and there is no error and no warning. The API starts, answers on plain HTTP, and looks healthy. Listing only https and not http drops the plaintext listener entirely, which is the point.

Verify rather than assume: curl -sk https://HOST:5555/v3/services/haproxy/configuration/version should answer, and the same URL on http:// should refuse the connection.

Alternatively bind to localhost and reach it through an SSH tunnel, which needs no certificate at all.

If the API uses a private or self-signed certificate

Almost nobody puts a publicly trusted certificate on a management API, so this is the normal case rather than the exception. There are two ways to handle it, and the first is better:

Where does "the API's CA" come from?

It is whatever issued the certificate in tls_certificate in dataplaneapi.yml — there is no separate thing to go and find. Three cases cover almost everyone:

  • Self-signed on the load balancer. Then the certificate is its own issuer: copy that same file to the Cert Camel machine and import it.
  • An internal CA you already run. Issue the API certificate from it and import that CA's root, not the API certificate.
  • Nothing yet. Make a small CA for the purpose, below.

Trust follows the issuer, not the machine. Generating a certificate on the Cert Camel box and copying it to HAProxy only works if that box also holds the CA that signed it — otherwise you have moved the certificate without moving the trust, and nothing changes.

Note that Cert Camel cannot issue this one for you over ACME. It deploys through the API, so the API has to be reachable, and trusted, before Cert Camel can do anything at all.

Making a CA and an API certificate from scratch, on the load balancer:

# 1. a CA, once - keep ca.key somewhere safe, it signs everything below
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
  -keyout ca.key -out ca.crt -subj "/CN=Internal LB CA"

# 2. a key and request for THIS node
openssl req -newkey rsa:2048 -nodes \
  -keyout dpa.key -out dpa.csr -subj "/CN=lb1.internal"

# 3. sign it, with the SANs you will actually connect to - see below
openssl x509 -req -in dpa.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 825 -out dpa.crt \
  -extfile <(printf "subjectAltName=DNS:lb1.internal,IP:10.0.0.11")

# 4. point dataplaneapi.yml at dpa.crt / dpa.key, then restart the API

Repeat steps 2–3 per node with that node's own name and address, signed by the same ca.crt. Then import ca.crt once on the Cert Camel machine and every node is trusted.

  1. Install the API's CA on the machine running Cert Camel. Copy the CA certificate across and import it into the Windows Trusted Root Certification Authorities store — certutil -addstore -f Root ca.crt as administrator, or per-user via certutil -user -addstore Root ca.crt. Cert Camel then verifies the API normally and the tick-box stays off.
  2. Tick Skip TLS verification on the target. Fine for a lab, and the only option when you cannot install the CA. It affects only the connection to the API — certificate verification never trusts anything regardless, because it reads what a node serves rather than trusting it.

The certificate also needs the address you connect to in its SANs. A certificate issued only for lb1.internal will be rejected when Cert Camel is pointed at 10.0.0.11, correctly and confusingly.

Telling the two failures apart on Windows

Could not establish trust relationship for the SSL/TLS secure channel covers both "I do not trust this CA" and "the name does not match", which sends people to the wrong fix. To separate them, build the chain by hand in PowerShell:

$c = New-Object Security.Cryptography.X509Certificates.X509Certificate2 'ca.crt'
$ch = New-Object Security.Cryptography.X509Certificates.X509Chain
$ch.ChainPolicy.ExtraStore.Add($c); $ch.Build($leaf); $ch.ChainStatus

UntrustedRoot on its own means the chain is complete and only the root needs installing. PartialChain means you have the wrong CA file.

Ignore RevocationStatusUnknown and OfflineRevocation here. A private CA publishes no CRL, so they always appear, and they do not block Cert Camel — .NET leaves CheckCertificateRevocationList off, so its connections never ask. They do block curl.exe, which checks by default and reports exit 60; use --ssl-no-revoke there to skip only the revocation check while still verifying the chain. A curl failure that Cert Camel does not share is usually this and nothing more.

Restrict who can reach it

Start from what is actually standing there. The API password is a single factor, and it is the whole of the authentication: anyone who can reach port 5555 and knows it can rewrite your configuration. TLS does not change that arithmetic. A server certificate protects the client from connecting to an impostor; it does nothing to restrict who may connect to the real one, because an attacker simply does not verify it — the same thing Cert Camel's own allow self-signed tick does. The CA certificate is handed out to clients rather than kept from them. It is not a second credential.

So the question worth answering is who can reach the port at all, and the answer should be the machine running Cert Camel, and nothing else — not your workstation, not the rest of the office, not whatever else shares that subnet. Nothing else has any business here.

Restricting it leaves your sites alone: 5555 is the management API, and whatever HAProxy is proxying on 80 and 443 keeps answering everyone exactly as it did before.

How you enforce that is your system’s business, not this guide’s

A host firewall limiting 5555 to that one address, and something like fail2ban watching for repeated authentication failures, both fit here well and are worth having. Neither is configured by Cert Camel, both differ between distributions and versions, and a wrong rule locks you out of a load balancer — so the commands belong in your own operating system’s documentation rather than in this one. Go and use it; the two notes below are the parts that documentation will not cover.

If you restrict by source address, that address must not move

Give the Cert Camel machine a static address or a DHCP reservation first. On an ordinary lease it will eventually be handed a different one, and from that moment every deployment fails with a timeout — which this guide tells you to read as something in the way, on a machine where nobody changed anything. That is a genuinely slow thing to work out months later.

If that machine has more than one interface, or reaches the load balancer over a VPN, use the address the load balancer actually sees rather than the one the machine calls itself.

Failed logins are logged — but not at the default log level

The Data Plane API records a failed authentication with the source address and a 401, which is exactly what anything watching for repeated failures needs:

level=info msg="10.0.0.50 - - [16/Aug/2026:19:56:38 +0000] \"GET /v3/services/haproxy/configuration/version HTTP/1.1\" 401 37 \"-\" \"curl/8.13.0\""

Note level=info. The API’s own default is warning, at which those lines are never written at all — so anything built on them matches nothing, forever, while looking perfectly healthy. Set log_level: info and keep access in log_types. The line also arrives wrapped in the API’s log format rather than as a plain access log, so a stock filter will need adjusting to match it.

One more, about Cert Camel rather than about the API: it retries on a schedule with a stored password. Rotate the credential on the load balancer without updating it here, and unattended renewal produces failed logins against every node until somebody notices — every six hours, not once a day, so a ban threshold arrives sooner than you might expect. Anything that bans on repeated failures will duly ban Cert Camel, and keep it banned after you have fixed the password — so exempt its address from the ban rather than discovering this at four in the morning.

And if these are a keepalived pair, whatever you put in front of them must not drop protocol 112: the nodes stop hearing each other and both become master. That failure and how to recognise it are in section 12.

The rest

  • A dedicated user for the API, not the stats login.
  • A long random password. Nobody types it — Cert Camel stores it DPAPI-encrypted — so there is no reason for it to be memorable.
  • Not root. Run as the HAProxy user with just enough rights to reload.
  • Rotate it like any other credential, and immediately if the config file containing the hash leaks.
  • Watch the logs. Every configuration change goes through this API, so its log is an audit trail worth keeping.

9. Prove it works

From the machine that will run Cert Camel, not from the load balancer itself — the point is to test the path that will actually be used:

# should return a number, the current config version
curl -su certcamel:PASSWORD http://10.0.0.11:5555/v3/services/haproxy/configuration/version

# should return a JSON array, possibly empty
curl -su certcamel:PASSWORD http://10.0.0.11:5555/v3/services/haproxy/storage/ssl_certificates

If you already enabled TLS in step 8, use https:// with --cacert ca.crt (or -k for a quick check) — the http:// form above will be refused, which is exactly what step 8 configured, not a fault to debug.

If /v3 gives a 404, try /v2 — older builds serve that. Cert Camel probes both, so either is fine.

What the responses mean

A number — working. 401 — the userlist name in the YAML does not match the one in haproxy.cfg, or the hash is wrong. Connection refused — not running, or bound to an address you are not reaching it on. Timeout — a firewall between you and it.

10. Prepare your certificate paths

HAProxy identifies a certificate by its file path. That single fact decides whether hitless replacement is possible at all. A path containing a date or a version cannot be updated in place, because next time it is a different path — which means editing the config, which means a reload.

Before                                     After
/certs/2026.example.com/fullchain.pem  ->  /etc/haproxy/ssl/example.com.pem
/certs/2027.example.com/fullchain.pem      (path never changes; contents replaced)

Two things to do:

  1. Move the certificates into ssl_certs_dir — the API only manages files there. This is usually more work than the config lines.
  2. Give each certificate a stable filename with no date in it, and point the bind line at it.
frontend fe_https
    bind *:443 ssl crt /etc/haproxy/ssl/example.com.pem alpn h2,http/1.1

Or, better, a crt-list — which additionally lets you add a new certificate at runtime, not just replace an existing one. Cert Camel makes direct use of this: give it the crt-list path (in the deployment group's crt-list path field, exactly as it appears on the bind line) and any certificate it pushes that the list does not reference yet is appended and hot-loaded — a brand-new certificate starts serving with no config edit and no reload window to schedule. The list must live inside ssl_certs_dir, since that is the only directory the API manages:

frontend fe_https
    bind *:443 ssl crt-list /etc/haproxy/ssl/crt-list.txt alpn h2,http/1.1
# /etc/haproxy/ssl/crt-list.txt
/etc/haproxy/ssl/example.com.pem
/etc/haproxy/ssl/other.com.pem
One reload to migrate. Zero reloads afterwards.

The move to stable paths costs exactly one restart. Every renewal after that is hitless. If you version certificates by keeping old copies, Cert Camel already archives every previous version under certs\<id>\history\ with the issuer, validity and names recorded — finer-grained than a folder per year.

A PEM for HAProxy is leaf, then intermediates, then the private key, in one file. That is exactly what Cert Camel's <name>-full.pem already is.

Keep ssl_certs_dir to serving certificates only

Anyone holding the API password can list and read this directory. It is easy to end up using it as a general key store — the API's own tls_key, a private CA key, spare .key and .csr files — because it is already there and already has the right permissions.

Do not. A CA key sitting there means one leaked API password mints certificates your estate trusts, and the API's own key means the API can be impersonated. Give the Data Plane API a directory containing only the combined PEMs it serves, and keep everything else somewhere it does not manage.

11. Point Cert Camel at it

Settings → Load balancers → Add a load balancer group. One group per set of nodes sharing credentials. Nodes go one per line:

lb1 https://10.0.0.11:5555
lb2 https://10.0.0.12:5555

The URL is the base only — scheme, host and port, with no /v3 or /v2 on the end. Cert Camel adds the version itself, after asking the API which one it speaks. Nearly every example URL in HAProxy's documentation carries a /v3, so this is an easy one to paste in by accident; Cert Camel strips a trailing version segment rather than building /v3/v3/… and reporting a 404 that reads like a missing endpoint.

Fill in the username and password from step 5. The two filename-related fields depend on which bind form you chose in step 10:

  • Using a crt-list (the recommended form): put its path in crt-list path, exactly as it appears on the bind line, and leave Certificate filename on HAProxy at the default {certId}.pem. Each certificate gets its own stable file, and Cert Camel appends the reference to the list itself the first time it pushes one the list does not know.
  • Using a plain crt line: leave crt-list path empty and set Certificate filename on HAProxy to the exact name on the bind line — see the warning below, because this is the one that bites silently.

Then press Test — it reports per node, with the API version and the certificates each one can see. Then save, assign the group to a certificate on its row in the Certificates table, and press Deploy.

Without a crt-list, the filename must match what HAProxy actually binds

HAProxy knows a certificate by its path, so on a plain crt bind line, Certificate filename on HAProxy has to be the exact name that line references. If your bind line says crt /etc/haproxy/ssl/lab.pem, the filename is lab.pem — not the default {certId}.pem, and not the certificate's own name.

Get it wrong and nothing looks wrong. The upload succeeds, the API reports success, and the file lands in ssl_certs_dir under a name no bind line references. (With a crt-list path configured this failure mode disappears: an unreferenced file gets appended to the list rather than stranded.)

The deployment log labels its checks, and this is the case that shows why there is more than one:

  • T0 — the bundle itself is valid: it parses, and the private key matches the certificate. Runs before anything is sent anywhere.
  • T1 — the node's API accepted the upload. Per node.
  • T3 — the node is really serving it: Cert Camel opens a TLS connection to that node and compares the serial number of what comes back. Per node, per name.

A filename that does not match your bind line passes T1 and fails T3. T1 only ever meant "the API said yes to a file write"; T3 is the only check that reads what the node is actually serving, so when they disagree, trust T3 over the green tick above it.

12. Pairs and clusters

Every node needs the Data Plane API installed and configured. They can share a username and password — that is what a group is — but each is pushed to and verified individually.

Never verify through the VIP

With a floating VIP — keepalived, VRRP, anything — connecting to the VIP only ever reaches whichever node currently holds it. Push to two nodes, verify through the VIP, get a green tick, and a stale certificate can sit on the standby for months until a failover puts it in front of traffic. Cert Camel connects to each node's own address for exactly this reason, which is why the Deployed column shows one pip per node rather than one tick per group.

If your nodes serve on an address different from their API address, put it as a third value on the node line: lb1 https://10.0.0.11:5555 10.0.0.11. It may carry its own port when a node does not answer on the group's verify port — lb1 https://10.0.0.11:5555 10.0.0.11:8443 — which is what you need when the nodes are reached through per-node forwards rather than directly.

Which address, exactly

Each node's own permanent IP — the address that belongs to that box and never moves — on the port HAProxy serves the site on. Not the VIP, which floats. In most builds that permanent address is also the management address, but the thing that matters is not that it is used for management: it is that HAProxy is listening for the site on it, on that particular node. By default Cert Camel uses the host from the API URL, which is normally the same machine and needs no configuration at all.

Check what your bind line listens on before relying on this

bind *:443 listens on every address the node has, so each node answers on its own IP and per-node verification works.

bind 10.0.0.100:443 — binding the VIP explicitly — does not. The standby has nothing listening on its own address, so T3 against it fails with a refused connection even though the push worked perfectly. That is a reporting problem, not a deployment one.

If you bind the VIP explicitly, either add a per-node listener for verification to reach, or accept that only the active node can be verified over the network, and treat the standby's T1 result as what you have. Do not "fix" it by pointing verification at the VIP — that turns a visible gap into a green tick that means nothing.

13. When it breaks

SymptomUsually
401 Unauthorized The userlist name in the YAML does not match haproxy.cfg, or the hash was pasted with a line break. Confirm with haproxy -c -f.
403 Forbidden Authenticated but not allowed to write — usually the stats socket is missing level admin.
409 Conflict The configuration version moved between reading it and writing. Something else changed HAProxy mid-flight; retry.
Upload succeeds, nothing changes The certificate went into ssl_certs_dir, but nothing references that path. Check the bind line or crt-list actually points at the filename you uploaded — or set the group's crt-list path field, and Cert Camel appends the reference itself.
Works, then reverts later Something is pushing over the Runtime API instead, or a config-management tool is rewriting the directory. Memory-only changes do not survive a reload.
One node green, one red Working as intended — that is the check earning its keep. Look at the failing node's API and its own logs.

The Data Plane API's own log is the first place to look; it records every request including the ones it rejected.

14. Complete example configurations

Everything above as three finished files, for a pair of load balancers. Adapted from a working two-node lab rather than written from memory — the structure, the health check and the reload behaviour have all actually been run.

The example is lb1 and lb2 sharing a virtual address that moves between them:

lb1   10.0.0.11   priority 110   normally MASTER
lb2   10.0.0.12   priority 100   normally BACKUP
VIP   10.0.0.10                  wherever the master is
What Cert Camel writes, and what it never touches

Of everything below, Cert Camel only ever writes certificate storage and crt-list entries. It never writes a bind line, never creates a frontend, and never edits your configuration file. If a certificate is not being served, the frontend and its crt-list reference are yours to fix — the tool will not have changed them.

haproxy.cfg

Identical on both nodes except the node line. Nothing here is Cert Camel-specific except the admin socket and the crt-list, both marked.

global
    log /dev/log local0
    chroot /var/lib/haproxy
    user haproxy
    group haproxy
    daemon

    # REQUIRED BY THE DATA PLANE API. It drives HAProxy through this socket,
    # and "level admin" is what lets it load certificates at runtime.
    stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
    stats timeout 30s

    # OPTIONAL, and think before you set it - see the note under this config.
    # It names the node in the stats page and the logs. Cert Camel does NOT
    # need it: it labels each node from its own settings and tells them apart
    # by URL.
    node lb1
    description edge load balancer, site A

    ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
    tune.ssl.default-dh-param 2048

defaults
    log     global
    mode    http
    option  httplog
    option  dontlognull
    timeout connect 5s
    timeout client  30s
    timeout server  30s
    retries 3

frontend fe_http
    bind *:80
    http-request redirect scheme https unless { ssl_fc }

frontend fe_https
    # CERT CAMEL WRITES THE crt-list, NOT THIS LINE. The bind stays as you
    # wrote it; deployment adds and updates entries in the file it points at,
    # so new certificates appear without the configuration changing.
    bind *:443 ssl crt-list /etc/haproxy/certs/crt-list.txt alpn h2,http/1.1

    http-response set-header Strict-Transport-Security "max-age=31536000"
    default_backend be_app

backend be_app
    balance roundrobin
    option httpchk GET /healthz
    http-check expect status 200
    server app1 10.0.1.21:8080 check
    server app2 10.0.1.22:8080 check

# Bound to the node's own address, not the VIP - you want to reach a SPECIFIC
# node when working out which one is misbehaving.
listen stats
    bind 10.0.0.11:9000
    stats enable
    stats uri /
    stats refresh 10s
    stats auth admin:CHANGE-THIS

userlist dataplaneapi
    user certcamel insecure-password CHANGE-THIS
    # Better - a hashed password, see section 5:
    # user certcamel password $5$rounds=...
A word about node if you replicate this file

Plenty of pairs keep the two configs in step by copying one to the other — rsync on a cron, Ansible, a git hook. A per-node node line fights that directly: it is the one line that must differ, so either the files never match and every diff is noisy, or the copy wins and lb2 starts calling itself lb1. The second is worse, because everything keeps working and only the labels lie.

Cert Camel does not need it. Each node is labelled from the name you gave it in Settings → Certificate Deployments, and nodes are told apart by their API URL, which is per node by construction. HAProxy's own node value is shown next to that as a secondary id when it is set, and simply omitted when it is not.

So: if you replicate configs, leave node out, or template it rather than copying it. If you maintain each node's config by hand, set it — it is genuinely useful in the stats page and the logs.

And the crt-list it points at. Create the file even if it is empty — HAProxy will not start with a crt-list that does not exist, and Cert Camel appends to it rather than creating it:

# /etc/haproxy/certs/crt-list.txt
# One line per certificate. Cert Camel maintains these.
/etc/haproxy/certs/example.com.pem
/etc/haproxy/certs/wildcard.example.com.pem
The first entry is the default certificate

HAProxy serves the first line to any client that does not send SNI. Put the name you most want answered correctly at the top, and do not be surprised when an SNI-less probe reports it.

keepalived.conf — the VRRP pair

This is what moves the virtual address between the two nodes, and it is separate from HAProxy entirely: HAProxy binds *:443 on both machines and has no idea a virtual address exists. keepalived decides which node currently holds it.

lb1, the higher priority:

global_defs {
    router_id lb1
    enable_script_security
    script_user root
}

# The piece people leave out, and the one that matters most. Without it the VIP
# happily stays on a node whose HAProxy has died - the machine is still up, so
# VRRP sees nothing wrong, and every request goes to a dead listener.
vrrp_script chk_haproxy {
    script "/usr/bin/killall -0 haproxy"
    interval 2
    timeout 2
    fall 2      # 2 failures before it counts as down
    rise 2      # 2 successes before it counts as up again
    weight -30  # drop priority by 30 - enough to lose to the backup
}

vrrp_instance VI_1 {
    state MASTER
    interface eth0
    virtual_router_id 51     # MUST match on both nodes, and be unique on the LAN
    priority 110             # higher wins
    advert_int 1

    authentication {
        auth_type PASS
        auth_pass CHANGE-ME  # 8 characters maximum, and sent in clear
    }

    virtual_ipaddress {
        10.0.0.10/24 dev eth0
    }

    track_script {
        chk_haproxy
    }
}

lb2 is the same file with three changes — everything else, especially virtual_router_id, auth_pass and virtual_ipaddress, stays identical:

    router_id lb2
    state BACKUP
    priority 100
Three ways a VRRP pair goes wrong

Both nodes think they are master. They cannot see each other's advertisements — a firewall dropping protocol 112, or a switch blocking multicast. Both hold the VIP and traffic goes wherever ARP last landed. If multicast is unreliable on your network, use unicast instead:

    unicast_src_ip 10.0.0.11
    unicast_peer {
        10.0.0.12
    }

A mismatched virtual_router_id produces exactly the same symptom, because each node is effectively alone in its own group.

Two unrelated pairs sharing a virtual_router_id on one LAN will fight over each other's addresses. It only has to be unique on the broadcast domain — but it does have to be.

Should the master take the VIP back when it recovers?

By default it does, so a flapping node moves the address back and forth. Add nopreempt to the instance (with state BACKUP on both nodes) if you would rather it stay put until something makes it move. Neither is wrong — decide which you want before an incident rather than during one.

dataplaneapi.yml

One per node, each managing only its own HAProxy. Full detail is in section 6; this is the whole file for lb1:

config_version: 2
name: lb1

dataplaneapi:
  host: 0.0.0.0
  port: 5555

  # TLS on the API itself. It carries private keys and a password, so this is
  # not optional. Section 8 has the one openssl command that creates these two
  # files - and note Cert Camel cannot issue this certificate, because it
  # deploys THROUGH the API, so the API has to be up before the tool works.
  # Self-signed is fine; tick "allow self-signed" in Cert Camel.
  tls:
    tls_host: 0.0.0.0
    tls_port: 5555
    tls_certificate: /etc/haproxy/ssl/dataplaneapi.crt
    tls_key: /etc/haproxy/ssl/dataplaneapi.key
  scheme:
    - https

  # Authentication comes from the HAProxy userlist above - section 5.
  userlist:
    userlist: dataplaneapi

  transaction:
    transaction_dir: /var/lib/dataplaneapi/transactions
    backups_number: 10
    backups_dir: /var/lib/dataplaneapi/backups

  resources:
    # WHERE CERT CAMEL WRITES CERTIFICATES. This must be the same directory
    # your crt-list entries point at, or deployment succeeds and HAProxy keeps
    # serving the old certificate - one of the more confusing failures, because
    # nothing reports an error.
    ssl_certs_dir: /etc/haproxy/certs
    maps_dir: /var/lib/dataplaneapi/maps
    general_storage_dir: /var/lib/dataplaneapi/general
    spoe_dir: /var/lib/dataplaneapi/spoe

haproxy:
  config_file: /etc/haproxy/haproxy.cfg
  haproxy_bin: /usr/sbin/haproxy
  reload:
    reload_delay: 2
    reload_cmd: systemctl reload haproxy
    restart_cmd: systemctl restart haproxy
    reload_strategy: custom

log_targets:
  - log_to: file
    log_file: /var/log/dataplaneapi.log
    log_level: info
    log_types:
      - access
      - app

Pointing Cert Camel at the pair

One group, two nodes, each reached by its own address. A node line takes up to three fields:

name   Data Plane API URL        verify address (optional)

lb1    https://10.0.0.11:5555    10.0.0.11:443
lb2    https://10.0.0.12:5555    10.0.0.12:443

The first two are how Cert Camel deploys. The third is how it checks the result: it opens a TLS connection there, asks for each name in turn, and compares the serial of what comes back. Different jobs, so different addresses — and this one has to reach a specific node, which is what catches a standby that missed an update.

Leave it blank if your frontends bind per-domain addresses

The example above works because bind *:443 means every address on the box has a listener. If instead each domain has its own frontend on its own address — bind 203.0.113.7:443 and so on — then the node's management address has no :443 at all, and the only thing left to point at is an address that floats between nodes. That would only ever check whichever node currently holds it, which is exactly the blind spot this field exists to remove.

So leave it empty. Cert Camel then asks that node's own Data Plane API what its running HAProxy has loaded, and compares the same serial. Per node, no extra listener, no spare address.

It is weaker evidence, and the log says so. It proves the right certificate is loaded and in use in the running process; it cannot prove that a client asking for a given name is served it, because a frontend pointing at the wrong crt-list would still pass. Where you can give a verify address, do — it is the stronger check.

Never point it at the virtual address

A deployment through the VIP reaches whichever node happens to hold it, so the other silently keeps the old certificate — and stays wrong until it takes over, which is the worst possible moment to find out. Verification follows the same rule: each node is checked by its own address, which is how a standby that missed an update gets caught.

Checking it before you trust it

# configuration is valid
haproxy -c -f /etc/haproxy/haproxy.cfg

# who holds the VIP right now - run on both, exactly one should show it
ip addr show eth0 | grep 10.0.0.10

# watch a failover: stop HAProxy on the master and the VIP should move
systemctl stop haproxy && journalctl -fu keepalived

# what a node is actually serving, asked of the node itself
echo | openssl s_client -connect 10.0.0.11:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -dates

That last one is worth keeping. It is what Cert Camel does after every deployment, against every node individually — and running it by hand is how you confirm the tool is telling you the truth.