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.
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
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 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'
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.
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 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'
Certbot reports a successful simulated issuance; staging material never enters the served PEM.
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 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'
The named production lineage contains cert.pem, chain.pem, fullchain.pem and protected privkey.pem for the real domain.
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 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
A root-owned0600 PEM exists; the HTTP bootstrap continues serving challenges unchanged.
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 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'
Both readiness endpoints return200; the separate TLS candidate is ready to edit.
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.
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
Candidate has both public listeners, one loopback challenge backend and two checked application nodes.