Setting Up High Availability (CARP) in OPNsense
This guide describes how to build a two-node OPNsense high availability cluster using CARP (Common Address Redundancy Protocol). It covers address planning, pfsync, configuration synchronization, DHCP failover with Kea, and the hypervisor and switch settings that cause most CARP failures, particularly when the firewalls are virtual machines.
How OPNsense HA works
An OPNsense HA cluster consists of three separate mechanisms:
- CARP. Two nodes share virtual IPs (VIPs). Both nodes are configured with each VIP, but only the current MASTER answers for it. Nodes elect the master by exchanging multicast advertisements. If the master stops advertising, the backup takes over in about a second.
- pfsync. The master streams its firewall state table to the backup over a dedicated link, so established connections survive a failover instead of being reset.
- XMLRPC config sync. The primary pushes configuration (firewall rules, NAT, VIPs, aliases, service configs) to the secondary, so administration happens on one box.
Clients and DNS never point at either node's real IP. They point at the CARP VIPs, which move to whichever node is currently master.
Address planning
Plan the addressing before touching the GUI. Every interface that participates in HA needs three addresses: a real IP for each node, plus the shared CARP VIP that clients actually use.
WAN
You need a routed block with at least three usable addresses; each additional public service bind requires another. Example with a /29:
- 203.0.113.57: ISP gateway
- 203.0.113.58: primary node real WAN IP
- 203.0.113.60: secondary node real WAN IP
- 203.0.113.59: CARP VIP for the public service bind (e.g. reverse proxy)
- 203.0.113.62: CARP VIP for outbound NAT and the shared "router" address
No ISP-side change is needed as long as the VIPs are inside a block already routed to your segment.
Internal interfaces
The same pattern applies on every LAN/VLAN: the address clients use as their default gateway becomes a CARP VIP, and each node gets a unique real IP beside it.
- LAN (/24): CARP VIP (shared gateway) 192.168.0.1, primary real 192.168.0.2, secondary real 192.168.0.3
- SERVERS (/24): CARP VIP 172.16.1.1, primary real 172.16.1.2, secondary real 172.16.1.3
- IOT (/24): CARP VIP 192.168.1.1, primary real 192.168.1.2, secondary real 192.168.1.3
- PFSYNC (/30): no CARP VIP; primary real 10.10.10.1, secondary real 10.10.10.2
Notes:
- The pfsync interface never gets a CARP VIP. It is a point-to-point sync link between the two node reals: a dedicated VLAN or crossover-style segment with nothing else on it.
- VHIDs must be unique per broadcast domain. A simple mnemonic is the last octet of the VIP (VHID 59 for .59, VHID 1 for the .1 gateways on their separate segments, and so on). If you add IPv6 VIPs on the same interface as IPv4 ones, give them distinct VHIDs (e.g. 159 vs 59).
- Each VIP gets a password, identical on both nodes (config sync handles that automatically).
- If you are converting an existing single-node setup, keep the primary's WAN real IP unchanged and add VIPs around it. This limits how much has to change in one step.
Hypervisor and switch prerequisites
If your OPNsense nodes are VMs, do this section first. These settings account for most "CARP doesn't work" and "CARP flaps constantly" reports, and none of them are visible from inside OPNsense.
CARP has two requirements that default hypervisor security settings block. The master answers for a virtual MAC address (00:00:5e:00:01:XX, where XX is the VHID) that does not belong to the VM's NIC, and the nodes communicate via multicast (224.0.0.18).
VMware ESXi
On the vSwitch/portgroups carrying CARP interfaces, set all three security options to Accept:
- Promiscuous Mode
- MAC Address Changes
- Forged Transmits
Without these, VIPs show "MASTER" locally but traffic to them is silently dropped by the vSwitch, or the nodes never hear each other's advertisements.
ESXi with two or more physical uplinks: constant flapping
If the vSwitch has two uplinks and promiscuous mode is on, the following loop forms:
- A node sends a CARP advertisement out uplink 1.
- The physical switch floods the multicast back in on uplink 2.
- Promiscuous mode delivers the reflected copy back to the same node that sent it.
- The node interprets this as another master and logs
MASTER -> BACKUP (more frequent advertisement received), then shortly afterBACKUP -> MASTER (master timed out). Every VHID flaps at the same instant, and on IPv6 this can escalate into a duplicate-address-detection storm.
The fix is an ESXi advanced setting that drops multicast arriving on the uplink it did not leave from:
# check (default is 0)
esxcli system settings advanced list -o /Net/ReversePathFwdCheckPromisc
# enable
esxcli system settings advanced set -o /Net/ReversePathFwdCheckPromisc -i 1
Apply it on both hosts, then activate it by toggling Promiscuous Mode Reject → Accept on the affected portgroups. The setting only takes effect when promiscuous mode is re-applied; no reboot is needed, and it persists across reboots.
An alternative, if that setting is not an option: configure the CARP portgroups' NIC teaming with a single active uplink (others standby). That also stops the reflection, at the cost of uplink load balancing.
Proxmox VE
Linux bridges do not filter the way vSwitches do, but Proxmox has its own issues:
- CARP virtual MAC blocked: disable the "Firewall" checkbox on the OPNsense VM's NICs. The per-NIC firewall does MAC anti-spoofing that blocks the CARP virtual MAC. This is the most common Proxmox CARP problem.
- Multicast reflection with bonded uplinks: use
active-backupbonding, or LACP (802.3ad) with matching switch config. Avoidbalance-rrandbalance-xor. - Bridge pruning CARP multicast: disable bridge multicast snooping (
bridge-mcsnoop 0in/etc/network/interfaces). It is on by default and can prune CARP advertisements when no querier exists. - NIC type: use VirtIO. If you see checksum or throughput problems, disable hardware CRC/TSO/LRO in Interfaces → Settings.
Physical switches
- IGMP/MLD snooping: either disable it on the VLANs carrying CARP, or make sure a multicast querier exists. Snooping without a querier prunes CARP advertisements and the nodes stop hearing each other.
- The pfsync VLAN and every CARP VLAN must be trunked to both nodes, and across any inter-switch links between them.
Step 1: pfsync link
Give each node an interface on the dedicated sync segment (e.g. a PFSYNC VLAN, 10.10.10.1/30 and 10.10.10.2/30). Confirm the nodes can ping each other across it before going further.
Add the firewall rule. A newly assigned OPNsense interface default-denies everything inbound. Add a pass rule on the PFSYNC interface (source = the pfsync subnet 10.10.10.0/30, protocol any) on both nodes. Without it, state sync fails, along with anything else that runs over this link, such as Kea HA.
Step 2: HA settings (System → High Availability → Settings)
On the primary:
- Synchronize interface = PFSYNC; enable state synchronization.
- Synchronize Config to IP = the secondary's pfsync address (10.10.10.2), with the secondary's admin username and password.
- Tick the config sections to sync: Virtual IPs, Firewall Rules, NAT, Aliases, and whichever service configs you use.
On the secondary:
- Synchronize interface = PFSYNC, state sync on.
- Leave Synchronize Config to IP blank. Only the primary pushes; pointing the secondary back at the primary (or at itself) breaks sync.
Two requirements that are easy to miss:
- The WebGUI ports must match on both nodes. XMLRPC contacts the backup on the primary's GUI port. If the primary listens on 8443 and the secondary on 443, sync fails with "backup not accessible." Also untick Verify peer if the nodes use self-signed certificates.
- Plugins are NOT synced. Config sync copies configuration, not installed software. Every plugin whose config you sync (HAProxy, ACME, etc.) must be manually installed on the secondary via System → Firmware → Plugins. Otherwise a failover moves the VIPs, but the daemon that should answer on them does not exist on the backup.
Step 3: CARP VIPs (Firewall → Virtual IPs)
Create all VIPs on the primary; they sync to the secondary. For each:
- Type: CARP, mode multicast (the default; leave the peer field empty).
- Subnet mask = the parent interface's mask, not /32. A VIP on a /24 LAN is a /24; on a /29 WAN it is a /29. Mismatched masks cause the advertisement-hash mismatch and split-brain described in the OPNsense documentation.
- VHID: per your address plan (unique per broadcast domain).
- advbase 1, advskew 0. The config sync automatically stores the secondary's copies with advskew 100. Lower skew wins the election, which makes the primary the preferred master and lets it reclaim MASTER after recovering instead of leaving a stable split-brain.
- Password: set once; syncs.
If you are converting from a single node that used IP alias VIPs for extra addresses, delete those aliases and re-create them as CARP VIPs. Do not leave duplicates behind.
After any VIP rework: run System → High Availability → Status → Synchronize, verify the secondary's VIP list is identical (no leftover /32s or alias duplicates), and reboot both nodes if the VIP set changed significantly.
Healthy state (Interfaces → Virtual IPs → Status): the primary shows every VIP MASTER at freq 1/0; the secondary shows every VIP BACKUP at freq 1/100; nothing flaps.
Step 4: per-node settings
Config sync deliberately does not touch the following. Set these by hand on each box:
- Interface/VLAN assignments and each node's real IP addresses (including the pfsync IP).
- The WebGUI port (must match, per step 2).
- Installed plugins (per step 2).
- Kea's "This server name" (per step 6).
If you are converting an existing node, this is where its old interface IPs (now claimed by the VIPs) get replaced with the new node reals. Do this with console access available, since you are editing the addresses you are connected through.
Step 5: point services at the VIPs
- DHCP: the gateway (and DNS, if it is the firewall) handed to clients must be the CARP VIP, not a node real.
- Outbound NAT: switch to hybrid/manual and set the translation address to a WAN CARP VIP, so return traffic for outbound connections lands on whichever node is active.
- Reverse proxy / HAProxy: bind frontends to CARP VIPs, never node reals, so the public-facing addresses follow the master. HAProxy can bind an IPv6 VIP on the frontend and still talk to IPv4 backends, which is useful if the internal network is v4-only.
- Public DNS: point records at the VIPs.
ACME / Let's Encrypt in HA
- Install the
os-acme-clientplugin on both nodes, but run ACME only on the primary. The default sync list is already correct for this: certificates sync, the ACME client config does not. - Do not attempt renewals on both nodes. HTTP-01 validation arrives on the public CARP VIP, which only the master holds. The backup's validations fail and count against Let's Encrypt rate limits. The master renews, and the issued certificates sync to the backup automatically.
Step 6: DHCP failover with Kea
OPNsense's Kea DHCP supports HA in hot-standby mode: the primary answers all DHCP, both daemons replicate leases to each other over the pfsync link, and the standby takes over with the full lease table intact.
Configure identical HA peer entries on both nodes (they sync from the primary):
node-primary(role: primary): v4 URLhttp://10.10.10.1:8000/, v6 URLhttp://10.10.10.1:8001/node-secondary(role: standby): v4 URLhttp://10.10.10.2:8000/, v6 URLhttp://10.10.10.2:8001/
Key points:
- v4 and v6 must use different ports (8000/8001 here), or Kea fails to start with
bind: Address already in use. - Leave the deprecated control agent disabled on both nodes. Each daemon opens its own HA listener, binding the URL of the peer whose name matches its own "This server name". Nothing needs to be configured by hand beyond the peer table.
- The pfsync pass rule from step 1 (source = sync subnet, any protocol) already covers the 8000/8001 traffic.
- On the secondary, set "This server name" =
node-secondaryin both the DHCPv4 and DHCPv6 HA settings. This is the one Kea field that differs per node, and a config sync will push the primary's value (node-primary) over it, after which both nodes try to act as primary and HA never forms. Re-check it after every manual "Synchronize" run. The alternative is to leave Kea out of the sync set entirely and mirror subnet/reservation changes by hand, trading duplicate data entry for a stable per-node identity.
Verify: both Kea daemons are running with no bind errors, the secondary's Leases page shows the same leases as the primary, and the HA state reads hot-standby with the partner in normal/ready.
Step 7: test the failover
- Prerequisite: confirm every synced-config plugin (HAProxy etc.) is installed and running on the secondary. Config sync will push a full HAProxy config to a box with no HAProxy daemon; the VIPs will fail over and nothing will answer on them.
- Baseline: primary all MASTER, secondary all BACKUP, services up.
- On the primary: Enter Persistent CARP Maintenance Mode (Interfaces → Virtual IPs → Status), or reboot it.
- The secondary should take MASTER within a couple of seconds. Browse your hosted services, make an outbound connection, and renew a DHCP lease. Everything should keep working, and pfsync should keep existing connections alive.
- Exit maintenance mode. The primary reclaims MASTER (the advskew difference makes it the preferred master).
Repeat this test after any CARP or VIP change.
Troubleshooting quick reference
- All VHIDs flap MASTER↔BACKUP in unison (
more frequent advertisement received/master timed out): the node's own multicast is being reflected back via a second uplink. ESXi:Net.ReversePathFwdCheckPromisc=1, then re-toggle promiscuous mode. Proxmox: fix the bond mode. - VIP shows MASTER locally but is unreachable: the hypervisor is blocking the CARP virtual MAC. ESXi: set all three portgroup security options to Accept. Proxmox: turn off the NIC firewall checkbox.
- Everything stuck BACKUP with no link down: stuck CARP demotion counter. Check
sysctl net.inet.carp.demotion; if positive, find the flapping interface, then reset withsysctl net.inet.carp.demotion=0. - Both nodes claim MASTER (split-brain): the nodes cannot hear each other, or the VIP sets/masks differ. Check the multicast path (snooping/querier, trunks) and ensure identical VIP sets with parent-subnet masks.
- Config sync reports "backup not accessible": WebGUI port mismatch, or certificate verification. Use the same port on both nodes and untick Verify peer for self-signed certificates.
- States don't sync: missing pass rule on the pfsync interface. Add it on both nodes (source = sync subnet, protocol any).
- Failover moves VIPs but services stay down: a plugin is installed on the primary only; the config synced but the daemon is missing. Install the plugin on the secondary.
- Kea won't start with
bind: Address already in use: the v4/v6 HA listeners share a port, or the control agent was re-enabled. Use v4=8000, v6=8001 and keep the control agent disabled. - Kea HA never forms; both nodes act primary: the secondary's "This server name" is still
node-primary(often reverted by config sync). Setnode-secondaryon the secondary, for v4 and v6.
Known limitations
- Internal IPv6 gateway failover is not instant unless you CARP it too. If your LAN IPv6 default route comes from router advertisements, clients learn the router's link-local address, which is different on each node. Failover then waits on RA lifetime/NUD timeouts (tens of seconds) rather than switching with CARP. Options: lower the RA interval/lifetime, or make the internal IPv6 gateways CARP VIPs advertised by radvd.
- Multicast CARP needs a clean L2. If your environment reflects or prunes multicast and you cannot fix it, OPNsense supports unicast CARP (set each VIP's peer field to the other node's real IP, mirrored on both nodes) as a fallback. Fixing the L2 is preferable, since multicast is the simpler steady state.
- Anything not in the sync list is manual. Interface assignments, node IPs, plugins, and per-node service identity fields all live outside config sync. Keep a short per-node checklist for when you rebuild or replace a node.