Learn how to combine HAProxy, Consul and consul-template to build a self-updating load balancer that discovers backends automatically, reacts to health checks, and reloads without downtime.
Prerequisites
- A running Consul server cluster (or single dev-mode server) reachable from the HAProxy node
- Root or sudo access on all nodes
- Basic familiarity with HAProxy configuration syntax
- Application services exposing an HTTP health check endpoint
What this solves
Static HAProxy backend lists break down once services scale up, scale down or move between hosts. This tutorial wires HAProxy to Consul's service catalog through consul-template, so backend servers are generated dynamically from live health check data and configuration reloads happen automatically when the topology changes.
By the end you will have a Consul agent registering services with health checks, consul-template rendering HAProxy configuration from those checks, and a reload pipeline that survives scaling events and failures without manual intervention.
Step-by-step installation
Install HAProxy
HAProxy will act as the load balancer that receives dynamically generated backend definitions.
sudo apt update
sudo apt install -y haproxysudo dnf install -y haproxyInstall the Consul agent
Each HAProxy node runs a local Consul agent in client mode, joined to your Consul cluster, to query service health data with low latency.
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update
sudo apt install -y consulsudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo dnf install -y consulConfigure the Consul client agent
This configuration joins the node to an existing Consul server cluster. Replace 203.0.113.10 with your actual Consul server addresses.
datacenter = "dc1"
data_dir = "/opt/consul"
bind_addr = "{{ GetInterfaceIP \"eth0\" }}"
retry_join = ["203.0.113.10", "203.0.113.11", "203.0.113.12"]
client_addr = "127.0.0.1"
enable_local_script_checks = true
ports {
grpc = 8502
}sudo mkdir -p /opt/consul
sudo chown -R consul:consul /opt/consul
sudo systemctl enable --now consul
consul membersRegister a service with health checks
Services register themselves in Consul's catalog along with a health check. HAProxy will only route to instances that pass this check.
{
"service": {
"name": "web-app",
"port": 8080,
"tags": ["http"],
"check": {
"http": "http://localhost:8080/healthz",
"interval": "10s",
"timeout": "3s",
"deregister_critical_service_after": "90s"
}
}
}sudo systemctl reload consul
consul catalog services
consul health checks web-appRepeat this registration file on every application host, adjusting the port for each instance. Consul tracks each instance independently by node and check status.
Install consul-template
consul-template watches the Consul catalog for changes and renders templates, in this case an HAProxy configuration file, whenever backend state changes.
sudo apt install -y consul-templatesudo dnf install -y consul-template/usr/local/bin with chmod 755.Create the HAProxy template
This template queries Consul for healthy instances of web-app and generates an HAProxy frontend and backend block. It also defines the stats page and load balancing algorithm.
global
log /dev/log local0
maxconn 20000
user haproxy
group haproxy
defaults
log global
mode http
timeout connect 5s
timeout client 30s
timeout server 30s
option httplog
frontend stats
bind *:8404
stats enable
stats uri /stats
stats refresh 10s
stats auth admin:{{ key "haproxy/stats_password" }}
frontend web_front
bind *:80
default_backend web_back
backend web_back
balance leastconn
option httpchk GET /healthz
{{ range service "web-app" }}
server {{ .Node }}-{{ .Port }} {{ .Address }}:{{ .Port }} check
{{ end }}consul kv put haproxy/stats_password 'S3cur3-Stats!Pass' so it never appears in version control.Configure consul-template to render and reload
This configuration tells consul-template where to write the rendered file and what command to run afterward to validate and reload HAProxy safely.
consul {
address = "127.0.0.1:8500"
}
template {
source = "/etc/consul-template/haproxy.ctmpl"
destination = "/etc/haproxy/haproxy.cfg"
command = "haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxy"
command_timeout = "30s"
wait {
min = "2s"
max = "10s"
}
}The wait block debounces rapid-fire changes during scaling events, so a burst of instance registrations does not trigger dozens of reloads in a few seconds.
Set correct ownership and permissions
consul-template needs to write to the HAProxy config directory and trigger a reload. Rather than opening permissions widely, grant a dedicated service account exactly what it needs.
sudo useradd -r -s /usr/sbin/nologin consul-template
sudo chown -R consul-template:haproxy /etc/haproxy
sudo chmod 750 /etc/haproxy
sudo chmod 640 /etc/haproxy/haproxy.cfgconsul-template and haproxy instead, with 750/640 permissions so only the two accounts that need access have it.Grant the reload command via sudoers instead of running consul-template as root:
consul-template ALL=(root) NOPASSWD: /bin/systemctl reload haproxy, /usr/sbin/haproxy -c -f /etc/haproxy/haproxy.cfgRun consul-template as a systemd service
Running consul-template under systemd ensures it restarts on failure and starts on boot alongside Consul and HAProxy.
[Unit]
Description=Consul Template for HAProxy
After=network-online.target consul.service
Wants=network-online.target
[Service]
User=consul-template
Group=haproxy
ExecStart=/usr/bin/consul-template -config=/etc/consul-template/config.hcl
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now consul-template
sudo systemctl status consul-templateEnable and start HAProxy
Start HAProxy once the first rendered config is in place.
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl enable --now haproxy
sudo systemctl status haproxyVerify your setup
Confirm Consul sees the service as healthy, the template rendered correctly, and HAProxy is routing traffic.
consul catalog services
consul health checks web-app
cat /etc/haproxy/haproxy.cfg | grep -A5 backend
curl -I http://127.0.0.1/
curl -u admin:S3cur3-Stats!Pass http://127.0.0.1:8404/statsRegister a second instance of web-app on another port and watch the backend update without restarting HAProxy manually.
sudo journalctl -u consul-template -fYou should see a render event followed by a successful reload log line within a few seconds of the new registration.
Testing failover and scaling scenarios
Simulate a backend failure
Stop the application process on one instance and confirm Consul marks the check critical, and that consul-template removes it from the backend within one interval cycle.
sudo systemctl stop web-app
consul health checks web-app
curl -u admin:S3cur3-Stats!Pass http://127.0.0.1:8404/statsThe stats page should show the server entry as down, and the HAProxy config should no longer list it after the next consul-template render.
Simulate horizontal scale-out
Register three additional instances in quick succession to confirm the debounce window in the wait block coalesces the reload into a single event instead of five separate reloads.
for port in 8081 8082 8083; do
sudo tee /etc/consul.d/web-app-$port.json > /dev/null <<EOF
{"service":{"name":"web-app","port":$port,"check":{"http":"http://localhost:$port/healthz","interval":"10s"}}}
EOF
done
sudo systemctl reload consulWatch journalctl -u consul-template -f during this test. You should see one render cycle cover all three new instances rather than three separate reload commands.
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| HAProxy config never updates | consul-template cannot reach the Consul HTTP API | Check consul_addr in config.hcl and confirm curl 127.0.0.1:8500/v1/status/leader returns a leader |
| Reload command fails silently | consul-template user lacks sudo rights for systemctl reload | Verify the sudoers entry with sudo -l -U consul-template |
| Backend flaps rapidly under load | Health check interval too aggressive for app startup time | Increase interval and add a startup grace period with deregister_critical_service_after |
| Stats page returns 403 | Wrong credentials or password stored incorrectly in Consul KV | Re-run consul kv get haproxy/stats_password and confirm it matches your curl command |
| New instances never appear in backend | Health check failing due to firewall blocking the check port | Allow the specific port with sudo ufw allow from 203.0.113.0/24 to any port 8080 instead of disabling the firewall |
Next steps
- Set up HAProxy high availability with keepalived clustering for automatic failover
- Configure HAProxy advanced routing with ACLs and maps for intelligent traffic management
- Monitor HAProxy and Consul with Prometheus and Grafana dashboards
- Implement Consul multi-datacenter replication with WAN federation
- Implement Consul ACL security and encryption for production deployments
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------------------------------------
# HAProxy + Consul + consul-template dynamic service discovery installer
# ---------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}$*${NC}"; }
log_warn() { echo -e "${YELLOW}$*${NC}"; }
log_err() { echo -e "${RED}$*${NC}" >&2; }
usage() {
cat <<EOF
Usage: $0 -j "<consul_join_ip1,consul_join_ip2,...>" [-i interface] [-a stats_password]
-j Comma-separated list of existing Consul server IPs to join (required)
-i Network interface to bind Consul to (default: eth0)
-a Password for the HAProxy stats page (default: randomly generated)
Example:
$0 -j "203.0.113.10,203.0.113.11,203.0.113.12" -i eth0 -a MySecret123
EOF
exit 1
}
# --- Parse arguments ---------------------------------------------------
JOIN_IPS=""
IFACE="eth0"
STATS_PASS="$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16 || true)"
while getopts ":j:i:a:h" opt; do
case "$opt" in
j) JOIN_IPS="$OPTARG" ;;
i) IFACE="$OPTARG" ;;
a) STATS_PASS="$OPTARG" ;;
h) usage ;;
*) usage ;;
esac
done
[ -z "$JOIN_IPS" ] && { log_err "Error: -j <consul_join_ips> is required"; usage; }
# --- Root check ----------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
log_err "This script must be run as root (use sudo)."
exit 1
fi
# --- Distro detection ------------------------------------------------------
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian) PKG_MGR="apt"; PKG_INSTALL="apt install -y" ;;
almalinux|rocky|centos|rhel|ol|fedora) PKG_MGR="dnf"; PKG_INSTALL="dnf install -y" ;;
amzn) PKG_MGR="yum"; PKG_INSTALL="yum install -y" ;;
*) log_err "Unsupported distro: $ID"; exit 1 ;;
esac
else
log_err "/etc/os-release not found, cannot detect distro."
exit 1
fi
HAPROXY_CFG="/etc/haproxy/haproxy.cfg"
HAPROXY_SVC="haproxy"
CONSUL_SVC="consul"
TOTAL_STEPS=9
STEP=0
# --- Rollback on failure --------------------------------------------------
cleanup_on_error() {
log_err "Installation failed at step $STEP. Rolling back partial changes..."
systemctl stop consul-template 2>/dev/null || true
systemctl stop consul 2>/dev/null || true
systemctl stop haproxy 2>/dev/null || true
log_warn "Services stopped. Check logs above for the failing command."
}
trap cleanup_on_error ERR
progress() {
STEP=$((STEP + 1))
echo -e "${GREEN}[$STEP/$TOTAL_STEPS] $*${NC}"
}
# --- 1. Update package cache -----------------------------------------------
progress "Updating package cache..."
if [ "$PKG_MGR" = "apt" ]; then
apt update
elif [ "$PKG_MGR" = "dnf" ]; then
dnf makecache -y >/dev/null 2>&1 || true
fi
# --- 2. Install HAProxy -----------------------------------------------------
progress "Installing HAProxy..."
$PKG_INSTALL haproxy
# --- 3. Add HashiCorp repo and install Consul + consul-template -------------
progress "Adding HashiCorp repository and installing Consul..."
if [ "$PKG_MGR" = "apt" ]; then
command -v curl >/dev/null || $PKG_INSTALL curl
command -v gpg >/dev/null || $PKG_INSTALL gnupg
curl -fsSL https://apt.releases.hashicorp.com/gpg | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
> /etc/apt/sources.list.d/hashicorp.list
apt update
$PKG_INSTALL consul
# consul-template may not be in every mirror; fall back to binary if needed
if ! $PKG_INSTALL consul-template; then
log_warn "consul-template not found in apt repo; will fetch binary release."
fi
else
$PKG_INSTALL dnf-plugins-core 2>/dev/null || true
dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
$PKG_INSTALL consul
if ! $PKG_INSTALL consul-template; then
log_warn "consul-template not found in dnf repo; will fetch binary release."
fi
fi
# Fallback: download consul-template binary if package install failed
if ! command -v consul-template >/dev/null 2>&1; then
progress "Downloading consul-template binary release..."
CT_VERSION="0.37.4"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) CT_ARCH="amd64" ;;
aarch64) CT_ARCH="arm64" ;;
*) log_err "Unsupported architecture: $ARCH"; exit 1 ;;
esac
TMP_ZIP="$(mktemp)"
curl -fsSL -o "$TMP_ZIP" \
"https://releases.hashicorp.com/consul-template/${CT_VERSION}/consul-template_${CT_VERSION}_linux_${CT_ARCH}.zip"
command -v unzip >/dev/null || $PKG_INSTALL unzip
unzip -o "$TMP_ZIP" -d /usr/local/bin consul-template
chmod 755 /usr/local/bin/consul-template
rm -f "$TMP_ZIP"
else
progress "consul-template already installed via package manager."
fi
# --- 4. Configure Consul client agent ----------------------------------------
progress "Configuring Consul client agent..."
mkdir -p /etc/consul.d
mkdir -p /opt/consul
# Build the retry_join JSON array from comma-separated input
IFS=',' read -ra IP_ARR <<< "$JOIN_IPS"
JOIN_JSON=$(printf '"%s", ' "${IP_ARR[@]}")
JOIN_JSON="[${JOIN_JSON%, }]"
cat > /etc/consul.d/consul.hcl <<EOF
datacenter = "dc1"
data_dir = "/opt/consul"
bind_addr = "{{ GetInterfaceIP \"${IFACE}\" }}"
retry_join = ${JOIN_JSON}
client_addr = "127.0.0.1"
enable_local_script_checks = true
ports {
grpc = 8502
}
EOF
chmod 644 /etc/consul.d/consul.hcl
# consul user/group is created by the package; ensure ownership is correct
if id consul >/dev/null 2>&1; then
chown -R consul:consul /opt/consul /etc/consul.d
else
log_warn "consul system user not found; leaving default ownership."
fi
# --- 5. Start Consul and verify cluster join ----------------------------------
progress "Enabling and starting Consul service..."
systemctl enable --now "$CONSUL_SVC"
sleep 3
if ! consul members >/dev/null 2>&1; then
log_err "Consul agent did not start correctly."
exit 1
fi
# --- 6. Register example service with health check ----------------------------
progress "Registering sample web-app service definition..."
cat > /etc/consul.d/web-app.json <<EOF
{
"service": {
"name": "web-app",
"port": 8080,
"tags": ["http"],
"check": {
"http": "http://localhost:8080/healthz",
"interval": "10s",
"timeout": "3s",
"deregister_critical_service_after": "90s"
}
}
}
EOF
chmod 644 /etc/consul.d/web-app.json
[ -n "${SUDO_USER:-}" ] || true
systemctl reload "$CONSUL_SVC" || systemctl restart "$CONSUL_SVC"
# --- 7. Store stats password in Consul KV and create HAProxy template ---------
progress "Storing HAProxy stats password in Consul KV and creating template..."
sleep 2
consul kv put haproxy/stats_password "$STATS_PASS" >/dev/null
mkdir -p /etc/consul-template.d
mkdir -p /etc/consul-template/templates
cat > /etc/consul-template/templates/haproxy.ctmpl <<'EOF'
global
log /dev/log local0
maxconn 20000
user haproxy
group haproxy
defaults
log global
mode http
timeout connect 5s
timeout client 30s
timeout server 30s
option httplog
frontend stats
bind *:8404
stats enable
stats uri /stats
stats refresh 10s
stats auth admin:{{ key "haproxy/stats_password" }}
frontend web_front
bind *:80
default_backend web_back
backend web_back
balance leastconn
option httpchk GET /healthz
{{ range service "web-app" }}
server {{ .Node }}-{{ .Port }} {{ .Address }}:{{ .Port }} check
{{ else }}
# no healthy web-app instances registered yet
{{ end }}
EOF
chmod 644 /etc/consul-template/templates/haproxy.ctmpl
cat > /etc/consul-template.d/consul-template.hcl <<EOF
consul {
address = "127.0.0.1:8500"
}
template {
source = "/etc/consul-template/templates/haproxy.ctmpl"
destination = "${HAPROXY_CFG}"
command = "systemctl reload ${HAPROXY_SVC}"
command_timeout = "30s"
}
EOF
chmod 644 /etc/consul-template.d/consul-template.hcl
# --- 8. Create consul-template systemd unit and start it ----------------------
progress "Creating consul-template systemd service..."
cat > /etc/systemd/system/consul-template.service <<EOF
[Unit]
Description=Consul Template
After=network.target consul.service
[Service]
Type=simple
ExecStart=/usr/local/bin/consul-template -config=/etc/consul-template.d/consul-template.hcl
Review the script before running. Execute with: bash install.sh