HAProxy and Consul: Dynamic Service Discovery for Load Balancing
In dynamic environments where services scale up and down automatically, static HAProxy configurations become a management burden. Consul integration enables HAProxy to automatically discover and route traffic to healthy service instances without manual configuration updates.
Why Consul + HAProxy?
The combination provides:
- Automatic service registration - Services announce themselves to Consul
- Health-aware routing - Only healthy instances receive traffic
- Dynamic scaling - New instances are automatically added to load balancing
- Multi-datacenter - Route traffic across datacenters with Consul federation
- No downtime updates - Configuration changes without service interruption
Architecture Overview
┌─────────────────────────────────┐
│ Consul Cluster │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │Server1│ │Server2│ │Server3│ │
│ └───────┘ └───────┘ └───────┘ │
└────────────────┬────────────────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Service A │ │ Service B │ │ Service C │
│ + Consul Agent│ │ + Consul Agent│ │ + Consul Agent│
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
└──────────────────────┼──────────────────────┘
│
▼
┌─────────────────────┐
│ HAProxy │
│ + consul-template │
└─────────────────────┘
│
▼
Clients/Users
Setting Up Consul
Consul Server Installation
# Download Consul
CONSUL_VERSION="1.17.0"
wget https://releases.hashicorp.com/consul/${CONSUL_VERSION}/consul_${CONSUL_VERSION}_linux_amd64.zip
unzip consul_${CONSUL_VERSION}_linux_amd64.zip
sudo mv consul /usr/local/bin/
# Create directories
sudo mkdir -p /etc/consul.d /var/lib/consul
sudo useradd --system --home /var/lib/consul --shell /bin/false consul
sudo chown -R consul:consul /etc/consul.d /var/lib/consul
Consul Server Configuration
Create /etc/consul.d/consul.hcl:
datacenter = "dc1"
node_name = "consul-server-1"
data_dir = "/var/lib/consul"
log_level = "INFO"
server = true
bootstrap_expect = 3
bind_addr = "{{ GetPrivateInterfaces | include \"network\" \"10.0.0.0/8\" | attr \"address\" }}"
client_addr = "0.0.0.0"
ui_config {
enabled = true
}
connect {
enabled = true
}
addresses {
http = "0.0.0.0"
grpc = "0.0.0.0"
}
ports {
http = 8500
grpc = 8502
grpc_tls = 8503
}
retry_join = [
"consul-server-1.example.com",
"consul-server-2.example.com",
"consul-server-3.example.com"
]
acl {
enabled = true
default_policy = "deny"
enable_token_persistence = true
}
Consul Agent Configuration (for service nodes)
Create /etc/consul.d/consul.hcl on service nodes:
datacenter = "dc1"
node_name = "service-node-1"
data_dir = "/var/lib/consul"
log_level = "INFO"
server = false
bind_addr = "{{ GetPrivateInterfaces | include \"network\" \"10.0.0.0/8\" | attr \"address\" }}"
client_addr = "127.0.0.1"
retry_join = [
"consul-server-1.example.com",
"consul-server-2.example.com",
"consul-server-3.example.com"
]
Systemd Service
Create /etc/systemd/system/consul.service:
[Unit]
Description=Consul Service Discovery Agent
Documentation=https://www.consul.io/
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
User=consul
Group=consul
ExecStart=/usr/local/bin/consul agent -config-dir=/etc/consul.d/
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
KillSignal=SIGTERM
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
Registering Services with Consul
Service Registration File
Create /etc/consul.d/web-service.hcl:
service {
name = "web"
port = 8080
tags = ["http", "production"]
meta {
version = "1.0.0"
environment = "production"
}
check {
name = "HTTP Health Check"
http = "http://localhost:8080/health"
interval = "10s"
timeout = "5s"
success_before_passing = 2
failures_before_critical = 3
}
check {
name = "TCP Port Check"
tcp = "localhost:8080"
interval = "5s"
timeout = "2s"
}
}
API Service Registration
service {
name = "api"
port = 3000
tags = ["http", "api", "v2"]
weights {
passing = 100
warning = 50
}
check {
name = "API Health"
http = "http://localhost:3000/api/health"
interval = "10s"
timeout = "3s"
header {
Content-Type = ["application/json"]
}
}
}
Dynamic Registration via HTTP API
# Register a service dynamically
curl -X PUT http://localhost:8500/v1/agent/service/register \
-H "Content-Type: application/json" \
-d '{
"ID": "web-1",
"Name": "web",
"Port": 8080,
"Tags": ["http", "production"],
"Check": {
"HTTP": "http://localhost:8080/health",
"Interval": "10s"
}
}'
# Deregister a service
curl -X PUT http://localhost:8500/v1/agent/service/deregister/web-1
Consul Template for HAProxy
Installing Consul Template
CT_VERSION="0.35.0"
wget https://releases.hashicorp.com/consul-template/${CT_VERSION}/consul-template_${CT_VERSION}_linux_amd64.zip
unzip consul-template_${CT_VERSION}_linux_amd64.zip
sudo mv consul-template /usr/local/bin/
Consul Template Configuration
Create /etc/consul-template/config.hcl:
consul {
address = "127.0.0.1:8500"
retry {
enabled = true
attempts = 5
backoff = "250ms"
}
}
template {
source = "/etc/consul-template/haproxy.ctmpl"
destination = "/etc/haproxy/haproxy.cfg"
perms = 0644
command = "systemctl reload haproxy"
command_timeout = "60s"
backup = true
wait {
min = "5s"
max = "10s"
}
}
log_level = "info"
HAProxy Template
Create /etc/consul-template/haproxy.ctmpl:
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
user haproxy
group haproxy
daemon
maxconn 4096
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
option http-server-close
timeout connect 5000
timeout client 50000
timeout server 50000
errorfile 400 /etc/haproxy/errors/400.http
errorfile 403 /etc/haproxy/errors/403.http
errorfile 408 /etc/haproxy/errors/408.http
errorfile 500 /etc/haproxy/errors/500.http
errorfile 502 /etc/haproxy/errors/502.http
errorfile 503 /etc/haproxy/errors/503.http
errorfile 504 /etc/haproxy/errors/504.http
# Stats endpoint
frontend stats
bind *:8404
mode http
stats enable
stats uri /stats
stats refresh 10s
stats admin if LOCALHOST
# Main HTTP Frontend
frontend http_front
bind *:80
bind *:443 ssl crt /etc/haproxy/certs/
# ACL definitions based on hostname
acl host_web hdr(host) -i www.example.com example.com
acl host_api hdr(host) -i api.example.com
acl host_admin hdr(host) -i admin.example.com
# Backend selection
use_backend web_servers if host_web
use_backend api_servers if host_api
use_backend admin_servers if host_admin
default_backend web_servers
# Web Servers Backend - dynamically populated from Consul
backend web_servers
balance roundrobin
option httpchk GET /health
http-check expect status 200
{{- range service "web" }}
server {{ .Node }}-{{ .Port }} {{ .Address }}:{{ .Port }} check weight {{ if .Weights.Passing }}{{ .Weights.Passing }}{{ else }}100{{ end }}{{ if not .Checks.AggregatedStatus | eq "passing" }} disabled{{ end }}
{{- end }}
# API Servers Backend
backend api_servers
balance leastconn
option httpchk GET /api/health
http-check expect status 200
{{- range service "api" }}
server {{ .Node }}-{{ .Port }} {{ .Address }}:{{ .Port }} check weight {{ if .Weights.Passing }}{{ .Weights.Passing }}{{ else }}100{{ end }}
{{- end }}
# Admin Servers Backend
backend admin_servers
balance roundrobin
option httpchk GET /health
{{- range service "admin" }}
server {{ .Node }}-{{ .Port }} {{ .Address }}:{{ .Port }} check
{{- end }}
Advanced Template with Tags and Filtering
# Production web servers only (filtered by tag)
backend web_production
balance roundrobin
option httpchk GET /health
{{- range service "web|production" }}
server {{ .ID }} {{ .Address }}:{{ .Port }} check
{{- end }}
# Staging web servers
backend web_staging
balance roundrobin
option httpchk GET /health
{{- range service "web|staging" }}
server {{ .ID }} {{ .Address }}:{{ .Port }} check
{{- end }}
# Services in specific datacenter
backend api_dc1
balance leastconn
{{- range service "api" "dc1" }}
server {{ .Node }}-{{ .ID }} {{ .Address }}:{{ .Port }} check
{{- end }}
# Multi-datacenter with failover
backend api_multi_dc
balance roundrobin
{{- range service "api" "dc1" }}
server {{ .Node }}-{{ .ID }} {{ .Address }}:{{ .Port }} check
{{- end }}
{{- range service "api" "dc2" }}
server {{ .Node }}-{{ .ID }} {{ .Address }}:{{ .Port }} check backup
{{- end }}
Template with Service Metadata
backend web_servers
balance roundrobin
{{- range service "web" }}
{{- if .ServiceMeta.version | eq "2.0" }}
# Version 2.0 servers get more weight
server {{ .ID }} {{ .Address }}:{{ .Port }} check weight 150
{{- else }}
server {{ .ID }} {{ .Address }}:{{ .Port }} check weight 100
{{- end }}
{{- end }}
Consul Template Systemd Service
Create /etc/systemd/system/consul-template.service:
[Unit]
Description=Consul Template
After=network-online.target consul.service
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
ExecStart=/usr/local/bin/consul-template -config=/etc/consul-template/config.hcl
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
KillSignal=SIGTERM
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Health Checks and Service Discovery
Consul Health Check Types
service {
name = "web"
port = 8080
# HTTP check
check {
name = "HTTP Health"
http = "http://localhost:8080/health"
method = "GET"
interval = "10s"
timeout = "5s"
}
# TCP check
check {
name = "TCP Port"
tcp = "localhost:8080"
interval = "5s"
timeout = "2s"
}
# Script check
check {
name = "Script Check"
args = ["/usr/local/bin/health-check.sh"]
interval = "30s"
timeout = "10s"
}
# gRPC check
check {
name = "gRPC Health"
grpc = "localhost:9090"
grpc_use_tls = true
interval = "10s"
}
# TTL check (service must update)
check {
name = "TTL Check"
ttl = "30s"
status = "passing"
}
}
Updating TTL Check
# Pass TTL check
curl -X PUT http://localhost:8500/v1/agent/check/pass/service:web-1
# Fail TTL check
curl -X PUT http://localhost:8500/v1/agent/check/fail/service:web-1
# Warn TTL check
curl -X PUT http://localhost:8500/v1/agent/check/warn/service:web-1
Advanced HAProxy + Consul Patterns
Blue-Green Deployments
# Template for blue-green deployment
frontend http_front
bind *:80
# Read active environment from Consul KV
{{- $active := key "app/active-environment" }}
default_backend web_{{ $active }}
backend web_blue
balance roundrobin
{{- range service "web|blue" }}
server {{ .ID }} {{ .Address }}:{{ .Port }} check
{{- end }}
backend web_green
balance roundrobin
{{- range service "web|green" }}
server {{ .ID }} {{ .Address }}:{{ .Port }} check
{{- end }}
Switch environments:
# Switch to green
consul kv put app/active-environment green
# Switch to blue
consul kv put app/active-environment blue
Canary Deployments with Weights
backend web_servers
balance roundrobin
{{- $canary_weight := keyOrDefault "app/canary-weight" "10" }}
# Stable servers
{{- range service "web|stable" }}
server {{ .ID }} {{ .Address }}:{{ .Port }} check weight {{ subtract 100 (parseInt $canary_weight) }}
{{- end }}
# Canary servers
{{- range service "web|canary" }}
server {{ .ID }} {{ .Address }}:{{ .Port }} check weight {{ $canary_weight }}
{{- end }}
Rate Limiting per Service
frontend http_front
bind *:80
{{- $rate_limit := keyOrDefault "app/rate-limit" "100" }}
stick-table type ip size 100k expire 30s store http_req_rate(10s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt {{ $rate_limit }} }
default_backend web_servers
Maintenance Mode
frontend http_front
bind *:80
{{- $maintenance := keyOrDefault "app/maintenance" "false" }}
{{- if eq $maintenance "true" }}
# Maintenance mode - serve static page
http-request deny deny_status 503
{{- else }}
default_backend web_servers
{{- end }}
Monitoring and Observability
Consul Service Health Queries
# List all healthy web services
curl http://localhost:8500/v1/health/service/web?passing=true | jq .
# Get service catalog
curl http://localhost:8500/v1/catalog/services | jq .
# Watch for changes
watch -n 2 'curl -s http://localhost:8500/v1/health/service/web | jq length'
HAProxy Stats Integration
frontend stats
bind *:8404
mode http
stats enable
stats uri /stats
stats refresh 10s
# Prometheus metrics endpoint
http-request use-service prometheus-exporter if { path /metrics }
Troubleshooting
Common Issues
- Services not appearing in HAProxy
- Check Consul agent logs:
journalctl -u consul - Verify service health:
consul catalog services - Check consul-template logs:
journalctl -u consul-template
- Check Consul agent logs:
- HAProxy not reloading
- Verify template syntax:
consul-template -template "haproxy.ctmpl:test.cfg" -once -dry - Check HAProxy config:
haproxy -c -f /etc/haproxy/haproxy.cfg
- Verify template syntax:
- Services marked unhealthy
- Check health check status:
consul catalog service web - Review check definitions in service registration
- Check health check status:
Debug Commands
# Test template rendering
consul-template -template "/etc/consul-template/haproxy.ctmpl:/tmp/haproxy-test.cfg" -once -dry
# Verify Consul connectivity
consul members
# List all services
consul catalog services
# Get service details
consul catalog service web
# Check service health
consul health checks web
Security Considerations
Consul ACL Tokens
# consul-template config with ACL
consul {
address = "127.0.0.1:8500"
token = "your-acl-token"
}
TLS Configuration
consul {
address = "consul.example.com:8501"
ssl {
enabled = true
verify = true
ca_cert = "/etc/consul-template/ca.pem"
cert = "/etc/consul-template/client.pem"
key = "/etc/consul-template/client-key.pem"
}
}
Next Steps
With Consul service discovery integrated, consider:
- Prometheus Monitoring - Add metrics collection for HAProxy and Consul
- Consul Connect - Implement service mesh with automatic mTLS
- Multi-datacenter - Extend to multiple Consul datacenters for geo-routing
Consul and HAProxy together create a powerful, self-healing infrastructure where services automatically join and leave the load balancer pool based on their health status.