Keepalived implements the Virtual Router Redundancy Protocol (VRRP) on Linux. Two or more servers agree on which one is the active node, and that node holds a shared virtual IP (VIP) address. If the active server stops responding, or a service you track on it fails, another node takes over the VIP within a couple of seconds, so clients keep connecting to the same address.
In this tutorial you will build an active/passive pair on Ubuntu 24.04: two Nginx web servers behind one virtual IP, with Keepalived moving the IP when a server or its Nginx process goes down. The same pattern works for HAProxy, a DNS resolver or any service that listens on a single address.
Prerequisites
To follow this guide you need:
- Two servers running Ubuntu 24.04 LTS, for example two CubePath VPS in the same location. This guide calls them web1 (primary) and web2 (backup).
- A non-root user with
sudoprivileges on both servers. - A network shared by both servers where they can reach each other directly, such as a private network. The examples use the interface
eth1with the addresses10.0.0.11(web1) and10.0.0.12(web2). - A free address in that same subnet to use as the virtual IP. The examples use
10.0.0.100.
ImportantThe virtual IP must be an address that the network allows to move between servers. On a private Layer 2 network this works out of the box. A public IP only fails over if it is routed to both servers and the network accepts gratuitous ARP from either of them, so check that with your provider before you rely on it.
Find the interface name and address on each server before you start:
ip -brief address
lo UNKNOWN 127.0.0.1/8 ::1/128
eth0 UP 203.0.113.21/24 fe80::be24:11ff:fe8a:1c2d/64
eth1 UP 10.0.0.11/24 fe80::be24:11ff:fe52:9a10/64
Replace eth1, 10.0.0.11, 10.0.0.12 and 10.0.0.100 with your own values throughout the guide.
Step 1 - Installing Nginx and Keepalived
Run the following commands on both servers. Nginx is the service you will make highly available, and Keepalived manages the virtual IP:
sudo apt update
sudo apt install nginx keepalived
To see which node answers during the failover test, replace the default page with one that shows the hostname:
echo "Served by $(hostname)" | sudo tee /var/www/html/index.html
Check that Nginx is running and returns the page:
curl http://localhost
Served by web1
Keepalived does not start yet. On Ubuntu its systemd unit only starts when /etc/keepalived/keepalived.conf exists, which you will create in Step 3.
Step 2 - Allowing VRRP traffic in the firewall
The nodes exchange VRRP advertisements using IP protocol 112. If UFW is active and drops these packets, both servers believe the other one is gone and both claim the VIP (a "split brain"). UFW has no simple rule for protocol 112, so add it to the rules UFW loads before its own.
On web1, open the file:
sudo nano /etc/ufw/before.rules
Find the line # End required lines near the top of the file and add these two lines directly below it:
# Allow VRRP from the Keepalived peer
-A ufw-before-input -p 112 -s 10.0.0.12 -j ACCEPT
On web2, add the same rule with the address of web1 as the source:
# Allow VRRP from the Keepalived peer
-A ufw-before-input -p 112 -s 10.0.0.11 -j ACCEPT
Allow HTTP on both servers so clients can reach Nginx, then reload UFW:
sudo ufw allow 'Nginx HTTP'
sudo ufw reload
If UFW is not enabled on your servers (sudo ufw status shows Status: inactive), the rules are stored but have no effect, and VRRP traffic already passes.
Step 3 - Configuring Keepalived on the primary node
Keepalived runs health check scripts, and with the enable_script_security option it refuses to run them as root. Create a dedicated system user without a shell for that purpose on both servers:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin keepalived_script
Now create the configuration file on web1:
sudo nano /etc/keepalived/keepalived.conf
Add the following content:
global_defs {
router_id web1
enable_script_security
script_user keepalived_script
}
vrrp_script chk_nginx {
script "/usr/bin/systemctl is-active --quiet nginx"
interval 2
fall 2
rise 2
}
vrrp_instance VI_1 {
state MASTER
interface eth1
virtual_router_id 51
priority 150
advert_int 1
unicast_src_ip 10.0.0.11
unicast_peer {
10.0.0.12
}
authentication {
auth_type PASS
auth_pass Kp7xQ2vM
}
virtual_ipaddress {
10.0.0.100/24 dev eth1
}
track_script {
chk_nginx
}
}
What each part does:
router_idis a name for this node that appears in the logs.vrrp_script chk_nginxrunssystemctl is-active nginxevery 2 seconds. After 2 failures (fall) the check is marked as failed, and after 2 successes (rise) it is healthy again.state MASTERandpriority 150make web1 the preferred owner of the VIP. The node with the highest priority wins the election.virtual_router_ididentifies this VRRP group. Both nodes must use the same value, and it must be unique among VRRP groups on the same network (1 to 255).unicast_src_ipandunicast_peersend advertisements directly to the other node instead of to the multicast group224.0.0.18. Most cloud and virtualized networks filter multicast, so unicast is the reliable choice.auth_passmust be identical on both nodes and is limited to 8 characters. Replace it with your own random string. It only protects against misconfigured neighbors, not against an attacker on the same network, because it travels in clear text.track_scriptties the instance to the Nginx check: if Nginx stops, this node enters theFAULTstate and releases the VIP.
Validate the syntax before starting the service:
sudo keepalived --config-test -f /etc/keepalived/keepalived.conf
The command prints nothing and exits with status 0 when the file is valid. Enable and start Keepalived:
sudo systemctl enable --now keepalived
Step 4 - Configuring Keepalived on the backup node
On web2, create the same file with four differences: a different router_id, state BACKUP, a lower priority, and the source and peer addresses swapped.
sudo nano /etc/keepalived/keepalived.conf
global_defs {
router_id web2
enable_script_security
script_user keepalived_script
}
vrrp_script chk_nginx {
script "/usr/bin/systemctl is-active --quiet nginx"
interval 2
fall 2
rise 2
}
vrrp_instance VI_1 {
state BACKUP
interface eth1
virtual_router_id 51
priority 100
advert_int 1
unicast_src_ip 10.0.0.12
unicast_peer {
10.0.0.11
}
authentication {
auth_type PASS
auth_pass Kp7xQ2vM
}
virtual_ipaddress {
10.0.0.100/24 dev eth1
}
track_script {
chk_nginx
}
}
Validate and start it:
sudo keepalived --config-test -f /etc/keepalived/keepalived.conf
sudo systemctl enable --now keepalived
Step 5 - Verifying the virtual IP
On web1, the VIP should now appear as a second address on eth1:
ip -brief address show eth1
eth1 UP 10.0.0.11/24 10.0.0.100/24 fe80::be24:11ff:fe52:9a10/64
On web2, the same command shows only 10.0.0.12/24. Check the state transitions in the logs of each node:
sudo journalctl -u keepalived --no-pager | grep VI_1
On web1 you should see a line like this:
Keepalived_vrrp[2141]: (VI_1) Entering MASTER STATE
And on web2:
Keepalived_vrrp[1987]: (VI_1) Entering BACKUP STATE
From another machine on the private network (or from web2 itself), request the VIP:
curl http://10.0.0.100
Served by web1
Step 6 - Testing failover
A failover setup you have not tested is not a failover setup. Run a loop on a third machine, or on web2, that requests the VIP once per second:
while true; do curl -s --max-time 1 http://10.0.0.100; sleep 1; done
Service failure
On web1, stop Nginx:
sudo systemctl stop nginx
Within about four seconds (two failed checks at a 2-second interval) the loop switches from Served by web1 to Served by web2. The web1 log shows why:
Keepalived_vrrp[2141]: Script `chk_nginx` now returning 3
Keepalived_vrrp[2141]: VRRP_Script(chk_nginx) failed (exited with status 3)
Keepalived_vrrp[2141]: (VI_1) Entering FAULT STATE
Start Nginx again:
sudo systemctl start nginx
Once the check passes twice, web1 has the higher priority again and takes the VIP back. This is called preemption and is the default behavior.
Server failure
Now simulate a crashed server by stopping Keepalived on web1:
sudo systemctl stop keepalived
web2 stops receiving advertisements and, after roughly three advertisement intervals, logs (VI_1) Entering MASTER STATE and adds 10.0.0.100 to its interface. Start Keepalived on web1 again when you are done:
sudo systemctl start keepalived
Press Ctrl+C to stop the test loop.
Step 7 - Adjusting failover behavior (optional)
Two settings are worth knowing once the basic pair works.
Avoiding a second switch on recovery. With preemption, clients are moved twice: once when web1 fails and again when it recovers. If you prefer that the VIP stays on web2 until the next failure, set state BACKUP on both nodes and add nopreempt inside the vrrp_instance block on both. Keep the different priorities so the initial election is still predictable.
Running commands on state changes. Keepalived can run a script when a node becomes master, backup or faulty, for example to send an alert. Add a line such as this inside vrrp_instance:
notify_master "/usr/local/bin/keepalived-notify.sh MASTER"
The script must be owned by root and not writable by other users, or Keepalived refuses to run it when enable_script_security is set. It runs as keepalived_script, so it cannot restart services unless you grant that explicitly.
After any change, validate the file and restart the service:
sudo keepalived --config-test -f /etc/keepalived/keepalived.conf
sudo systemctl restart keepalived
Troubleshooting
Both nodes hold the VIP at the same time. The nodes cannot see each other's advertisements. Confirm that VRRP packets arrive on each node with sudo tcpdump -ni eth1 vrrp. If nothing shows up, review the firewall rule from Step 2 and the unicast_src_ip and unicast_peer addresses. Also check that virtual_router_id and auth_pass match on both nodes.
The logs show WARNING - default user 'keepalived_script' for script execution does not exist. Create the user as shown in Step 3 and restart Keepalived.
The check never succeeds and the node stays in FAULT. Run the check manually as the script user to see its exit code:
sudo -u keepalived_script /usr/bin/systemctl is-active nginx
It should print active. If Nginx is running but the check still fails, look for typos in the script path.
A service bound to the VIP will not start on the backup node. A process cannot bind to an address that is not configured on the server. Either make the service listen on all addresses (Nginx does this by default with listen 80;) or allow binding to non-local addresses:
echo 'net.ipv4.ip_nonlocal_bind = 1' | sudo tee /etc/sysctl.d/60-nonlocal-bind.conf
sudo sysctl --system
Conclusion
You now have two Ubuntu 24.04 servers sharing a virtual IP with Keepalived, with automatic failover when either Nginx or the whole primary server fails, and you have tested both cases. Clients only need to know 10.0.0.100.
As next steps, put HAProxy on both nodes and track it instead of Nginx to build a redundant load balancer, keep the web content in sync between the nodes with a deployment tool or rsync, and send the Keepalived logs to your monitoring system so every state change is recorded.
