{"id":39,"date":"2026-11-09T18:04:19","date_gmt":"2026-11-09T16:04:19","guid":{"rendered":"https:\/\/www.cojan.ro\/blog\/?p=39"},"modified":"2026-11-09T18:04:19","modified_gmt":"2026-11-09T16:04:19","slug":"why-i-built-pmxspan-proxmox-reinstalls","status":"publish","type":"post","link":"https:\/\/www.cojan.ro\/blog\/why-i-built-pmxspan-proxmox-reinstalls\/","title":{"rendered":"Reinstalling a Proxmox VM Shouldn&#8217;t Be a Manual Job: Why I Built PMXSpan"},"content":{"rendered":"\n<h1 class=\"wp-block-heading\"><strong>Reinstalling a Proxmox VM Shouldn&#8217;t Be a Manual Job<\/strong><\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">I have run Proxmox VE since 2009.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Over those years, one job never got easier for me: reinstalling a guest.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Somebody wants a clean OS on an existing VM. Same VMID. Same IP. Same MAC, because something upstream cares about it. Fresh Debian, please.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The manual version looks like this:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Find a current cloud image for that OS.<\/li>\n\n\n\n<li>Download it.<\/li>\n\n\n\n<li>Import it as the boot disk.<\/li>\n\n\n\n<li>Find a free IP address.<\/li>\n\n\n\n<li>Set up the network again.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Then the next guest. Then the next one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of these steps is hard. Together, they are a reliable way to make a mistake at 23:00.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So I built <a href=\"https:\/\/github.com\/AGSQ11\/PMXSpan\">PMXSpan<\/a>, a native desktop app for managing Proxmox VE clusters. I built it mostly for three jobs:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>automated VM and container reinstalls<\/li>\n\n\n\n<li>current cloud images, pulled directly from the vendors<\/li>\n\n\n\n<li>automatic IP assignment from its own IPAM<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">It does more than that. But those three are why it exists.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Templates Get Old<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The usual answer is a VM template. Build it once, clone it forever.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The problem is &#8220;forever&#8221;.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A template you built six months ago boots with six months of missing updates. The first thing you do on the &#8220;fresh&#8221; guest is patch it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I wanted every reinstall to start from the image the vendor publishes today.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>An OS Image Catalog That Points at the Vendor<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">PMXSpan has an OS image catalog. Each entry is a URL to the vendor&#8217;s own cloud image, for example Debian&#8217;s <code>latest<\/code> bookworm image on cloud.debian.org.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"648\" src=\"https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-1.png?resize=1024%2C648&#038;ssl=1\" alt=\"\" class=\"wp-image-43\" srcset=\"https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-1.png?resize=1024%2C648&amp;ssl=1 1024w, https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-1.png?resize=300%2C190&amp;ssl=1 300w, https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-1.png?resize=768%2C486&amp;ssl=1 768w, https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-1.png?w=1360&amp;ssl=1 1360w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">VM images in qcow2, raw, vmdk or ova format are downloaded to an <code>import<\/code> storage and imported as the boot disk. Container templates go to a <code>vztmpl<\/code> storage.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each entry also stores the image format, the OS type, the cloud-init user, an optional checksum and which nodes may use it. Custom cloud-init snippets stay on your computer. They are never exported or imported.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Config overrides exist, but they are deliberately narrow. CPU, memory, firmware and similar keys are accepted. Anything that touches disks, network, cloud-init, host access or container privileges is rejected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A catalog entry should not be a back door into your host.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Catalogs import and export as JSON, so the same list can follow you between machines.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Rebuild, but Keep the Identity<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the part I actually wanted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A rebuild replaces the guest&#8217;s disks with a fresh copy of a template or a catalog image. It keeps:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>the VMID<\/li>\n\n\n\n<li>the name<\/li>\n\n\n\n<li>the resources<\/li>\n\n\n\n<li>the NICs, with their MAC addresses<\/li>\n\n\n\n<li>the IP addresses<\/li>\n\n\n\n<li>the pool<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Anything that finds the guest by its VMID or IP keeps working: DNS records, monitoring checks, upstream firewall rules.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"648\" src=\"https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-2.png?resize=1024%2C648&#038;ssl=1\" alt=\"\" class=\"wp-image-44\" srcset=\"https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-2.png?resize=1024%2C648&amp;ssl=1 1024w, https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-2.png?resize=300%2C190&amp;ssl=1 300w, https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-2.png?resize=768%2C486&amp;ssl=1 768w, https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-2.png?w=1360&amp;ssl=1 1360w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">On the Manage tab, you pick a template VM or a catalog image, set a new password (or generate one), paste SSH keys, and decide two things: start the guest afterwards, and take a backup first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two details matter more than the button.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The image is downloaded <strong>before<\/strong> anything is touched. A failed download leaves the guest exactly as it was.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And the rebuild wipes the guest&#8217;s disks. PMXSpan says so in capitals, and the backup option sits right next to the button.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I&#8217;d rather have a tool that is blunt about destruction than one that is polite about it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>IPAM That Refuses to Guess<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The third job was IP addresses.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">PMXSpan keeps local IPv4 and IPv6 pools. The create wizard and rebuilds take addresses from them. No more looking for a free IP in a spreadsheet.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"648\" src=\"https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-3.png?resize=1024%2C648&#038;ssl=1\" alt=\"\" class=\"wp-image-45\" srcset=\"https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-3.png?resize=1024%2C648&amp;ssl=1 1024w, https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-3.png?resize=300%2C190&amp;ssl=1 300w, https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-3.png?resize=768%2C486&amp;ssl=1 768w, https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/image-3.png?w=1360&amp;ssl=1 1360w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">One rule I cared about: an explicitly requested IP is assigned exactly, or the operation fails. It is never silently replaced with another one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Silent substitution is how a guest ends up on an address nobody documented.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Assignments<\/strong> list the IP, server, VMID, interface, hostname and when it was assigned. You can also reserve addresses for things outside Proxmox, like a switch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>History<\/strong> logs every address that was assigned, adopted, released, made main, reserved or unreserved, with who did it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Drift<\/strong> compares IPAM with the live cluster. It finds:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>addresses still held for guests that are gone<\/li>\n\n\n\n<li>static addresses guests use that IPAM doesn&#8217;t know about<\/li>\n\n\n\n<li>main addresses that differ<\/li>\n\n\n\n<li>guests created by PMXSpan that have no IPAM record<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Most of those come with a one-click fix: Adopt, Apply IPAM or Release.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An IPAM is only useful while it matches reality. Drift is how you find out when it stopped.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>A Desktop App, on Purpose<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">PMXSpan is a native desktop app written in Rust, using egui and tokio. It talks directly to the Proxmox VE API.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No web server.<br>No database server.<br>No agents on your nodes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing new to install on the cluster, and nothing new to expose to the internet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It does need credentials, so those got some attention:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>API token or password login, with TOTP two-factor support<\/li>\n\n\n\n<li>TLS options, including certificate fingerprint pinning<\/li>\n\n\n\n<li>a local vault encrypted with Argon2id (64 MiB) and XChaCha20-Poly1305 under a master password, locked after 30 idle minutes by default<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">For scheduled work there is a headless mode (<code>--run-schedules<\/code>) that runs without the window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The repository also ships a mock Proxmox API (<code>tools\/mock_pve.py<\/code>), so you can click through the interface without pointing it at a real cluster.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>What Else Is in There<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Managing several clusters from one window pulled in the rest:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>multiple clusters and server profiles<\/li>\n\n\n\n<li>a VM and container create wizard with auto-placement and cloud-init<\/li>\n\n\n\n<li>backup jobs and Proxmox Backup Server file restore<\/li>\n\n\n\n<li>snapshot schedules, timed power actions and usage alerts<\/li>\n\n\n\n<li>users, tokens, pools, ACLs, a tenant wizard and per-pool limits<\/li>\n\n\n\n<li>noVNC and xterm.js consoles, plus SPICE through virt-viewer<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Where It Stands<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">PMXSpan is at version 0.6.0. It is not finished.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Not included yet: reverse DNS (PTR) management through external DNS backends.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The macOS build is not notarized yet, so macOS will warn you the first time you open it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>You Can Use It, Inspect It or Break It<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">PMXSpan is open source under the <strong>MIT license<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The repository is here: <strong><a href=\"https:\/\/github.com\/AGSQ11\/PMXSpan\">github.com\/AGSQ11\/PMXSpan<\/a><\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Builds are published through the project&#8217;s <a href=\"https:\/\/github.com\/AGSQ11\/PMXSpan\/releases\">GitHub Releases<\/a>:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Platform<\/th><th>Download<\/th><\/tr><\/thead><tbody><tr><td>Windows 10\/11 (x64)<\/td><td>Setup.exe installer or portable zip<\/td><\/tr><tr><td>Debian, Ubuntu, Mint<\/td><td>.deb package<\/td><\/tr><tr><td>Any Linux (x86_64)<\/td><td>AppImage (needs FUSE 2) or tar.gz<\/td><\/tr><tr><td>macOS 11+<\/td><td>.dmg (not notarized yet)<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Linux builds need glibc 2.31 or newer. Each release ships a <code>SHA256SUMS.txt<\/code>. Check it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To build from source, you need Rust 1.88 or newer:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cargo build --release<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you rebuild guests for a living, I want your feedback. Open an issue for anything that breaks, anything missing, and any cloud image PMXSpan chokes on.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>My Take<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Reinstalling a guest is not a hard problem.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It is a repetitive one, and repetitive work is where mistakes live.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I wanted the boring version. Pick the image. Keep the identity. Get the IP right. Know that a failed download leaves the old guest alone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That became PMXSpan.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Your guests stay normal Proxmox guests. Nothing runs on the nodes on PMXSpan&#8217;s behalf. Close the app, and the cluster doesn&#8217;t notice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Good infrastructure tooling should make itself useful.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It should not make itself indispensable.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Reinstalling a Proxmox guest by hand is five small steps and plenty of room for mistakes. I built PMXSpan to rebuild VMs and containers from fresh vendor images while keeping the VMID, MACs and IPs, with an IPAM that refuses to guess.<\/p>\n","protected":false},"author":1,"featured_media":46,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"advanced_seo_description":"","jetpack_seo_html_title":"","jetpack_seo_noindex":false,"jetpack_seo_schema_type":"","_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_hide_featured_image":false,"_jetpack_featured_image_duplicate_dismissed":0,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[2],"tags":[6,9,4,11,7,8],"class_list":["post-39","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-field-notes","tag-ipam","tag-kvm","tag-proxmox","tag-proxmox-ve","tag-reinstall-os","tag-virtualization"],"jetpack_sharing_enabled":true,"jetpack_featured_media_url":"https:\/\/i0.wp.com\/www.cojan.ro\/wordpress\/wp-content\/uploads\/2026\/11\/dashboard1.png?fit=1360%2C860&ssl=1","_links":{"self":[{"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/posts\/39","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/comments?post=39"}],"version-history":[{"count":1,"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/posts\/39\/revisions"}],"predecessor-version":[{"id":47,"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/posts\/39\/revisions\/47"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/media\/46"}],"wp:attachment":[{"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/media?parent=39"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/categories?post=39"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.cojan.ro\/blog\/wp-json\/wp\/v2\/tags?post=39"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}