Proxmox HA load balancing is not dynamic. The cluster resource scheduler (CRS) places VMs when they start or fail over, but it never rebalances running workloads. If a node gets overloaded, nothing moves automatically.

What the CRS Actually Does
The CRS picks a node for a VM when it starts or when HA recovers it. It uses a scoring system based on CPU, memory, and sometimes other factors. But once the VM is running, the CRS doesn’t touch it. No live migration, no balancing. It’s a placement algorithm, not a balancer.
This trips people up. You set up HA, see “load balancing” in the docs, and assume it’ll spread VMs out. Nope. If you add a new node, existing VMs stay put. If one node gets slammed, nothing moves.
Why It Doesn’t Rebalance
Rebalancing means live-migrating VMs, which is risky and disruptive. Proxmox’s design philosophy is conservative: don’t move things unless you have to. The CRS only runs when a VM needs a new home. That’s it.
So if you want dynamic load balancing, you have to script it yourself.

A Script to Force Rebalancing
Here’s a basic approach. It checks each node’s CPU load and migrates the VM with the highest CPU usage off the most loaded node. You’d run this on a schedule, like every 15 minutes via cron.
#!/bin/bash
# Simple Proxmox HA load balancer
THRESHOLD=80 # CPU load percentage to trigger migration
# Get the node with highest CPU load
OVERLOADED_NODE=$(pvesh get /nodes --output-format json | jq -r '.[].node' | while read node; do
LOAD=$(pvesh get /nodes/$node/status --output-format json | jq -r '.data.cpu')
echo "$node $LOAD"
done | sort -k2 -n | tail -1 | awk '{print $1}')
LOAD=$(pvesh get /nodes/$OVERLOADED_NODE/status --output-format json | jq -r '.data.cpu')
if [ $(echo "$LOAD > $THRESHOLD" | bc) -eq 1 ]; then
# Find the VM with highest CPU usage on that node
VMID=$(pvesh get /nodes/$OVERLOADED_NODE/qemu --output-format json | jq -r '.[].vmid' | while read vmid; do
CPU=$(pvesh get /nodes/$OVERLOADED_NODE/qemu/$vmid/status/current --output-format json | jq -r '.data.cpu')
echo "$vmid $CPU"
done | sort -k2 -n | tail -1 | awk '{print $1}')
# Find the least loaded node (excluding the current one)
TARGET_NODE=$(pvesh get /nodes --output-format json | jq -r '.[].node' | grep -v $OVERLOADED_NODE | while read node; do
LOAD=$(pvesh get /nodes/$node/status --output-format json | jq -r '.data.cpu')
echo "$node $LOAD"
done | sort -k2 -n | head -1 | awk '{print $1}')
# Migrate the VM
qm migrate $VMID $TARGET_NODE --online
fi
This is rough. It doesn’t check memory, doesn’t handle HA groups, and could cause a migration storm if loads fluctuate. You’d want to add cooldowns and better scoring. But it’s a starting point.
Should You Even Bother?
For most homelabs, dynamic rebalancing is overkill. If you have a few VMs and a couple of nodes, manual migration when you notice a problem is fine. The script above adds complexity and risk. Live migration can fail, and if it does, your VM might be stuck.
If you really need automatic balancing, consider using HA groups with static assignments. You can pin VMs to preferred nodes, and if a node fails, they’ll move to the fallback. That’s not dynamic, but it’s predictable. And predictability is worth a lot.
For more on HA and failover, check out Falling Over to a ZFS Replica: The Manual Proxmox Failover That Actually Works.