↓ Skip to main content
  1. Posts/

Traefik High Availability with GitHub Sync (No Consul, Simpler Setup)

Noor Khafidzin
Author
Noor Khafidzin
A homelab enthusiast obsessed with system efficiency and the art of troubleshooting.
Table of Contents
Traefik High Availability - This article is part of a series.
Part : This Article

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.

The idea in one sentence: keep all Traefik config in Git, let every node read the same files, and let Keepalived float a Virtual IP between them. Git does the distribution, Keepalived does the failover.

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.

Prefer the Consul route? Nothing in the old guide is wrong. I keep both articles as two valid designs for the same problem — pick the one that matches how you like to operate. Read the Consul version at Traefik High Availability with Consul KV & Keepalived.

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/, and compose.yaml.
  • Keepalived floats the Virtual IP 10.1.1.111 so clients always hit a healthy node.
  • Let’s Encrypt certificates are stored in acme.json on each node — no rsync, no shared cert directory.

Prerequisites
#

  • Three Linux hosts (Debian/Ubuntu/Armbian) with Docker and the Compose plugin installed.
  • git installed 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.pub

Add 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/homelab

Test 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-ha

Using 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: false

Key points:

  • providers.file.watch: true — Traefik hot-reloads when the files change without a restart (this is what your git pull hooks into).
  • certificatesResolvers.cloudflare — Traefik now issues and renews certificates itself via the Cloudflare DNS-01 challenge. No acme.sh, no rsync.
  • forwardedHeaders.trustedIPs — makes sure the real client IP is passed to your backends.
File provider only. I deliberately left out the Docker provider so the whole routing table lives in Git. If you still want to expose some containers via labels, add 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.

Always use the backend’s IP address, never a Docker container name. Container names only resolve on the host where the container runs, so they break the moment traffic lands on a different node.

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/certs
  • network_mode: host — Traefik binds ports 80/443 directly and its API is reachable at localhost:8080, which is what Keepalived will health-check.
  • ./traefik/certs is a bind mount that persists acme.json between 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
Never commit secrets. Keep both .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.json

Because .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:

  1. Asks Let’s Encrypt for a certificate using the DNS-01 challenge.
  2. Uses your CF_DNS_API_TOKEN to create the _acme-challenge TXT record in Cloudflare.
  3. 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.json

Step 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.111

Finally, point your wildcard DNS record (*.noorkhafidzin.com) at the VIP 10.1.1.111.

Why host 200 stays lowest: it is on Wi-Fi, so it should only take over when both wired nodes are gone. Lower priority = last resort, which is exactly what you want.

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 && ./push

Optional: 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>&1

Day-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 -f

Troubleshooting
#

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.

Traefik High Availability - This article is part of a series.
Part : This Article

Related


Load Comments