nginx + keepalived VIP Failover: Parameter Matrix and 6 Production Incidents
Why keepalived, not BGP
If you have IPMI access, dynamic routing upstream, and a router willing to speak eBGP to a stateless VM, BGP is nicer. For two VMs behind a cloud VPC with a stateless load balancer in front, keepalived is the pragmatic answer: a small daemon speaking UDP 112 to the peer, moving the VIP in about three seconds. That's the whole pitch, and it's enough for most of us.
I've run keepalived in front of nginx at three companies and one non-profit. It works. It also fails in ways that eat on-call time if you don't know the failure modes ahead of time.
The happy path isn't the story
Every tutorial shows you the config, tells you to systemctl start keepalived, and shows the VIP floating when you kill the primary. In production you will not have that experience. You will have a firewall rule from a security audit three years ago, an auth_pass someone copy-pasted wrong, a NIC that got renamed after a kernel update, and a health check that only verifies the process is up rather than whether the service is serving. This article is about those cases.
Parameter matrix
Values for Ubuntu 22.04, keepalived 2.1.5, nginx 1.24.x.
| Parameter | Safe value | What breaks if tuned wrong |
|---|---|---|
virtual_router_id |
51 (identical both sides) |
Different IDs = different VRRP groups = no failover, no errors, just silence |
virtual_routerpriority |
Primary 100, Secondary 90 |
Same on both = split-brain; higher on secondary = VIP on wrong box |
virtual_ipaddress |
192.0.2.100/24 (identical) |
Not matching = VIP moves but unreachable; wrong prefix = routes nowhere |
advert_int |
1 (identical) |
3x gap between peers = silent desync, occasional flapping |
garp_master_delay |
5 |
Too low = STP churn on the switch; too high = slow first packet after failover |
garp_master_repeat |
2 |
Reduces post-failover burst; clients with stale ARP keep hitting the dead primary |
garp_master_refresh |
60 |
Refreshes gratuitous ARPs; 0 disables and stale ARP wins |
garp_master_ttl |
Omit | Non-zero sets a lifetime; usually leave to the switch |
gna_interval |
1 |
Interval for Gratuitous ARP; too aggressive annoys neighbors, too slow leaves caches stale |
garp_validate_interface |
Omit | Validates the GARP NIC matches interface; only for weird NIC topologies |
nopreempt |
Yes, both sides | Without it, primary recovery yanks the VIP back — connection resets |
smtp_connect_timeout |
3 |
Long = delayed alerts, short = false alerts on slow SMTP |
script (in vrrp_script) |
Bash curl to localhost | Any static check (pgrep nginx) misses process-alive-but-not-serving |
auth_pass |
8-char ASCII, identical both sides | Only first 8 chars are used; longer strings are noise (see trap below) |
interface |
e.g. eth0 |
Wrong NIC = VIP moves to a NIC the client can't route to |
interface_broadcast |
Omit | Forces broadcast on a specific NIC; only needed on multi-homed setups |
virtual_router_track_interface |
e.g. eth0 |
If the interface dies, VRRP drops the VIP immediately rather than waiting |
notify_script |
Path to state-change hook | Fires on MASTER/BACKUP transitions; useful for logging and alerting |
Case 1: VIP doesn't float when the primary dies
Symptom: kill nginx on the primary, wait 30 seconds, curl the VIP — still hitting the dead box.
Checklist, in order:
# 1. Are VRRP packets actually being sent?
sudo tcpdump -i eth0 -nn -c 5 vrrp
# 2. Do auth_pass values match byte-for-byte (first 8 chars)?
grep auth_pass /etc/keepalived/keepalived.conf | cut -c1-8
# 3. Is the firewall passing protocol 112?
sudo iptables -L INPUT -n | grep -Ei 'vrrp|112'
Nine out of ten: a firewall. VRRP is IP protocol 112, not a TCP/UDP port. Standard rules like -p tcp --dport 80 -j ACCEPT don't touch it, and a hardened host usually defaults to DROP which eats VRRP silently.
Also check the cloud security group — many managed clouds let you filter on protocol number, and someone almost always forgets to add 112.
Case 2: Split-brain — both nodes hold the VIP
Symptom: after a primary reboot, both hosts show the VIP in ip addr. A client's ARP cache points at whichever box it hit first, so requests randomly work and randomly hang.
Detect:
Root cause: priorities are equal (both think they're primary) or auth_pass mismatches (each side ignores the other and defaults to MASTER). Fix both:
vrrp_instance VI_1 {
interface eth0
virtual_router_id 51
virtual_routerpriority 100 # 90 on the secondary
advert_int 1
nopreempt
authentication {
auth_type PASS
auth_pass S3cret12
}
virtual_ipaddress {
192.0.2.100/24
}
}
Reload with systemctl reload keepalived — a reload, not a restart, is enough to clear the bad state on the losing node.
Case 3: Flapping every few minutes
Symptom: access logs show the VIP switching backends every 3–5 minutes, connections reset mid-flight.
I hit this the day I first deployed keepalived. The check was pgrep nginx. Under load, pgrep briefly returned a stale PID that was momentarily unresponsive; the check failed, VIP moved. Ten seconds later nginx recovered, check passed, VIP moved back. Every time.
Fix: nopreempt on both sides plus a slower, more deliberate check.
vrrp_script chk_nginx {
script "/etc/keepalived/check_nginx.sh"
interval 5
fall 3
rise 2
weight -20
}
fall 3 at interval 5 means 15 seconds of consecutive failure before demotion. That's a real outage, not a hiccup.
Case 4: Wrong NIC on multi-homed hosts
Symptom: VIP moves to the secondary, ip addr shows it correctly, but curl 192.0.2.100 times out.
The interface field is where VRRP sends advertisements. On a host with two NICs — eth0 for business, eth1 for management — you want VRRP on the business NIC. Keepalived has three related knobs: interface (primary NIC for VRRP and VIP binding), interface_broadcast (forces broadcast on a specific NIC, useful on point-to-point links), and bind_ifid (assigns the virtual router ID to a specific interface so multiple VRs don't collide).
The mistake I made: interface eth0 on the primary, interface eth1 on the secondary. On failover, keepalived bound the VIP to eth1 — the mgmt network — and client traffic went nowhere.
Rule of thumb: same NIC name, same subnet, on both nodes. Add garp_master_delay 5 and garp_master_repeat 2 so the switch's MAC table updates without taking the port down.
Case 5: Health check reports up when the backend is down
This one costs the most money. Nginx is running, listening on 80, but the upstream app is dead and every request 502s. Keepalived, checking only nginx, says all clear.
Don't use systemctl is-active --quiet nginx. That's liveness, not readiness. Use a script that speaks HTTP to what the client would speak HTTP to:
#!/bin/bash
# /etc/keepalived/check_nginx.sh
set -e
curl -sf --max-time 3 http://127.0.0.1/healthz > /dev/null
curl -sf --max-time 3 http://127.0.0.1/upstream-probe > /dev/null
The /upstream-probe location returns 200 only when its upstream responds. Without it, nginx will happily 502 while still being "up". If you can't add an endpoint, at minimum curl the actual client URL. Set fall 3, rise 2 so a single blip doesn't trip the failover.
Case 6: Upgrade breaks config parsing
Upgrading keepalived from 2.0.x to 2.1.x is where I've lost the most time to parser errors. Two gotchas.
! comments in vrrp_script. Keepalived 2.0 used ! as a line comment inside script blocks. 2.1 removed it in some code paths. If your config has:
You'll see Error found in configuration file on reload, and systemd won't start keepalived. Convert all ! comments to #.
vrrp_version default flipped. Keepalived 1.4 defaulted to VRRPv2, 2.x to VRRPv3. Mixed-version peers don't interop. Pin it explicitly on both sides:
Always test with keepalived -t -f /etc/keepalived/keepalived.conf before systemctl reload. If the parser complains, the reload will silently keep the old config live while the new config fails to parse — the worst kind of half-state.
The auth_pass trap
VRRP's auth_pass is defined as 8 ASCII characters. Keepalived silently truncates anything longer. I've seen people generate a 32-char password with openssl rand -base64 24, paste it into both configs, and think they have strong VRRP auth. They have 8 chars.
Worse, if two nodes share the same string but differ in the first 8 characters (say S3cretPass1 vs S3cretPass2), the nodes don't reject each other's packets loudly — they just ignore the advertisements and each falls back to thinking it's primary. Split-brain via a typo, half an hour to find.
Test it: grep auth_pass /etc/keepalived/keepalived.conf | cut -c1-8 on both nodes, then diff.
Cheat sheet: 15 commands to bookmark
| # | Command | What it tells you |
|---|---|---|
| 1 | sudo tcpdump -i eth0 -nn vrrp |
Are VRRP advertisements flowing? |
| 2 | sudo arping -I eth0 192.0.2.100 |
Who currently owns the VIP's MAC? |
| 3 | ip addr show eth0 |
Is the VIP bound on this host? |
| 4 | systemctl status keepalived |
Service state |
| 5 | journalctl -u keepalived -n 200 --no-pager |
Recent state transitions |
| 6 | keepalived -V |
Version — v2 vs v3 matters |
| 7 | keepalived -t -f /etc/keepalived/keepalived.conf |
Config syntax check |
| 8 | ss -tlnp \| grep :80 |
Is nginx listening? |
| 9 | ss -tnp state established '( sport = :80 )' |
Established client connections on 80 |
| 10 | cat /etc/keepalived/keepalived.conf |
Actual loaded config, not what you remember writing |
| 11 | tail -f /var/log/keepalived.log |
Old-style syslog output if you use it |
| 12 | dig +short 192.0.2.100 |
Reverse-resolve check |
| 13 | ifconfig eth0 |
Legacy alias for ip addr; some scripts still use it |
| 14 | ip route get 192.0.2.100 |
Which NIC the client kernel will use |
| 15 | curl -v --max-time 3 http://192.0.2.100/ |
End-to-end check through the VIP |
FAQ
Why isn't my VIP floating after the primary goes down? Three things, in this order. Are VRRP advertisements actually being sent? tcpdump -i eth0 -nn vrrp on the surviving node — you should see packets from the dead primary's IP every second until it truly dies. Does auth_pass match byte-for-byte? Remember the 8-character truncation. Is the firewall allowing protocol 112? On iptables: iptables -I INPUT -p vrrp -j ACCEPT. Half the time I've debugged this, it's the firewall. The other half is auth_pass.
What's the correct value for virtual_routerpriority? Lower is higher priority. Classic setup: Primary 100, Secondary 90. The 10-point gap is enough for VRRP to decide who wins without tripping on the small weight adjustments the track scripts make. Never use the same priority on both nodes — you'll get split-brain or constant re-election. For true active-active, don't fight keepalived: VRRP is fundamentally active-passive at the VIP level. Use LVS or BGP with ECMP.
Should I use nopreempt or preempt? For nginx fronting a web app: nopreempt on both sides. Once the secondary takes over, let it keep the VIP until it dies. With preempt, every time the primary recovers from any blip it yanks the VIP back — connections reset, DNS caches churn. Only use preempt when the primary has a stateful role the secondary can't do and failing back is cheaper than manual promotion.
Why is auth_pass 32 chars in my config but keepalived only uses 8? VRRP defines auth_pass as an 8-byte string (RFC 3768). Keepalived reads the value, silently truncates to 8 ASCII bytes, and uses that. It never warns. So pasting a 32-char openssl rand -base64 string into both configs gives you 8 chars of auth — and both nodes just need to agree on those first 8. Two nodes with different full passwords but the same first 8 chars will happily interop; two nodes with different first 8 chars won't interop at all and each falls back to thinking it's primary — split-brain. Test with cut -c1-8 before deploying.
How do I health-check the upstream so keepalived actually fails over? Two layers. First, use vrrp_script with a curl-based check, not systemctl is-active — that only tells you the process is registered, not that it's serving. Second, tune fall and rise. I use interval 5, fall 3, rise 2 — 15 seconds of consecutive failures before declaring the node down, 10 seconds of consecutive successes before bringing it back. A single blip never trips the failover; a real outage does. Add weight -20 so a failing check loses the VRRP election to a healthy peer at priority 100.