Build a self-updating load balancer by integrating HAProxy with Consul service discovery, consul-template, and health checks so backends register and deregister automatically without manual reloads.
Prerequisites
- Two or more Linux servers with root or sudo access
- Basic familiarity with HAProxy configuration syntax
- Network connectivity between hosts on Consul's default ports
- An application with a health check endpoint to register as a service
What this solves
Static HAProxy backend lists break down once services scale up, restart, or move between hosts. This tutorial wires HAProxy to Consul's service catalog using consul-template, so backend servers are added, removed, and health-checked automatically as your topology changes.
By the end, you will have Consul agents registering services, consul-template regenerating HAProxy configuration on catalog changes, reload automation without dropping connections, and ACL plus TLS hardening for the whole pipeline.
Step-by-step configuration
Install HAProxy and the Consul agent
Both packages ship in official repositories on Ubuntu, Debian, AlmaLinux and Rocky. Install HAProxy first, then add HashiCorp's repository for Consul.
sudo apt update
sudo apt install -y haproxy curl gnupg lsb-release
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 consul consul-templatesudo dnf install -y haproxy curl dnf-plugins-core
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo dnf install -y consul consul-templateCreate a dedicated Consul data directory and user
Consul should never run as root. Create a system user with a locked-down home directory for its data.
sudo useradd --system --home /var/lib/consul --shell /usr/sbin/nologin consul
sudo mkdir -p /var/lib/consul /etc/consul.d
sudo chown -R consul:consul /var/lib/consul
sudo chmod 750 /var/lib/consulConfigure the Consul agent
This example runs a single-node Consul server for the tutorial. In production you would run 3 or 5 server nodes for quorum, similar to the multi-datacenter setup in Consul multi-datacenter replication with WAN federation.
datacenter = "dc1"
data_dir = "/var/lib/consul"
server = true
bootstrap_expect = 1
bind_addr = "203.0.113.10"
client_addr = "127.0.0.1"
ui_config {
enabled = true
}
connect {
enabled = true
}sudo chown -R consul:consul /etc/consul.d
sudo chmod 640 /etc/consul.d/consul.hclCreate a systemd unit for Consul
Run Consul as a managed service so it restarts automatically and logs to journald.
[Unit]
Description=Consul service discovery agent
After=network-online.target
Wants=network-online.target
[Service]
User=consul
Group=consul
ExecStart=/usr/bin/consul agent -config-dir=/etc/consul.d
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now consul
sudo systemctl status consulRegister application services and health checks
Define each backend as a Consul service with an HTTP health check. Consul only advertises healthy instances to HAProxy.
{
"service": {
"name": "web-app",
"port": 8080,
"address": "203.0.113.21",
"tags": ["http"],
"check": {
"http": "http://203.0.113.21:8080/healthz",
"interval": "5s",
"timeout": "2s",
"deregister_critical_service_after": "1m"
}
}
}sudo chown consul:consul /etc/consul.d/web-app.json
sudo consul reloadRepeat this file on each backend host with its own address, or push the definition remotely with the Consul HTTP API in a later step. Verify registration:
consul catalog services
consul health checks web-appWrite the consul-template source template
consul-template watches the Consul catalog and renders a new HAProxy config whenever the set of healthy instances changes.
global
log /dev/log local0
maxconn 4096
defaults
log global
mode http
timeout connect 5s
timeout client 30s
timeout server 30s
frontend web_front
bind *:80
default_backend web_back
backend web_back
balance roundrobin
option httpchk GET /healthz
{{range service "web-app"}} server {{.Node}}-{{.Port}} {{.Address}}:{{.Port}} check
{{end}}Configure consul-template to write HAProxy config and reload
The wait and dedup blocks smooth out rapid catalog churn so HAProxy is not reloaded on every single check flap.
consul {
address = "127.0.0.1:8500"
}
template {
source = "/etc/consul-template/haproxy.ctmpl"
destination = "/etc/haproxy/haproxy.cfg"
command = "sudo systemctl reload haproxy"
command_timeout = "30s"
}
wait {
min = "3s"
max = "15s"
}[Unit]
Description=Consul Template for HAProxy backends
After=consul.service
Requires=consul.service
[Service]
User=root
ExecStart=/usr/bin/consul-template -config=/etc/consul-template/config.hcl
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsystemctl reload haproxy. Grant this with a scoped sudoers rule instead of running the whole service as root without limits.consul-template ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxysudo systemctl daemon-reload
sudo systemctl enable --now consul-template
sudo systemctl status consul-templateValidate and enable HAProxy
Always validate the generated configuration before trusting automatic reloads in production.
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl enable --now haproxy
sudo systemctl status haproxyFor graceful reloads with zero dropped connections, review the reload strategy covered in HAProxy performance tuning with connection pooling, since consul-template simply calls systemctl reload under the hood.
Set up Consul DNS forwarding
Consul's built-in DNS server lets applications resolve service names like web-app.service.consul directly, which is useful for services that talk to each other without going through HAProxy.
[Resolve]
DNS=127.0.0.1:8600
Domains=~consulsudo mkdir -p /etc/systemd/resolved.conf.d
sudo systemctl restart systemd-resolved
dig @127.0.0.1 -p 8600 web-app.service.consulUse the HTTP API for programmatic registration from deployment pipelines instead of hand-editing JSON files on every host:
curl --request PUT --data '{
"Name": "web-app",
"Port": 8080,
"Address": "203.0.113.22",
"Check": {
"HTTP": "http://203.0.113.22:8080/healthz",
"Interval": "5s"
}
}' http://127.0.0.1:8500/v1/agent/service/registerTest failover and dynamic registration
Simulate a backend failure and confirm HAProxy removes it without manual intervention.
curl -s http://127.0.0.1:8500/v1/health/service/web-app?passing | jq '.[].Service.Address'
sudo systemctl stop web-app
sleep 15
cat /etc/haproxy/haproxy.cfg | grep server
sudo systemctl start web-appRegister a brand new instance on a fresh host and confirm it appears in the backend within a few seconds, without touching HAProxy configuration by hand.
Secure the cluster with ACLs
Default Consul installs allow any client to read and write the catalog. Enable ACLs with a default-deny policy and issue scoped tokens.
acl {
enabled = true
default_policy = "deny"
enable_token_persistence = true
}sudo systemctl restart consul
consul acl bootstrapSave the bootstrap token output securely, then create a read-only token for consul-template.
service_prefix "" {
policy = "read"
}
node_prefix "" {
policy = "read"
}consul acl policy create -name "template-read" -rules @/etc/consul.d/template-policy.hcl
consul acl token create -description "consul-template read token" -policy-name "template-read"Add the resulting token to the consul-template configuration:
consul {
address = "127.0.0.1:8500"
token = "YOUR-GENERATED-TOKEN-HERE"
}sudo chmod 600 /etc/consul-template/config.hcl
sudo systemctl restart consul-templateFor a deeper walkthrough of policy design, see advanced Consul ACL policies for production security hardening.
Enable TLS for agent communication
Encrypt Consul's RPC and gossip traffic so catalog data and tokens are not exposed on the network.
consul tls ca create
consul tls cert create -server -dc dc1tls {
defaults {
ca_file = "/etc/consul.d/consul-agent-ca.pem"
cert_file = "/etc/consul.d/dc1-server-consul-0.pem"
key_file = "/etc/consul.d/dc1-server-consul-0-key.pem"
verify_incoming = true
verify_outgoing = true
}
}sudo chown consul:consul /etc/consul.d/*.pem
sudo chmod 600 /etc/consul.d/*-key.pem
sudo chmod 644 /etc/consul.d/*.pem
sudo systemctl restart consulFor HAProxy's public-facing TLS, follow HAProxy SSL termination with Let's Encrypt certificates to terminate client traffic securely before it reaches the dynamic backend pool.
Verify your setup
sudo systemctl status consul consul-template haproxy
consul members
consul catalog services
curl -I http://127.0.0.1/
sudo haproxy -c -f /etc/haproxy/haproxy.cfgCommon issues
| Symptom | Cause | Fix |
|---|---|---|
| HAProxy config never updates | consul-template cannot reach Consul or lacks a valid ACL token | Check journalctl -u consul-template -f and verify the token has service read permissions |
| HAProxy reload fails silently | consul-template user lacks sudo rights for systemctl | Confirm the sudoers rule in /etc/sudoers.d/consul-template matches the exact command |
| Backend flaps constantly | Health check interval too aggressive or app slow to respond | Increase interval and timeout values in the service check definition |
| Consul agent won't join cluster | Firewall blocking gossip ports 8301/8302 | Open the specific TCP/UDP ports with your firewall tool instead of disabling it |
| consul-template permission denied writing config | Destination file owned by a different user | chown root:root /etc/haproxy/haproxy.cfg and rerun as the correct service user |
| ACL bootstrap fails with "already bootstrapped" | Bootstrap already ran on this cluster | Recover using an existing management token instead of re-bootstrapping |
Next steps
- Set up HAProxy high availability with keepalived clustering
- Configure Consul Connect service mesh monitoring with distributed tracing
- Monitor HAProxy and Consul with Prometheus and Grafana dashboards
- Implement Consul backup and disaster recovery with automated snapshots
- Configure HAProxy advanced routing with ACLs and maps for intelligent traffic management
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}[OK]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
usage() {
echo "Usage: $0 -b <bind_addr> [-d <datacenter>] [-c <client_addr>]"
echo " -b Consul bind address (required, e.g. 203.0.113.10)"
echo " -d Consul datacenter name (default: dc1)"
echo " -c Consul client address (default: 127.0.0.1)"
exit 1
}
BIND_ADDR=""
DATACENTER="dc1"
CLIENT_ADDR="127.0.0.1"
while getopts "b:d:c:h" opt; do
case "$opt" in
b) BIND_ADDR="$OPTARG" ;;
d) DATACENTER="$OPTARG" ;;
c) CLIENT_ADDR="$OPTARG" ;;
h|*) usage ;;
esac
done
[ -z "$BIND_ADDR" ] && { log_error "Bind address is required."; usage; }
# ---------------------------------------------------------------------------
# Prerequisites
# ---------------------------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
log_error "This script must be run as root or with sudo."
exit 1
fi
TOTAL_STEPS=9
STEP=0
next_step() { STEP=$((STEP+1)); echo -e "\n[$STEP/$TOTAL_STEPS] $1"; }
# Rollback on failure
cleanup_on_error() {
log_error "Installation failed at step $STEP. Rolling back partial changes..."
systemctl stop consul consul-template haproxy 2>/dev/null || true
exit 1
}
trap cleanup_on_error ERR
# ---------------------------------------------------------------------------
# Step 1: Detect distro
# ---------------------------------------------------------------------------
next_step "Detecting distribution..."
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian)
PKG_MGR="apt"; PKG_INSTALL="apt install -y"
FIREWALL_CMD="ufw"
;;
almalinux|rocky|centos|rhel|ol|fedora)
PKG_MGR="dnf"; PKG_INSTALL="dnf install -y"
FIREWALL_CMD="firewalld"
;;
amzn)
PKG_MGR="yum"; PKG_INSTALL="yum install -y"
FIREWALL_CMD="firewalld"
;;
*)
log_error "Unsupported distro: $ID"
exit 1
;;
esac
else
log_error "/etc/os-release not found; cannot detect distro."
exit 1
fi
log_info "Detected distro: $ID ($PKG_MGR)"
# ---------------------------------------------------------------------------
# Step 2: Install HAProxy and base tools
# ---------------------------------------------------------------------------
next_step "Installing HAProxy and prerequisites..."
if [ "$PKG_MGR" = "apt" ]; then
apt update
$PKG_INSTALL haproxy curl gnupg lsb-release
else
$PKG_INSTALL haproxy curl dnf-plugins-core || $PKG_INSTALL haproxy curl
fi
log_info "HAProxy installed."
# ---------------------------------------------------------------------------
# Step 3: Add HashiCorp repo and install Consul + consul-template
# ---------------------------------------------------------------------------
next_step "Adding HashiCorp repository and installing Consul + consul-template..."
if [ "$PKG_MGR" = "apt" ]; then
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
else
dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
$PKG_INSTALL consul consul-template
fi
log_info "Consul and consul-template installed."
# ---------------------------------------------------------------------------
# Step 4: Create dedicated consul user and data directory
# ---------------------------------------------------------------------------
next_step "Creating Consul system user and data directory..."
if ! id consul >/dev/null 2>&1; then
useradd --system --home /var/lib/consul --shell /usr/sbin/nologin consul
fi
mkdir -p /var/lib/consul /etc/consul.d
chown -R consul:consul /var/lib/consul
chmod 750 /var/lib/consul
log_info "Consul user and directories ready."
# ---------------------------------------------------------------------------
# Step 5: Configure Consul agent
# ---------------------------------------------------------------------------
next_step "Writing Consul agent configuration..."
cat > /etc/consul.d/consul.hcl <<EOF
datacenter = "${DATACENTER}"
data_dir = "/var/lib/consul"
server = true
bootstrap_expect = 1
bind_addr = "${BIND_ADDR}"
client_addr = "${CLIENT_ADDR}"
ui_config {
enabled = true
}
connect {
enabled = true
}
EOF
chown -R consul:consul /etc/consul.d
chmod 640 /etc/consul.d/consul.hcl
log_info "Consul configuration written."
# ---------------------------------------------------------------------------
# Step 6: Create systemd unit for Consul
# ---------------------------------------------------------------------------
next_step "Creating systemd service for Consul..."
cat > /etc/systemd/system/consul.service <<'EOF'
[Unit]
Description=Consul service discovery agent
After=network-online.target
Wants=network-online.target
[Service]
User=consul
Group=consul
ExecStart=/usr/bin/consul agent -config-dir=/etc/consul.d
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
chmod 644 /etc/systemd/system/consul.service
systemctl daemon-reload
systemctl enable --now consul
sleep 2
log_info "Consul service started."
# ---------------------------------------------------------------------------
# Step 7: Configure consul-template with base HAProxy template
# ---------------------------------------------------------------------------
next_step "Configuring consul-template and HAProxy template source..."
# Detect distro-specific HAProxy config location
if [ -d /etc/haproxy ]; then
HAPROXY_CFG="/etc/haproxy/haproxy.cfg"
else
log_error "HAProxy config directory not found."
exit 1
fi
mkdir -p /etc/consul-template.d /etc/consul-template/templates
chown -R consul:consul /etc/consul-template.d /etc/consul-template
cat > /etc/consul-template/templates/haproxy.ctmpl <<'EOF'
global
log /dev/log local0
maxconn 4096
defaults
log global
mode http
timeout connect 5s
timeout client 30s
timeout server 30s
frontend web_front
bind *:80
default_backend web_back
backend web_back
balance roundrobin
{{ range service "web-app" }}
server {{ .Node }} {{ .Address }}:{{ .Port }} check
{{ else }}
# no healthy web-app instances registered
{{ end }}
EOF
chmod 644 /etc/consul-template/templates/haproxy.ctmpl
cat > /etc/consul-template.d/config.hcl <<EOF
consul {
address = "${CLIENT_ADDR}:8500"
}
template {
source = "/etc/consul-template/templates/haproxy.ctmpl"
destination = "${HAPROXY_CFG}"
command = "systemctl reload haproxy"
}
EOF
chmod 640 /etc/consul-template.d/config.hcl
chown consul:consul /etc/consul-template.d/config.hcl
log_info "consul-template configured to render ${HAPROXY_CFG}."
# ---------------------------------------------------------------------------
# Step 8: Create systemd unit for consul-template and enable HAProxy
# ---------------------------------------------------------------------------
next_step "Creating systemd service for consul-template..."
cat > /etc/systemd/system/consul-template.service <<'EOF'
[Unit]
Description=Consul Template daemon for HAProxy config rendering
After=network-online.target consul.service
Wants=network-online.target
[Service]
User=root
Group=root
ExecStart=/usr/bin/consul-template -config=/etc/consul-template.d/config.hcl
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
chmod 644 /etc/systemd/system/consul-template.service
systemctl daemon-reload
systemctl enable --now haproxy
systemctl enable --now consul-template
log_info "consul-template and HAProxy services enabled."
# ---------------------------------------------------------------------------
# Step 9: Configure firewall and verify
# ---------------------------------------------------------------------------
next_step "Configuring firewall and verifying installation..."
if [ "$FIREWALL_CMD" = "ufw" ]; then
if command -v ufw >/dev/null 2>&1; then
ufw allow 80/tcp >/dev/null 2>&1 || true
ufw allow 8500/tcp >/dev/null 2>&1 || true
ufw allow 8300:8302/tcp >/dev/null 2>&1 || true
log_info "UFW rules applied for HTTP, Consul UI/API and cluster ports."
else
log_warn "ufw not found; skipping firewall configuration."
fi
elif [ "$FIREWALL_CMD" = "firewalld" ]; then
if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
firewall-cmd --permanent --add-service=http >/dev/null 2>&1 || true
firewall-cmd --permanent --add-port=8500/tcp >/dev/null 2>&1 || true
firewall-cmd --permanent --add-
Review the script before running. Execute with: bash install.sh