← Start of tutorial

WIUNIX ACADEMY

HAProxy with Let’s Encrypt and validated certificate renewal

Page 2 of 4. Review the earlier steps and prerequisites before continuing.

YOUR SYSTEM. YOUR STEPS.

Where are you working?

Choose your OS and version. The prerequisites, commands and troubleshooting follow your environment.

Ubuntu 26.04 LTSReviewed against official documentation ·

USE YOUR OWN VALUES

Configure this tutorial

Set the example values for your environment; related explanations and copy panels update together. Never enter passwords, private keys or tokens. Values stay in this tab and are not sent to the server or share links. Check actual DNS, certificates and network policy before running commands.

Different IP addresses, domains or ports? Open the settings below, change the values and select “Apply to tutorial”, then copy the updated commands.

Set values for my environment7 editable values
08

LET’S BUILD

Prove the challenge path before contacting ACME

Start the two dedicated services only after configuration validation. From an independent internet host fetch the same probe without a resolve override, on every advertised A/AAAA path. Its exact body must be returned without redirect. Other paths return308 with the fixed hostname, not a client-controlled Host. Python http.server is a minimal lab responder, loopback-only, serving a webroot containing public tokens only; use your maintained static responder for a production service.

bash

Bash on dedicated edge VM; external checks noted in text

set -eu
sudo haproxy -c -f '/srv/academy-haproxy-tls/haproxy.cfg'
sudo systemctl daemon-reload
sudo systemctl start academy-acme-webroot.service academy-haproxy.service
printf '%s\n' academy-http01-probe | sudo tee '/srv/academy-haproxy-tls/webroot/.well-known/acme-challenge/academy-probe' >/dev/null
curl --fail --resolve 'app.example.com:80:192.0.2.10' 'http://app.example.com/.well-known/acme-challenge/academy-probe'
curl --head --resolve 'app.example.com:80:192.0.2.10' 'http://app.example.com/'
dig +short A 'app.example.com'
dig +short AAAA 'app.example.com'
How do I know it worked?

Probe returns academy-http01-probe and ordinary HTTP returns308 to the fixed HTTPS hostname.

Resolve DNS, IPv6 and CA prerequisites

All public A/AAAA addresses must reach this responder on port80; IPv6 is preferred and a wrong AAAA response is not reliably rescued by IPv4. This IPv4-only example requires no AAAA record; an IPv6 deployment needs an explicit IPv6 bind and independently tested routing. Check authoritative DNS, CAA permissions, DNSSEC errors, outbound HTTPS443 to ACME, system clock and provider/NAT/firewall reachability before ordering. HTTP-01 cannot issue wildcard names.

The next commands contact a public CA and may register an account after you review its terms. Use your real owned hostname. No issuance/account registration was performed for this article. Failures remain failures; do not suppress exit status, retry production orders blindly or promise a fixed certificate lifetime. Consult current rate limits and honor Retry-After.

09

LET’S BUILD

Try staging in a separate ACME tree

Review the CA terms interactively. Enter a monitored email in Certbot itself, never in reader settings. Staging uses separate accounts and untrusted certificates; nothing from this directory is deployed. A successful dry run is a real CA/network operation and is not proved by this page preview.

bash

Bash on dedicated edge VM; external checks noted in text

sudo certbot certonly --dry-run --webroot -w '/srv/academy-haproxy-tls/webroot' -d 'app.example.com' --cert-name 'app.example.com' \
  --config-dir '/srv/academy-haproxy-tls/acme/staging-config' \
  --work-dir '/srv/academy-haproxy-tls/acme/staging-work' \
  --logs-dir '/srv/academy-haproxy-tls/acme/staging-logs'
How do I know it worked?

Certbot reports a successful simulated issuance; staging material never enters the served PEM.

10

LET’S BUILD

Request the production certificate once

Proceed only after the external probe and staging succeed. Keep Certbot output and its failure status. Use a single hostname and the explicit cert-name so the protected lineage is predictable. This certonly command does not edit HAProxy.

bash

Bash on dedicated edge VM; external checks noted in text

sudo certbot certonly --webroot -w '/srv/academy-haproxy-tls/webroot' -d 'app.example.com' --cert-name 'app.example.com' \
  --config-dir '/srv/academy-haproxy-tls/acme/config' \
  --work-dir '/srv/academy-haproxy-tls/acme/work' \
  --logs-dir '/srv/academy-haproxy-tls/acme/logs'
sudo certbot certificates --config-dir '/srv/academy-haproxy-tls/acme/config' --work-dir '/srv/academy-haproxy-tls/acme/work' --logs-dir '/srv/academy-haproxy-tls/acme/logs'
How do I know it worked?

The named production lineage contains cert.pem, chain.pem, fullchain.pem and protected privkey.pem for the real domain.

11

LET’S BUILD

Assemble and validate the first protected PEM

The full chain precedes the matching private key. Verify hostname, server purpose, trust chain and at least one day remaining before combining them. The candidate is0600 and installed atomically on the same filesystem. Keep the ACME private tree0700; do not loosen its symlink targets to make HAProxy read them.

