Proxmox VEXXVII · Multi-Cluster and Multi-TenancyRunning more than one
Keeping clusters identical: baselines, drift and version skew
What you'll learn
- Enumerate the cluster-scoped configuration files in /etc/pve and say which must match across clusters
- Define a naming contract for storage IDs, bridges and VMID ranges, and explain what each one buys
- Build a drift report that compares two clusters through the API rather than by reading files
- State a version-skew policy that distinguishes what must match inside a cluster from what should match across clusters
- Choose which parts of the baseline to automate first, and which to leave alone
Prerequisites
Verified against Proxmox VE 9.2.4 · Proxmox Backup Server 4.2.5 · Ceph Squid / Tentacle · Debian 13 (Trixie) · Linux kernel 7.0 (PVE 9.2 default) · 2026-08-12
Drift does not announce itself. It shows up as a sentence in an incident channel: “that works on the other cluster”. By then it has usually been true for months.
The previous two lessons were about deciding to have a second cluster and moving guests to it. This one is about the bill that arrives afterwards, which is not hardware. It is that every cluster-scoped setting now exists twice, with nothing keeping the copies in agreement.
The surface, named
/etc/pve is the whole cluster control plane, and its file map is documented.
These are the entries that are cluster-scoped — one copy per cluster, shared by
every node — and therefore the entries that can diverge between clusters:
| Path | What it holds | Diverges as |
|---|---|---|
datacenter.cfg | Datacenter-wide options: migration, bwlimit, ha, crs, mac_prefix, max_workers, next-id, console, keyboard, tag-style | Different migration policy, different HA shutdown policy, different bandwidth defaults |
storage.cfg | Every storage definition | Same ID meaning different things; a storage present on one cluster only |
user.cfg | Users, groups, roles and ACLs | A leaver removed once; a role that exists on one cluster |
domains.cfg | Authentication realms | LDAP/AD/OIDC configured on one cluster and not the other |
firewall/cluster.fw | Cluster-wide firewall rules, security groups, IPsets, aliases | A rule added during an incident on one cluster only |
ha/resources.cfg, ha/rules.cfg | HA resources and scheduling constraints | Not comparable directly — resources are per guest — but rule style should match |
sdn/* | Zones, VNets, subnets, controllers, IPAM | The same VNet name on two different networks |
status.cfg | External metrics servers | One cluster reporting to Graphite/InfluxDB and one not |
vzdump.cron and the job configuration | Cluster-wide backup schedule | Different windows, different retention |
ceph.conf | Ceph configuration, where hyper-converged | Different tunables, different pool defaults |
corosync.conf | Cluster membership and links | Necessarily different; only the shape should match |
virtual-guest/cpu-models.conf | Custom CPU models | A model referenced by a guest that only exists on one cluster |
priv/token.cfg, priv/tfa.cfg, priv/shadow.cfg | Token secrets, TFA, local passwords | Necessarily different; never copy these |
Node-scoped entries — nodes/<NAME>/config, the per-node certificates, and the
guest configuration files under nodes/<NAME>/qemu-server/ and
nodes/<NAME>/lxc/ — are a different problem and mostly not one you want to
make identical.
The naming contract
Before automating anything, agree on names. Three of them do disproportionate work, because cross-cluster operations are name-based and nothing validates meaning.
Storage IDs mean the same thing everywhere
--target-storage local-zfs:ceph-vm is a string-to-string map, and every
migration, every script and every runbook that crosses the boundary carries one.
If fast-nvme is a local ZFS pool on cluster A and an iSCSI LUN on cluster B,
every map has to be written by someone who knows that, and it will eventually be
written by someone who does not.
The contract worth adopting is that a storage ID encodes capability and locality, not hardware:
| Good ID | Means, on every cluster |
|---|---|
local-zfs | Node-local ZFS, not shared, no live migration without disk copy |
shared-rbd | Ceph RBD, shared, snapshots, thin |
shared-lvm | LVM over SAN, shared, volume-chain snapshots (PVE 9) |
nfs-backup | File-level, backup content type |
pbs-primary | The Proxmox Backup Server datastore |
Then --target-storage shared-rbd:shared-rbd is a sentence that means something
on both clusters, and 1 — the shorthand that maps each source storage to
itself — becomes safe rather than a gamble.
Bridge and VNet names mean the same network
Same argument, worse consequence, because a wrong storage map fails and a wrong
bridge map succeeds. Part XXV describes the failure in full: vmbr0 exists on
both clusters, means production on one and out-of-band management on the other,
and the guest arrives on the wrong VLAN with a static address it still believes
in.
Write down, per cluster, what each vmbrN and each VNet is attached to, and
treat that document as part of the cluster’s definition. If the two clusters
cannot agree on names, agree on a mapping table instead and put it in the
runbook — but agree on it once, centrally, not per migration.
VMID ranges do not overlap
This one is free and almost nobody does it. datacenter.cfg has a next-id
option with lower and upper bounds — default 100 and 1000000 — controlling
the range from which the GUI and API pick the next free ID.
pvesh set /cluster/options --next-id lower=100,upper=99999Cluster A takes 100–99999, cluster B takes 100000–199999, cluster C the next band. What that buys:
- A VMID is globally unique across the estate, so
--target-vmidcan always keep the original number and a remote migration never collides. - A guest ID identifies its home cluster on sight, which shortens every incident conversation and every ticket.
- PBS backup groups, which are keyed by guest type and ID, stay unambiguous across a shared backup server.
Retrofitting is harder than starting this way, but even retrofitting only the allocation range is worth it: new guests stop colliding immediately, and the existing overlap becomes a finite known list rather than a growing one.
Detecting drift
You cannot diff two clusters by reading files, for the reason in the callout
above. You can diff them through the API, and pvesh returns JSON on request.
CLUSTER="site-a"
OUT="/var/lib/pve-baseline/${CLUSTER}/$(date +%F)"
mkdir -p "$OUT"
pvesh get /cluster/options --output-format json > "$OUT/options.json"
pvesh get /storage --output-format json > "$OUT/storage.json"
pvesh get /access/roles --output-format json > "$OUT/roles.json"
pvesh get /access/users --output-format json > "$OUT/users.json"
pvesh get /access/groups --output-format json > "$OUT/groups.json"
pvesh get /access/acl --output-format json > "$OUT/acl.json"
pvesh get /access/domains --output-format json > "$OUT/domains.json"
pvesh get /cluster/firewall/groups --output-format json > "$OUT/fw-groups.json"
pvesh get /cluster/firewall/rules --output-format json > "$OUT/fw-rules.json"
pvesh get /cluster/firewall/options --output-format json > "$OUT/fw-options.json"
pvesh get /cluster/sdn/zones --output-format json > "$OUT/sdn-zones.json"
pvesh get /cluster/sdn/vnets --output-format json > "$OUT/sdn-vnets.json"
pvesh get /cluster/backup --output-format json > "$OUT/backup-jobs.json"
pvesh get /pools --output-format json > "$OUT/pools.json"
ls -l "$OUT"Then compare. The only trick is that these lists are unordered and carry per-cluster noise (digests, node names, counts), so normalise before diffing.
A="/var/lib/pve-baseline/site-a/2026-08-12/storage.json"
B="/var/lib/pve-baseline/site-b/2026-08-12/storage.json"
norm() {
jq -S 'map({storage, type, content, shared, nodes, disable})
| sort_by(.storage)' "$1"
}
diff -u <(norm "$A") <(norm "$B") || true$ diff -u <(norm "$A") <(norm "$B") {
"content": "images,rootdir",
"disable": null,
- "nodes": null,
+ "nodes": "pve-b1,pve-b2",
"shared": 1,
"storage": "shared-rbd",
"type": "rbd"
},
{
"content": "backup",
- "shared": 1,
- "storage": "pbs-primary",
- "type": "pbs"
+ "shared": 1,
+ "storage": "pbs-secondary",
+ "type": "pbs"
}Version skew
Two different rules, frequently conflated.
Inside a cluster, versions must match. The Cluster Manager chapter is explicit that all nodes should run the same version for HA deployments, and the mixed-version state is a transient condition during a rolling upgrade, not a configuration.
Across clusters, versions do not have to match, and insisting they do throws away one of the main reasons for splitting: being able to validate an upgrade somewhere before production meets it.
What you need instead is a written skew policy. A workable one:
- One minor version of skew, maximum, and only in one direction. The validation cluster runs ahead; production follows within a defined window. Nothing runs behind by more than one minor release.
- Remote migration only between clusters at the same version, until you have tested otherwise on guests that do not matter. The feature is experimental and cross-version behaviour is not documented.
- A hard stop on major versions. Do not leave two clusters on different major releases for longer than the migration project takes.
- The skew is recorded and has an owner and an end date, or it is not a policy, it is a backlog.
pveversion -v
pvesh get /nodes --output-format json | jq -r '.[].node' | while read -r n; do
printf '%s\t' "$n"
pvesh get "/nodes/$n/version" --output-format json | jq -r '"\(.release) \(.version)"'
doneWhat to automate, and in what order
Not everything deserves a playbook. Order by blast radius of getting it wrong, and by how often it changes.
Automate first — high value, low risk, changes rarely, and the drift hurts:
- Repositories and pinning. Two clusters on different repository sets is how version skew becomes accidental rather than chosen.
datacenter.cfgoptions, especiallymigration,bwlimit,ha(shutdown_policy) andnext-id.- Roles and ACL structure. The role definitions and the shape of the permission tree, not the individual user list.
- Storage definitions, once the naming contract exists.
- Firewall security groups, IPsets and aliases. Rules that reference them can stay hand-managed; the objects they reference should not.
- Notification targets and matchers, so an alert is not loud on one cluster and silent on the other.
Automate later, carefully — SDN objects (an apply is disruptive), backup jobs (windows genuinely differ per site), HA rules (per-guest and rarely transferable).
Do not automate — anything under priv/, certificates, corosync.conf,
node network configuration. These are per-cluster by nature, and a playbook that
templates them is a playbook that will one day render cluster A’s identity onto
cluster B.
Part XX covers the tooling: xx-cli-ansible-proxmox for the fleet mechanics and
xx-automation-idempotency for the discipline that stops a playbook from being
a second source of drift.
Key takeaways
- Cluster-scoped configuration lives in
/etc/pve:datacenter.cfg,storage.cfg,user.cfg,domains.cfg,firewall/cluster.fw,ha/*,sdn/*,status.cfg,vzdump.cron,ceph.conf,corosync.conf. Every one of them can diverge. - Never copy
/etc/pvebetween clusters. It carries each cluster’s CA key, ticket authkey, node certificates, token secrets and corosync membership. The unit of synchronisation is the API call, not the file. - Storage IDs should encode capability and locality, not hardware, so that a
storage map is meaningful on both sides — and so the
1shorthand is safe. - Bridge and VNet names must mean the same network on both clusters, or the mapping table must be written down centrally. A wrong bridge map succeeds.
- Allocate non-overlapping VMID ranges with
pvesh set /cluster/options --next-id lower=,upper=. It makes IDs globally unique, keeps--target-vmidusable, and disambiguates PBS backup groups. - Build the drift report from
pvesh ... --output-format json, normalise withjq, diff peer-to-peer and against yesterday, and keep an allow-list of intended differences so the report is empty when the estate is right. - Versions must match inside a cluster; across clusters a written skew policy is better than enforced uniformity, because being able to validate an upgrade is one of the reasons the second cluster exists.
- Automate repositories, datacenter options, roles, storage and firewall
objects first. Never automate
priv/, certificates,corosync.confor node networking.
Knowledge check
Knowledge check · 5 questions
Q1. An engineer proposes keeping two clusters in agreement by rsyncing /etc/pve from the primary to the secondary nightly. What is the objection?
Q2. What does allocating non-overlapping VMID ranges per cluster with the next-id datacenter option actually buy?
Q3. Which of these should be automated early when keeping two clusters aligned? Select all that apply.
Q4. Two clusters in the same estate must always run the same Proxmox VE version, for the same reason nodes inside one cluster must.
Q5. A nightly drift report between two clusters has printed the same twelve differences every night for four months. What is the fix?
Passing score: 75%. Answers are checked in this browser.