Introduction #
A little while ago I published a guide on building Traefik High Availability with Consul KV & Keepalived. It works beautifully — but it also asks you to run a three-node Consul cluster just to store a handful of routing rules.
If your only goal is “my reverse proxy should survive a node dying”, that is a lot of machinery for the job. Consul adds a quorum to babysit, a Go agent on every host, and a whole second mental model (KV keys) on top of Traefik’s YAML.
So I simplified it. In this article I rebuild the exact same HA cluster — same three hosts, same Keepalived VIP — but the single source of truth is now a private GitHub repository instead of Consul KV. Every node clones the repo, Traefik reads its config from local files, and a tiny push script keeps all nodes in sync.
Why Ditch Consul? #
Consul is an excellent tool, but it is aimed at service discovery at scale. For a homelab or a small production cluster, a Git repository already gives you everything Consul KV was doing for this use case: version control, one canonical copy, and an easy way to review changes.
| Concern | Consul KV approach | GitHub Sync approach |
|---|---|---|
| Dynamic config storage | Consul KV cluster (3 nodes for quorum) | Files in a private Git repo |
| Extra agents per host | Consul agent on every node | None — just Git |
| Change workflow | consul kv put ... commands |
Edit a file, commit, push |
| History / rollback | Consul snapshots | Native Git history (git log, git revert) |
| Failure mode | Lose quorum → config stops updating | Last pulled commit stays active |
| Shared storage (NFS) | Not required | Not required |
The trade-off is honest: with Consul, config changes propagate instantly to every node. With Git, a node only sees a change after it pulls. For most people that one extra step is a fair price for removing an entire distributed database from the stack.
Architecture Overview #
Three nodes, one Virtual IP, one Git repository:
┌──────────────────────────────┐
│ GitHub (private repo) │
│ traefik-ha.git │
│ • traefik.yaml │
│ • dynamic-config/*.yaml │
│ • compose.yaml │
└───────────────┬──────────────┘
clone / pull │
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Host 22 │ │ Host 11 │ │ Host 200 │
│ MASTER │ │ BACKUP │ │ BACKUP │
│ prio 150 │ │ prio 130 │ │ prio 110 │
│ 🏆 VIP │ │ │ │ (wlan0) │
├───────────┤ ├───────────┤ ├───────────┤
│ traefik-ha│ │ traefik-ha│ │ traefik-ha│
│ acme.json │ │ acme.json │ │ acme.json │
└───────────┘ └───────────┘ └───────────┘
└───────────── VRRP (224.0.0.18) ─────────────┘
DNS: *.noorkhafidzin.com → 10.1.1.111 (VIP)| Node | IP | Role | Keepalived priority | Interface |
|---|---|---|---|---|
| Host 22 | 10.1.1.22 | MASTER | 150 | eth0 |
| Host 11 | 10.1.1.11 | BACKUP | 130 | eth0 |
| Host 200 | 10.1.1.200 | BACKUP | 110 | wlan0 |
- Traefik runs as a Docker container (
traefik-ha) on all three nodes, reading configuration from the File Provider (/etc/traefik/dynamic-config). - GitHub holds the single source of truth for
traefik.yaml,dynamic-config/, andcompose.yaml. - Keepalived floats the Virtual IP
10.1.1.111so clients always hit a healthy node. - Let’s Encrypt certificates are stored in
acme.jsonon each node — norsync, no shared cert directory.
Prerequisites #
- Three Linux hosts (Debian/Ubuntu/Armbian) with Docker and the Compose plugin installed.
gitinstalled on all hosts and root/sudo access.- An empty private GitHub repository (e.g.
traefik-ha). - VRRP multicast (destination
224.0.0.18) allowed between hosts. - A Cloudflare account with a zone for your domain and an API token that has Zone → DNS → Edit permission.
Step 1 — Create a Dedicated Deploy SSH Key #
Git needs to authenticate without a password. Create one key on the primary host and copy it to the others.
# On Host 22 (10.1.1.22)
ssh-keygen -t ed25519 -f ~/.ssh/homelab -N "" -C "homelab"
cat ~/.ssh/homelab.pubAdd the public key in GitHub → Settings → SSH and GPG keys (or as a deploy key on the repository). Then copy the private key to the two backup nodes:
scp ~/.ssh/homelab [email protected]:~/.ssh/homelab
scp ~/.ssh/homelab [email protected]:~/.ssh/homelab
ssh [email protected] 'chmod 600 ~/.ssh/homelab'
ssh [email protected] 'chmod 600 ~/.ssh/homelab'Step 2 — Configure the SSH Alias and Clone #
Create ~/.ssh/config on every host so Git always uses the right key:
Host github-homelab
HostName github.com
User git
IdentityFile ~/.ssh/homelabTest it, then clone the repository to the same path on all three nodes:
ssh -T git@github-homelab
# → Hi zarvelionzynji! You've successfully authenticated...
# On each host (22, 11, 200)
git clone git@github-homelab:zarvelionzynji/traefik-ha.git /home/el/traefik-haUsing an identical path (/home/el/traefik-ha) on every node keeps the scripts and Compose files copy-pasteable.
Step 3 — Static Configuration (traefik.yaml)
#
Unlike the Consul setup, there is no Consul provider here. All routing lives in files inside the repo, and TLS is handled by Traefik’s built-in ACME client.
/home/el/traefik-ha/traefik/config/traefik.yaml:
global:
checkNewVersion: true
sendAnonymousUsage: false
log:
level: INFO
api:
dashboard: true
insecure: true
entryPoints:
web:
address: :80
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: :443
forwardedHeaders:
insecure: true
trustedIPs:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
providers:
providersThrottleDuration: 2s
file:
directory: /etc/traefik/dynamic-config
watch: true
certificatesResolvers:
cloudflare:
acme:
email: [email protected]
storage: /var/traefik/certs/acme.json
dnsChallenge:
provider: cloudflare
propagation:
disableChecks: falseKey points:
providers.file.watch: true— Traefik hot-reloads when the files change without a restart (this is what yourgit pullhooks into).certificatesResolvers.cloudflare— Traefik now issues and renews certificates itself via the Cloudflare DNS-01 challenge. Noacme.sh, norsync.forwardedHeaders.trustedIPs— makes sure the real client IP is passed to your backends.
providers.docker — just remember your config then lives in two places.
Step 4 — Dynamic Configuration (Routers & Services) #
Every route is a plain YAML file in traefik/config/dynamic-config/. Here is a single file, routes.yaml, that routes a domain to a backend:
http:
routers:
app-rtr:
rule: "Host(`app.noorkhafidzin.com`)"
entryPoints:
- websecure
service: app-svc
tls:
certResolver: cloudflare
services:
app-svc:
loadBalancer:
servers:
- url: "http://10.1.1.50:8080"To add another app, add another router/service pair, commit, and push. Because tls.certResolver is cloudflare, Traefik requests the certificate automatically the first time the domain is hit — you do not need to set tls: true or manage cert files by hand.
Step 5 — Docker Compose #
/home/el/traefik-ha/compose.yaml:
services:
traefik:
image: traefik:v3.6.8
container_name: traefik-ha
restart: unless-stopped
network_mode: host
env_file:
- .env
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/config/traefik.yaml:/etc/traefik/traefik.yaml:ro
- ./traefik/config/dynamic-config:/etc/traefik/dynamic-config:ro
- ./traefik/certs:/var/traefik/certsnetwork_mode: host— Traefik binds ports 80/443 directly and its API is reachable atlocalhost:8080, which is what Keepalived will health-check../traefik/certsis a bind mount that persistsacme.jsonbetween restarts.- The Docker socket mount is optional if you are not using the Docker provider.
Step 6 — Secrets and .gitignore
#
The Cloudflare API token lives in a .env file:
# /home/el/traefik-ha/.env
CF_DNS_API_TOKEN=your_cloudflare_token_here.env and acme.json out of Git. The .gitignore below protects you even though the push script runs git add ..
/home/el/traefik-ha/.gitignore:
.env
traefik/certs/acme.jsonBecause .env is not committed, create it once per host (copy it over SSH). Everything else is distributed automatically by Git.
Step 7 — SSL Certificates #
There is nothing to install. The first time a request arrives for a domain with certResolver: cloudflare, Traefik:
- Asks Let’s Encrypt for a certificate using the DNS-01 challenge.
- Uses your
CF_DNS_API_TOKENto create the_acme-challengeTXT record in Cloudflare. - Stores the resulting certificate in
/var/traefik/certs/acme.json.
Each node keeps its own acme.json, so there is no certificate sync step at all. Just make sure the file is locked down:
chmod 600 /home/el/traefik-ha/traefik/certs/acme.jsonStep 8 — Keepalived VIP Failover #
Install Keepalived on all three hosts:
sudo apt-get update && sudo apt-get install -y keepalived/etc/keepalived/keepalived.conf on Host 22 (MASTER):
global_defs { router_id TRAEFIK_HA }
# Only hold the VIP while Traefik's API actually responds
vrrp_script check_traefik {
script "/bin/bash -c 'curl -sf http://localhost:8080/api/version > /dev/null 2>&1'"
interval 3
timeout 2
rise 2
fall 3
}
vrrp_instance VI_TRAEFIK {
state MASTER
interface eth0
virtual_router_id 111
priority 150
advert_int 1
authentication { auth_type PASS; auth_pass traefikHA }
virtual_ipaddress { 10.1.1.111/24 dev eth0 }
track_script { check_traefik }
}On Host 11 use state BACKUP, priority 130, interface eth0.
On Host 200 use state BACKUP, priority 110, interface wlan0, and put the VIP on wlan0 too.
Then restart Keepalived on each host and confirm the VIP:
sudo systemctl restart keepalived
ip addr show eth0 | grep 10.1.1.111Finally, point your wildcard DNS record (*.noorkhafidzin.com) at the VIP 10.1.1.111.
Step 9 — The Push Script #
Editing config by hand on three machines is how drift starts. Instead, put a small script in the repo that commits, pushes, and tells the backup nodes to pull.
/home/el/traefik-ha/push:
#!/bin/bash
REPO_DIR="/home/el/traefik-ha"
HOSTS=("10.1.1.11" "10.1.1.200")
SSH_KEY="/home/el/.ssh/homelab"
echo "🚀 Starting sync process..."
cd "$REPO_DIR" || { echo "❌ Repo not found"; exit 1; }
git add .
git commit -m "Auto-sync: $(date '+%Y-%m-%d %H:%M:%S')"
if git push origin main; then
echo "✅ Push successful!"
else
echo "❌ Push failed"; exit 1
fi
for HOST in "${HOSTS[@]}"; do
echo "🔄 Pulling on $HOST..."
ssh -i "$SSH_KEY" "el@$HOST" "cd $REPO_DIR && git pull origin main && docker compose restart traefik"
echo "✅ $HOST updated"
done
echo "🎉 All hosts synchronized!"Make it executable and use it after any edit:
chmod +x /home/el/traefik-ha/push
cd /home/el/traefik-ha && ./pushOptional: Automatic Pull with Cron #
If you would rather not push manually, have each backup node pull on a schedule:
# On hosts 11 and 200
crontab -e
# Add:
*/5 * * * * cd /home/el/traefik-ha && git pull origin main && docker compose restart traefik >> /var/log/traefik-sync.log 2>&1Day-2 Operations #
# Container status
docker compose -f /home/el/traefik-ha/compose.yaml ps
# Follow logs
docker logs -f traefik-ha
# Which node currently holds the VIP?
ip addr show | grep 10.1.1.111
# Watch failover happen live
sudo systemctl stop keepalived # on the MASTER, then watch the VIP move
journalctl -u keepalived -fTroubleshooting #
| Symptom | What to check |
|---|---|
| Traefik container keeps restarting | docker logs traefik-ha — look for config errors (e.g. an unknown field under propagation). |
| No certificate issued | Cloudflare token scopes (Zone → DNS → Edit); the zone must exist; scan logs for dns01 challenge errors. |
| VIP never fails over | VRRP multicast 224.0.0.18 blocked by firewall, or wrong interface (eth0 vs wlan0). |
git push fails |
Test ssh -T git@github-homelab; confirm the key is loaded and the alias is correct. |
acme.json permission denied |
chmod 600 .../traefik/certs/acme.json and ensure it is owned by root. |
| Routing works on one node only | A backend URL probably uses a container name instead of an IP address. |
Conclusion #
You now have the same resilient Traefik setup as the Consul version, with fewer moving parts: Git distributes the configuration, Traefik’s File Provider reads it locally, its built-in ACME client handles certificates, and Keepalived keeps a single Virtual IP pointing at a healthy node.
The mental model is refreshingly small — if a node dies, the VIP moves, and the survivor already has the same config because it pulled from the same repo. You can patch and reboot hosts one at a time, and users never notice.
If you came from the Consul build, both designs are worth knowing: Traefik High Availability with Consul KV & Keepalived for instant KV distribution, and this Git-based setup for maximum simplicity.
Which one fits your homelab better? Let me know in the comments — and tell me how you handle cert sync or config distribution on your own cluster.