Build a two-node active-passive cluster with Pacemaker, Corosync and pcs, including STONITH fencing, resource constraints and automatic failover of a floating virtual IP.
Prerequisites
- At least two Linux nodes with root or sudo access
- A dedicated network path between nodes for cluster communication
- A working fencing mechanism such as IPMI, iLO, or a cloud fencing agent
- Synchronized system clocks across all nodes
- Basic familiarity with systemd and Linux networking
What this solves
Pacemaker and Corosync form the standard open source stack for building highly available Linux clusters. This tutorial builds a two-node active-passive cluster with quorum, STONITH fencing and a floating virtual IP, then walks through testing failover and troubleshooting the most common quorum and fencing failures.
This is an advanced setup. You need at least two nodes with a dedicated network for cluster communication, ideally a third quorum device or a third node, and a fencing mechanism such as IPMI, a cloud API agent or a shared power switch.
Step-by-step installation
Prepare both nodes
Set a unique hostname on each node and make sure both can resolve each other by name. Add entries to /etc/hosts on both nodes.
203.0.113.10 node1 node1.example.com
203.0.113.11 node2 node2.example.comConfirm time is in sync between nodes, since clock drift causes false quorum timeouts. See configuring NTP with chrony if your nodes are not already synced.
Install Corosync, Pacemaker and pcs
Install the cluster stack on both nodes. Ubuntu and Debian ship pcs in the universe/backports repos; AlmaLinux and Rocky include it in the HighAvailability repo, which must be enabled first.
sudo apt update
sudo apt install -y pacemaker corosync pcs fence-agents resource-agentssudo dnf install -y epel-release
sudo dnf config-manager --set-enabled highavailability
sudo dnf install -y pacemaker corosync pcs fence-agents-all resource-agentsRun these commands on both nodes.
Set the cluster user password and enable pcsd
pcs uses a system account called hacluster to authenticate between nodes. Set a strong password for it on both nodes, then enable the pcsd daemon.
sudo passwd hacluster
sudo systemctl enable --now pcsdUse a strong, unique password here, not something guessable. It is only used for inter-node cluster authentication, never for interactive login.
Open the required firewall ports
Corosync uses UDP 5405 and pcsd uses TCP 2224. Open these explicitly rather than disabling the firewall.
sudo ufw allow 2224/tcp
sudo ufw allow 5405/udp
sudo ufw allow 3121/tcp
sudo ufw allow 21064/tcpsudo firewall-cmd --permanent --add-service=high-availability
sudo firewall-cmd --reloadAuthenticate the nodes and create the cluster
Run this from one node only. It authenticates both hosts using the hacluster credentials and generates the Corosync configuration automatically.
sudo pcs host auth node1 node2 -u hacluster -p 'YourStrongClusterPass94!'
sudo pcs cluster setup mycluster node1 node2 --force
sudo pcs cluster start --all
sudo pcs cluster enable --allReview and tune corosync.conf
pcs generates /etc/corosync/corosync.conf automatically, but understanding it matters for troubleshooting. Each node is a ring member and quorum is calculated from the total number of configured votes.
totem {
version: 2
cluster_name: mycluster
transport: knet
crypto_cipher: aes256
crypto_hash: sha256
}
nodelist {
node {
ring0_addr: 203.0.113.10
name: node1
nodeid: 1
}
node {
ring0_addr: 203.0.113.11
name: node2
nodeid: 2
}
}
quorum {
provider: corosync_votequorum
two_node: 1
}
logging {
to_logfile: yes
logfile: /var/log/cluster/corosync.log
to_syslog: yes
}The two_node: 1 setting is critical in a two-node cluster. It tells Corosync to allow quorum with only one node reachable, since a strict majority of two nodes requires both to be online, which defeats the purpose of HA. In production with three or more nodes, remove this setting and let standard majority quorum apply.
Disable the default STONITH-less behavior and configure fencing
Pacemaker refuses to run resources safely without a working fencing device. Never set stonith-enabled=false in production. Configure a real fencing agent instead, for example IPMI based fencing (fence_ipmilan) for bare metal, or a cloud-specific agent for virtualized environments.
sudo pcs stonith create node1-fence fence_ipmilan \
pcmk_host_list="node1" \
ipaddr="203.0.113.50" \
login="admin" \
passwd="StrongIpmiPass77!" \
lanplus=1 \
op monitor interval=60s
sudo pcs stonith create node2-fence fence_ipmilan \
pcmk_host_list="node2" \
ipaddr="203.0.113.51" \
login="admin" \
passwd="StrongIpmiPass77!" \
lanplus=1 \
op monitor interval=60sEach fencing device must target the opposite node, not itself. node1-fence powers off node2's IPMI interface and vice versa, so a healthy node can forcibly remove a failed peer from the cluster.
pcs property set stonith-enabled=false in production.Create a floating virtual IP resource
This IP address moves to whichever node is currently active, giving clients a stable endpoint regardless of which physical node is serving traffic.
sudo pcs resource create cluster_vip ocf:heartbeat:IPaddr2 \
ip=203.0.113.100 cidr_netmask=24 \
op monitor interval=10sAdd a managed service and group it with the VIP
Assume you are protecting an Nginx instance. Install it on both nodes first, then wrap it in a Pacemaker resource so the cluster controls start and stop instead of systemd directly.
sudo pcs resource create web_server ocf:heartbeat:nginx \
configfile=/etc/nginx/nginx.conf \
op monitor interval=15s
sudo pcs resource group add web_group cluster_vip web_serverGrouping resources ensures they always start, stop and move together in the order listed. For more granular control than groups allow, use explicit constraints as shown next.
If you need a reverse proxy in front of this stack, see configuring an NGINX reverse proxy for the base config this resource expects.
Configure colocation and ordering constraints
Groups cover simple cases, but explicit constraints give finer control for more complex topologies, such as when a database must start before an application server on the same node.
sudo pcs constraint colocation add web_server with cluster_vip INFINITY
sudo pcs constraint order cluster_vip then web_serverThe colocation constraint forces web_server to always run on the same node as cluster_vip. The order constraint ensures the VIP comes up first, so the service is reachable the moment it starts. You can also set location preferences to favor a specific node during normal operation.
sudo pcs constraint location web_group prefers node1=100Review the full cluster configuration
Before testing failover, review the complete resource and constraint set to catch mistakes early.
sudo pcs config showVerify your setup
Check overall cluster status, quorum state and resource placement.
sudo pcs status
sudo corosync-quorumtool -s
sudo pcs resource statusConfirm the virtual IP is reachable and bound to the active node.
ip addr show | grep 203.0.113.100
curl -I http://203.0.113.100Test failover by moving the active node out of the cluster and watching resources migrate.
sudo pcs node standby node1
sudo pcs status
sudo pcs node unstandby node1During this test, the VIP and web_server should relocate to node2 within a few seconds, and curl against 203.0.113.100 should keep responding throughout. To simulate a hard failure instead of a graceful standby, power off node1 entirely and confirm node2's fencing agent successfully fences it before taking over resources.
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| pcs status shows "partition WITHOUT quorum" | Fewer than required votes are reachable, often from a network split or a node down | Check connectivity with corosync-cfgtool -s and confirm two_node or a qdevice is correctly configured |
| Resources stuck in "Stopped" with stonith errors | No working fencing device configured, or fencing agent cannot reach the target's management interface | Run sudo pcs stonith status and test manually with sudo stonith_admin --reboot node2 |
| Cluster forms but VIP never comes up | IPaddr2 resource cidr_netmask or interface mismatch | Check sudo pcs resource debug-start cluster_vip for detailed errors |
| Node rejoins but resources do not fail back | No location preference set, or stickiness keeps resources on the current node | Set sudo pcs resource defaults resource-stickiness=0 or adjust location constraints explicitly |
| Fencing loops: nodes repeatedly reboot each other | Fencing device misconfigured to target the wrong host, or network partition causing both nodes to fence each other | Verify each stonith resource's pcmk_host_list matches the correct target and add a fencing delay with pcmk_delay_max |
| pcs cluster auth fails between nodes | Firewall blocking TCP 2224, or hacluster password mismatch | Re-run sudo passwd hacluster on both nodes with the same value and confirm port 2224 is open |
Next steps
- Set up HAProxy high availability with keepalived as an alternative or complementary layer in front of this cluster
- Configure PostgreSQL streaming replication if the protected service is a database rather than a web server
- Monitor keepalived and cluster health with Prometheus alerts
- Configure Pacemaker cluster monitoring with Prometheus and Grafana
- Set up DRBD replicated storage for Pacemaker clusters
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# ------------------------------------------------------------------
# Pacemaker + Corosync HA cluster bootstrap script
# Run on EACH node, then only the final cluster creation step
# needs to run once (use --init-cluster on one node after both
# have run this script with --node-setup).
# ------------------------------------------------------------------
# ---- Colors ----
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[1;33m'
NC=$'\033[0m'
log() { echo -e "${GREEN}$*${NC}"; }
warn() { echo -e "${YELLOW}$*${NC}"; }
err() { echo -e "${RED}$*${NC}" >&2; }
usage() {
cat <<EOF
Usage: $0 --local-name <name> --local-ip <ip> --peer-name <name> --peer-ip <ip> [--init-cluster]
--local-name Hostname of this node (e.g. node1)
--local-ip IP of this node (e.g. 203.0.113.10)
--peer-name Hostname of the peer node (e.g. node2)
--peer-ip IP of the peer node (e.g. 203.0.113.11)
--cluster-name Name of the cluster (default: mycluster)
--init-cluster After setup, also authenticate + create the cluster
(run this flag on only ONE of the two nodes)
Example:
$0 --local-name node1 --local-ip 203.0.113.10 \\
--peer-name node2 --peer-ip 203.0.113.11 --init-cluster
EOF
exit 1
}
CLUSTER_NAME="mycluster"
INIT_CLUSTER=0
LOCAL_NAME=""
LOCAL_IP=""
PEER_NAME=""
PEER_IP=""
while [ $# -gt 0 ]; do
case "$1" in
--local-name) LOCAL_NAME="$2"; shift 2 ;;
--local-ip) LOCAL_IP="$2"; shift 2 ;;
--peer-name) PEER_NAME="$2"; shift 2 ;;
--peer-ip) PEER_IP="$2"; shift 2 ;;
--cluster-name) CLUSTER_NAME="$2"; shift 2 ;;
--init-cluster) INIT_CLUSTER=1; shift ;;
-h|--help) usage ;;
*) err "Unknown argument: $1"; usage ;;
esac
done
[ -z "$LOCAL_NAME" ] || [ -z "$LOCAL_IP" ] || [ -z "$PEER_NAME" ] || [ -z "$PEER_IP" ] && {
if [ -z "$LOCAL_NAME" ] || [ -z "$LOCAL_IP" ] || [ -z "$PEER_NAME" ] || [ -z "$PEER_IP" ]; then
err "Missing required arguments."
usage
fi
}
# ---- Prerequisite checks ----
if [ "$(id -u)" -ne 0 ]; then
err "This script must be run as root (or via sudo)."
exit 1
fi
# ---- Rollback on failure ----
cleanup_on_error() {
err "[ERROR] Installation failed. Rolling back partial changes..."
systemctl stop pcsd 2>/dev/null || true
systemctl stop corosync 2>/dev/null || true
systemctl stop pacemaker 2>/dev/null || true
warn "Rollback complete. Review /var/log for details before retrying."
}
trap cleanup_on_error ERR
TOTAL_STEPS=9
# ---- [1/9] Detect distro ----
echo "[1/9] Detecting distribution..."
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian)
PKG_MGR="apt"
PKG_INSTALL="apt install -y"
FW_CMD="ufw"
;;
almalinux|rocky|centos|rhel|ol|fedora)
PKG_MGR="dnf"
PKG_INSTALL="dnf install -y"
FW_CMD="firewalld"
;;
amzn)
PKG_MGR="yum"
PKG_INSTALL="yum install -y"
FW_CMD="firewalld"
;;
*)
err "Unsupported distro: $ID"
exit 1
;;
esac
else
err "/etc/os-release not found, cannot detect distro."
exit 1
fi
log "Detected distro: $ID ($PKG_MGR)"
# ---- [2/9] Set hostname and /etc/hosts ----
echo "[2/9] Setting hostname and /etc/hosts entries..."
hostnamectl set-hostname "$LOCAL_NAME"
# Add host entries idempotently
for entry in "$LOCAL_IP $LOCAL_NAME ${LOCAL_NAME}.example.com" "$PEER_IP $PEER_NAME ${PEER_NAME}.example.com"; do
ip_part=$(echo "$entry" | awk '{print $1}')
if ! grep -q "^${ip_part}[[:space:]]" /etc/hosts 2>/dev/null; then
echo "$entry" >> /etc/hosts
fi
done
log "Hostname and /etc/hosts configured."
# ---- [3/9] Update package cache ----
echo "[3/9] Updating package cache..."
if [ "$PKG_MGR" = "apt" ]; then
apt update
elif [ "$PKG_MGR" = "dnf" ]; then
dnf install -y epel-release || true
dnf config-manager --set-enabled highavailability || true
fi
# ---- [4/9] Install cluster stack ----
echo "[4/9] Installing Pacemaker, Corosync, pcs, fencing agents..."
if [ "$PKG_MGR" = "apt" ]; then
$PKG_INSTALL pacemaker corosync pcs fence-agents resource-agents chrony
elif [ "$PKG_MGR" = "dnf" ] || [ "$PKG_MGR" = "yum" ]; then
$PKG_INSTALL pacemaker corosync pcs fence-agents-all resource-agents chrony
fi
log "Cluster packages installed."
# ---- [5/9] Ensure time sync is running ----
echo "[5/9] Ensuring chrony (NTP) is enabled..."
systemctl enable --now chronyd 2>/dev/null || systemctl enable --now chrony 2>/dev/null || \
warn "Could not enable chrony automatically; verify time sync manually."
# ---- [6/9] Set hacluster password and enable pcsd ----
echo "[6/9] Setting hacluster password and enabling pcsd..."
if [ -z "${HACLUSTER_PASSWORD:-}" ]; then
warn "HACLUSTER_PASSWORD env var not set. Generating a random password."
HACLUSTER_PASSWORD=$(tr -dc 'A-Za-z0-9!@#%' </dev/urandom | head -c 20)
warn "Generated hacluster password: $HACLUSTER_PASSWORD"
warn "Store this securely — you need the SAME password on both nodes."
fi
echo "hacluster:${HACLUSTER_PASSWORD}" | chpasswd
systemctl enable --now pcsd
log "pcsd enabled and hacluster password set."
# ---- [7/9] Open firewall ports ----
echo "[7/9] Configuring firewall rules..."
if [ "$FW_CMD" = "ufw" ]; then
if command -v ufw >/dev/null 2>&1; then
ufw allow 2224/tcp
ufw allow 5405/udp
ufw allow 3121/tcp
ufw allow 21064/tcp
log "ufw rules added (2224/tcp, 5405/udp, 3121/tcp, 21064/tcp)."
else
warn "ufw not found; skipping firewall configuration."
fi
elif [ "$FW_CMD" = "firewalld" ]; then
if command -v firewall-cmd >/dev/null 2>&1; then
systemctl enable --now firewalld
firewall-cmd --permanent --add-service=high-availability
firewall-cmd --reload
log "firewalld high-availability service enabled."
else
warn "firewall-cmd not found; skipping firewall configuration."
fi
fi
# ---- [8/9] Enable corosync/pacemaker services ----
echo "[8/9] Enabling corosync and pacemaker services..."
systemctl enable corosync 2>/dev/null || true
systemctl enable pacemaker 2>/dev/null || true
log "Base node setup complete for $LOCAL_NAME."
# ---- [9/9] Optionally initialize the cluster ----
echo "[9/9] Cluster initialization step..."
if [ "$INIT_CLUSTER" -eq 1 ]; then
log "Running cluster initialization (this host: $LOCAL_NAME)..."
# Authenticate both nodes using hacluster credentials
pcs host auth "$LOCAL_NAME" "$PEER_NAME" -u hacluster -p "$HACLUSTER_PASSWORD"
# Generate corosync config and create the cluster
pcs cluster setup "$CLUSTER_NAME" "$LOCAL_NAME" "$PEER_NAME" --force
# Start and enable cluster on both nodes
pcs cluster start --all
pcs cluster enable --all
# For a strict two-node cluster, allow quorum with one node reachable.
# In production with 3+ nodes, remove this and use a qdevice instead.
pcs property set no-quorum-policy=stop || true
if [ -f /etc/corosync/corosync.conf ] && ! grep -q "two_node" /etc/corosync/corosync.conf; then
warn "two_node setting not found in corosync.conf; pcs cluster setup should have added it automatically for a 2-node cluster."
fi
log "Cluster '$CLUSTER_NAME' created and started."
else
warn "Skipping cluster creation (--init-cluster not passed)."
warn "Run this script with --init-cluster on exactly ONE node once both nodes are set up."
fi
# ---- Verification ----
echo "Running verification checks..."
FAILED=0
if ! systemctl is-active --quiet pcsd; then
err "pcsd is not running."
FAILED=1
else
log "pcsd is running."
fi
if [ "$INIT_CLUSTER" -eq 1 ]; then
if command -v pcs >/dev/null 2>&1; then
if pcs cluster status >/dev/null 2>&1; then
log "Cluster status check passed."
pcs status || true
else
err "Cluster status check failed."
FAILED=1
fi
fi
fi
if [ "$FAILED" -eq 1 ]; then
err "One or more verification checks failed. Review output above."
exit 1
fi
trap - ERR
log "Setup complete for node: $LOCAL_NAME"
warn "Reminder: do NOT skip STONITH fencing configuration before production use."
Review the script before running. Execute with: bash install.sh