Blog ·
Choosing a VMID range for a hosting business
A VMID is an identity, not a counter. What the range you pick decides: what your platform may create, what it may read, and what happens when it fills.
By Provibr
Every Proxmox install starts you at 100 and counts up. That works fine until the machine stops being yours alone: until there is a control panel creating guests on it, a colleague creating guests on it by hand, a template library, and a backup job that restores something at three in the morning.
At that point the VMID stops being a counter and becomes an identity, and the range you hand to your automation stops being a convenience and becomes a boundary. It is worth choosing deliberately, because two of the three things a range decides are invisible until they go wrong.
What Proxmox actually allows
The full space is wider than most people assume:
| Value | |
|---|---|
| Lowest VMID Proxmox accepts | 100 |
| Highest VMID Proxmox accepts | 999999999 |
| Typical manual usage | 100 to a few hundred |
Nine digits. That is not an invitation to use them all, but it does mean the usual worry, "will I run out", is the wrong worry. You will run out of a badly chosen range long before you run out of numbers.
Those two bounds are the only ones the hypervisor enforces. Everything else, the part that matters, is a decision you make.
A range is a permission boundary before it is an allocator
Here is the part that is easy to miss. If a platform is told "you allocate from 5000 to 8999", it has been told two things at once:
- What it may create. New guests get a number from that window and nowhere else.
- What is not yours. Everything outside that window was made by somebody else, and the platform has no business touching it.
The second one is worth writing on the wall. When we built the Proxmox integration, the vmid range in the integration settings is described in the code as exactly that: the platform allocates only inside [vmidRangeStart, vmidRangeEnd], so an integration cannot by construction reach a VM outside it. Whoever keeps their production VMs outside the range keeps them out of reach.
That framing changes how you pick the numbers. You are not choosing where your automation starts counting. You are drawing a line around the machines you are prepared to let a program delete.
The read side is a separate question, and it bites later
Creating is the obvious half. Reading is the half that surprised us.
Our agent polls the cluster once per round with a single GET /cluster/resources?type=vm, which touches no guest at all. But for every running guest in that answer it then made two more calls into the QEMU guest agent, network-get-interfaces and get-fsinfo, to learn addresses and disk usage. That list came straight out of the cluster query, unfiltered.
On a test hypervisor that also runs production workloads, that list included the production machines. The reads are harmless in the sense that nothing is written. They are not harmless in the sense that matters, because "read-only" is not an answer to the question why is your agent talking to the guest agent of a machine that is not yours.
The fix is that the same range now bounds the reads. If you are building this yourself, the lesson generalises: a boundary that only covers writes is half a boundary, and the read half is the one nobody notices is missing.
Where the range should live
We had to answer this once and it is worth stating the reasoning, because the obvious answer is wrong.
The obvious answer is to put the range next to the credentials, in whatever blob the platform pushes to the agent when the integration is created. It is one message, it arrives with everything else, done.
That fails on a restart. Credentials are pushed once; after a restart the agent rebuilds its integrations from its local vault, without a push. A boundary delivered on that path quietly disappears the first time the process restarts, and a boundary that silently stops applying is worse than no boundary, because nobody goes looking for it.
So the range travels in its own message, sent on every session handshake, carrying the full state. Two delivery paths, one source of truth. There is also a host-level environment variable as a fallback, and the order between them is deliberate:
| Source | When it wins |
|---|---|
| The platform's setting | Whenever it sent one |
| Host environment variable | Only when the platform said nothing |
| No limit at all | When neither said anything |
The tempting order is the reverse, on the theory that a belt fastened on the host is stronger. It produces a state nobody can see: an administrator who once set a variable silently keeps a boundary that no longer matches what the panel shows, and a customer who widens the range in the UI cannot work out why nothing changed.
One small detail with a large failure mode: an unreadable value is treated as "nobody", not as "everybody". Somebody who asks for a boundary and makes a typo gets no reads at all plus a log line saying why. The cost is cosmetic, an address and a disk figure missing in the panel. The alternative, falling back to "everything is allowed", is the classic allowlist failure: it fails open, silently, in exactly the case where somebody was trying to be careful.
Templates go outside the range. Always.
This one is a rule and not a preference, and it is the mistake that eats itself.
An allocator hands out numbers from the range. Put a template inside the range and one day the allocator reaches it, a new order is created on that number, and the template becomes a customer VM. The order after that clones from a machine that is no longer a template. So the validator refuses a Proxmox template VMID that falls inside the integration's range, and the allocator additionally treats every linked template as taken, in case somebody widened the range after linking one.
Outside the range, a template can live anywhere. The range bounds what the platform creates, not where source material is allowed to sit.
Never reuse a number, even when the machine is gone
The allocator hands out the lowest free VMID in the range, and "free" is stricter than you would expect: every VMID the integration has ever handed out counts as taken, including the ones belonging to deleted servers.
That looks wasteful for about five seconds, until you consider what reuse would mean. A late state event from the old VM lands on the new row. The audit history of two different servers runs together in one timeline. Somebody restores yesterday's backup onto a number that now belongs to another customer.
Interestingly, we make the opposite choice for IP addresses in the same product, and for a good reason: an address is an address, and never reusing one would permanently exhaust a /24 after 254 machines. A VMID is a name for a machine's history. Ranges are cheap; confusion is not.
There is a third category of "taken" that only shows up once you have more than one thing creating machines. A restore that is still running has claimed its target VMID but does not have a server row yet, so a second restore in the same minute would otherwise be handed the same "free" number. Anything that reserves a VMID before the row exists has to be counted.
What happens when the range fills
Two answers, and both should exist.
The friendly one is arithmetic: the allocator scans the range, finds nothing free, and returns a specific failure naming the start and end of the range. The customer reads "this range is full, from 5000 to 8999" rather than a hypervisor error four layers down.
The real one is the database. The unique constraint on (integration, external id) is the referee: two orders placed in the same instant can both see the same lowest free number, and exactly one insert survives. The loser retries, up to a small fixed number of attempts. Checking first and inserting second opens precisely the race the constraint closes, so the constraint is the mechanism and the friendly message is a courtesy on top of it.
Note what we do not use: Proxmox has a next-free-id endpoint, and it is the wrong tool here. It answers "what number is free on this cluster", which is a different question from "what number is free and inside the window that belongs to me". Ask the hypervisor and you will eventually be handed a number outside your own boundary.
So, practically
- Start high and leave the low numbers alone. 100 to a few hundred is where hand-made machines live on every Proxmox install, including yours in three years when you have forgotten this decision.
- Pick a range with an obvious shape. 5000 to 8999 reads as "ours" at a glance in the web UI. A range of 4000 numbers is four thousand machines on one integration.
- Put templates outside it, in their own block, far away.
- Write the range down where the platform can see it, not only in a host config file, and make sure it is re-sent rather than sent once.
- Check that reads are bounded too, not just creates and deletes.
The number itself does not matter much. What matters is that the boundary is one thing, visible in one place, applied to reads and writes alike, and that it fails closed when it cannot be read.
This came out of building the Proxmox integration for Provibr, where the agent runs inside the customer's own network and only ever connects outbound.