bash

Bash on dedicated edge VM; external checks noted in text

set -eu
sudo python3 - <<'PY'
import os, subprocess, tempfile
from pathlib import Path
os.umask(0o077)
root=Path('/srv/academy-haproxy-tls')
lineage=root/'acme/config/live/app.example.com'
target=root/'certs/app.example.com.pem'
assert not target.exists() and not target.is_symlink()
def run(*args):
    return subprocess.run(args,check=True,capture_output=True).stdout
run('openssl','x509','-in',str(lineage/'cert.pem'),'-checkend','86400','-noout')
run('openssl','verify','-CAfile','/etc/ssl/certs/ca-certificates.crt',
    '-untrusted',str(lineage/'chain.pem'),'-purpose','sslserver',
    '-verify_hostname','app.example.com',str(lineage/'cert.pem'))
assert run('openssl','x509','-in',str(lineage/'cert.pem'),'-pubkey','-noout') == run('openssl','pkey','-in',str(lineage/'privkey.pem'),'-pubout')
fd,name=tempfile.mkstemp(dir=target.parent,prefix='.initial-')
with os.fdopen(fd,'wb') as f:
    f.write((lineage/'fullchain.pem').read_bytes()+b'\n'+(lineage/'privkey.pem').read_bytes())
    f.flush(); os.fsync(f.fileno())
os.replace(name,target)
print('Initial protected PEM assembled; TLS config not yet loaded')
PY
How do I know it worked?

A root-owned0600 PEM exists; the HTTP bootstrap continues serving challenges unchanged.

12

LET’S BUILD

Save a complete TLS candidate without overwriting the live config

Confirm both private backends serve the same application and /health/ready returns200 only when dependencies are ready. Both must accept Host app.example.com. Session/state handling belongs to the application. Paste the next file into the new candidate; keep the running bootstrap intact. The configuration sets no HSTS preload/includeSubDomains and exposes no administrative socket.

bash

Bash on dedicated edge VM; external checks noted in text

set -eu
curl --fail -H 'Host: app.example.com' 'http://10.77.0.21:8080/health/ready'
curl --fail -H 'Host: app.example.com' 'http://10.77.0.22:8080/health/ready'
test ! -e '/srv/academy-haproxy-tls/haproxy.tls.cfg'
test ! -L '/srv/academy-haproxy-tls/haproxy.tls.cfg'
sudo install -m 0644 /dev/null '/srv/academy-haproxy-tls/haproxy.tls.cfg'
sudo nano '/srv/academy-haproxy-tls/haproxy.tls.cfg'
How do I know it worked?

Both readiness endpoints return200; the separate TLS candidate is ready to edit.

13

LET’S BUILD

TLS, trusted headers and readiness checks

This replaces the entire candidate file. Forwarded is removed, while X-Forwarded-* values are set from the direct connection. Do not add option forwardfor on top of this policy. Backends must trust only the actual private source address used by HAProxy, not every RFC1918 client.

text · /srv/academy-haproxy-tls/haproxy.tls.cfg

File on the dedicated edge VM

global
  log stdout format raw local0
  user haproxy
  group haproxy
  ssl-default-bind-options ssl-min-ver TLSv1.2

defaults
  log global
  mode http
  option httplog
  timeout connect 5s
  timeout client 30s
  timeout server 30s
  timeout http-request 10s

frontend public_http
  bind 192.0.2.10:80
  acl known_host hdr(host) -i app.example.com app.example.com:80
  http-request deny deny_status 421 unless known_host
  acl acme_path path_beg /.well-known/acme-challenge/
  http-request redirect prefix https://app.example.com code 308 unless acme_path
  use_backend acme_webroot if acme_path

backend acme_webroot
  server challenge 127.0.0.1:8888 check

frontend public_https
  bind 192.0.2.10:443 ssl crt /srv/academy-haproxy-tls/certs/app.example.com.pem alpn h2,http/1.1
  acl known_host hdr(host) -i app.example.com app.example.com:443
  http-request deny deny_status 421 unless known_host
  http-request del-header Forwarded
  http-request del-header X-Forwarded-For
  http-request del-header X-Forwarded-Proto
  http-request del-header X-Forwarded-Host
  http-request del-header X-Forwarded-Port
  http-request del-header X-Real-IP
  http-request set-header X-Forwarded-For %[src]
  http-request set-header X-Forwarded-Proto https
  http-request set-header X-Forwarded-Host app.example.com
  http-request set-header X-Forwarded-Port 443
  default_backend app_nodes

backend app_nodes
  balance roundrobin
  option httpchk
  http-check send meth GET uri /health/ready ver HTTP/1.1 hdr Host app.example.com
  http-check expect status 200
  server app_a 10.77.0.21:8080 check inter 2s fall 2 rise 2
  server app_b 10.77.0.22:8080 check inter 2s fall 2 rise 2
How do I know it worked?

Candidate has both public listeners, one loopback challenge backend and two checked application nodes.

Explore all tutorials ↗