Cloudbuilder is a tool for managing VM templates in Proxmox. It automates downloading cloud OS images, customizing them, and importing them as Proxmox VM templates.
- Template Management: Download, customize, and import VM templates
- File Import: Copy files from host into template images during build
- Fast Boot: Caps the network-online wait at 10s, so VMs don't stall 1–2 minutes on boot
- Standalone Mode: Build templates locally without Proxmox (auto-detected or via
--build-only) - Import Pre-built Images: Import existing qcow2/img files from local paths or URLs
- Minimal Downtime: Updates templates with minimal unavailability in Proxmox
- Template Filtering: Process only specific templates with
--onlyand--except - Status Reporting: Show template status without making changes
- Metadata Tracking: Track build dates, update dates, and VMIDs
- Automatic Storage Detection: Automatically selects compatible Proxmox storage
- Shell Completions: Tab completion for bash and zsh
- Update checks: Nightly checks report available updates; applying an update is manual and reviewed
- Python 3.8+
- libguestfs-tools (for virt-customize)
For Proxmox integration (optional):
- Proxmox VE 6.0+
- Proxmox CLI tools (qm, pvesh)
Without Proxmox, cloudbuilder runs in standalone mode and builds templates locally.
# Clone the repository
cd /opt && git clone https://github.com/iandk/cloudbuilder.git
cd cloudbuilder
# Install dependencies
pip install -r requirements.txt
# Make script executable
chmod +x cloudbuilder.py
# Create symlink
ln -s /opt/cloudbuilder/cloudbuilder.py /usr/local/bin/cloudbuilderCreate a templates.json file in the current directory:
{
"debian-12": {
"image_url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
"image_sha512": "6c2607f1846ee86040830c87d0b723f0967da3e884ea4673d9db4aa8eee13a4b7c663524bfa42082c16fc6919f3aa1bf425c004d07ff06c53a319ad0c42647bb",
"install_packages": ["qemu-guest-agent", "curl", "git"],
"update_packages": true,
"run_commands": [
"systemctl enable qemu-guest-agent",
"rm -f /etc/ssh/ssh_host_*",
"systemctl enable ssh",
"rm -rf /var/lib/cloud/instance /var/lib/cloud/data",
"truncate -s 0 /etc/machine-id"
],
"ssh_password_auth": false,
"ssh_root_login": false
}
}Use copy_files to embed files into the template image. Files are copied after package installation but before run_commands, so they're available for commands to use.
{
"my-template": {
"image_url": "https://example.com/image.qcow2",
"image_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"install_packages": ["nginx"],
"copy_files": {
"files/nginx.conf": "/etc/nginx/",
"files/scripts/setup.sh": "/usr/local/bin/"
},
"run_commands": [
"chmod +x /usr/local/bin/setup.sh",
"/usr/local/bin/setup.sh"
]
}
}Replace the all-zero example digest with the SHA-256 published by the image vendor; Cloudbuilder rejects a missing or mismatched digest.
- Keys: Local file paths (relative to cloudbuilder directory or absolute)
- Values: Destination directories inside the image (must end with
/)- Correct:
"/etc/nginx/"- copies file into /etc/nginx/ - Wrong:
"/etc/nginx/nginx.conf"- this will fail (not a directory)
- Correct:
- Recommended: Store files in the
files/directory in the cloudbuilder root
Cloud images block boot behind a *-wait-online unit until the network is "configured". The stock timeouts are 120s (systemd-networkd-wait-online, on Debian/Ubuntu) and 60s (NetworkManager-wait-online, on RHEL-family/openSUSE). A VM with an interface that never gets an address — most commonly a second NIC with no DHCP server on it — therefore stalls for one to two minutes on every boot before carrying on regardless.
Every systemd-based template pulls in the network-wait component, which caps that wait at 10 seconds.
This is a ceiling, not a delay: a healthy VM brings its network up in 1–3s and boots exactly as before. The cap only applies in the case that was already broken. On multi-NIC VMs, --any additionally lets systemd-networkd proceed as soon as the primary interface is up, instead of failing the unit and leaving the system in a degraded state.
To change the timeout, edit the single value in the network-wait component in templates.json:
"network-wait": {
"copy_files": { "files/network-wait-online-setup": "/tmp/" },
"run_commands": [
"sh /tmp/network-wait-online-setup 10",
"rm -f /tmp/network-wait-online-setup"
]
}Alpine is not affected (OpenRC, no network-online.target).
| Option | Description |
|---|---|
image_url |
URL to download the cloud image |
image_sha256 |
Vendor-published SHA-256 of the downloaded artifact (see digest requirement below) |
image_sha512 |
Vendor-published SHA-512 of the downloaded artifact (see digest requirement below) |
image_checksum_url |
Vendor checksum manifest (SHA256SUMS, SHA512SUMS, *.sha256) resolved at build time — for rolling URLs |
install_packages |
List of packages to install |
update_packages |
Whether virt-customize updates the base image before installing configured packages |
copy_files |
Object mapping local file paths to destination directories in the image |
run_commands |
Custom commands to run during customization |
ssh_password_auth |
Enable (true) or explicitly disable (false, the default) SSH password authentication |
ssh_root_login |
Allow (true) or explicitly deny (false, the default) root login via SSH |
selinux_relabel |
Run and verify a final guest-side SELinux relabel; inherited by the bundled RHEL/Fedora templates |
Set update_packages: true when a pinned image can lag its live package
repositories. virt-customize performs that update before install_packages,
which avoids mixed-version dependency conflicts. run_commands execute after
package installation, so a manual upgrade there is too late to repair such a
conflict. Templates may leave this false when the vendor artifact and its
repositories are known to be in sync and a faster, more reproducible build is
preferred.
For RHEL-family images, Cloudbuilder captures a vendor BLS kernel command line before any package operation and validates every BLS entry afterward. This is required because kernel installation inside libguestfs can otherwise copy the customization appliance's temporary root UUID and guest-only arguments into the template, producing an image that falls into dracut emergency mode when cloned.
Those templates also relabel the completed filesystem with the guest's own
setfiles utility and verify both PID 1's context and the absence of
/.autorelabel before publication. This is intentional: Debian's libguestfs
appliance may lack its optional SELinux relabel feature and silently defer the
work to first boot. A deferred relabel is not safe when a package update has
already replaced systemd without a context, because enforcing mode can freeze
the guest before the relabel service or Cloud-Init starts.
Cloudbuilder's bundled templates are for supported upstream releases only. EOL templates are removed instead of being silently redirected to archives: archive builds no longer receive security updates and are unsuitable as default Proxmox templates. Keep a separately reviewed local configuration if a legacy image is required for a migration.
Upstream images are accepted only when they match a vendor digest. Every template must supply one of two sources, and a template with neither is rejected before anything is downloaded:
image_sha256/image_sha512— a static pin. Correct for a versionedimage_url(a dated or release-numbered filename), where the bytes behind the URL never change.image_checksum_url— the vendor's checksum manifest (SHA256SUMS,SHA512SUMS, or a per-file*.sha256), fetched over HTTPS at build time and matched to the image's filename. Required for a rollingimage_url(.../current/,.../latest/,.../release/, or a fixed filename the vendor rebuilds in place).
Either way the digest describes the bytes downloaded from image_url (the
compressed archive when the URL ends in .xz), and a mismatch fails closed.
A static pin on a rolling URL is a configuration error, not a safety net: it
breaks the build on the vendor's next republish, and re-pinning by hand only
lasts until the one after that. Switching such a URL to a dated one is not a
fix either — vendors prune old directories, turning the mismatch into a 404.
The bundled ubuntu-24-04, ubuntu-26-04, debian-12, debian-13, and
opensuse-leap-16 templates therefore use image_checksum_url; the remaining
templates use versioned URLs with static pins. tests/test_template_security.py
enforces that pairing.
The algorithm is derived from the digest length in the manifest entry (64 hex characters = SHA-256, 128 = SHA-512), never from the manifest's filename, so a vendor renaming the file cannot select the wrong hash.
Scripts under files/ and the configuration security policy have tests:
bash tests/test-network-wait-online.sh
python3 -m unittest discover -s tests -vThey re-exec inside a Debian container (requires Docker) so the guest scripts are exercised against the shell and sed they will actually meet in a real image.
Cloudbuilder serializes mutating runs with a lock in the template directory; wait for an active run rather than starting a second update. This is intentional because concurrent runs could otherwise race shared metadata and Proxmox VMID allocation.
When updating or rebuilding an existing Proxmox template, Cloudbuilder now preserves its VMID and treats Cloud-Init as mandatory. It first checks linked clones, creates a full-clone rollback template and imports a validated staged candidate. Only then does it replace the original VMID. If cutover fails it restores the rollback copy; if rollback itself fails, the staging VMIDs are left in place and logged for manual recovery. A failed/unknown linked-clone check always blocks replacement rather than assuming it is safe.
Imported templates set Proxmox ciupgrade=0. Cloudbuilder already updates and
verifies packages while building the image; performing another implicit,
unpinned upgrade during every clone's first boot makes provisioning depend on
package-mirror availability and can fail Cloud-Init on an offline network.
Cloud-Init itself remains mandatory and handles identity, hostname, users,
networking, and keys normally. Every bundled template explicitly installs the
guest package, explicitly enables its systemd/OpenRC boot lifecycle, and fails
before import if a read-only image check cannot find both the executable and
the enabled services. The same read-only check applies to manifest imports.
# Check and build missing templates
./cloudbuilder.py
# Show status without making changes
./cloudbuilder.py --status
# Update existing templates
./cloudbuilder.py --update
# Rebuild templates from scratch
./cloudbuilder.py --rebuild
# Update cloudbuilder itself
./cloudbuilder.py --self-update
# Check for available updates without changing the host
./cloudbuilder.py --check-self-update
# Force update (discards local changes if any)
./cloudbuilder.py --self-update --force
# Set up shell tab completions
./cloudbuilder.py --setup-completionsCloudbuilder can run on systems without Proxmox VE installed. It automatically detects the environment and adjusts behavior accordingly.
# On a non-Proxmox system, standalone mode is automatic
./cloudbuilder.py --status
# WARNING Proxmox VE not detected - running in standalone mode
# Build templates locally (downloads and customizes images)
./cloudbuilder.py --rebuild --only debian-12
# Images are saved to /var/lib/cloudbuilder/templates/
# On a Proxmox system, explicitly skip import
./cloudbuilder.py --build-only --only debian-12
# INFO Standalone mode enabled - skipping Proxmox importIn standalone mode:
- Templates are downloaded and customized locally
- Images are saved to the template directory as qcow2 files
- No Proxmox CLI tools (pvesh, qm) are required
- Status table shows only local columns (no Proxmox/VMID)
# Process only specific templates
./cloudbuilder.py --only debian-12,ubuntu-24-04
# Process all templates except specified ones
./cloudbuilder.py --except fedora-40,fedora-41
# Combine with other flags
./cloudbuilder.py --update --only debian-12Import templates from pre-built qcow2/img files without going through the customization process:
# Import templates from a local manifest file
./cloudbuilder.py --import-manifest imports.json
# Import templates from a remote manifest (URL)
./cloudbuilder.py --import-manifest http://example.com/imports.json
# Import only specific templates from manifest
./cloudbuilder.py --import-manifest imports.json --only rocky-9,centos-stream
# Force re-import templates that already exist (replaces them)
./cloudbuilder.py --import-manifest imports.json --forceManifest file format (imports.json):
{
"rocky-9": {
"source": "rocky-9.qcow2",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"centos-stream": {
"source": "centos-stream.qcow2",
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"vmid": 9050
},
"custom-debian": {
"source": "custom-debian.qcow2",
"sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"vmid": 9060,
"customize": true
}
}Manifest fields:
| Field | Required | Description |
|---|---|---|
source |
Yes | Filename, relative path, or full URL to the qcow2/img file. Relative sources resolve against the manifest location. |
sha256 |
Yes | SHA-256 of the exact source artifact. A mismatch aborts before the local image or Proxmox template is replaced. |
vmid |
No | Specific VMID to assign (auto-assigns if omitted) |
customize |
No | Run virt-customize using config from templates.json (default: false) |
Generating a manifest from a directory:
# Generate manifest from template directory (default location)
./cloudbuilder.py --generate-manifest
# Generate manifest from a specific directory
./cloudbuilder.py --generate-manifest /path/to/images/
# Specify output file
./cloudbuilder.py --generate-manifest -o my-manifest.json
# Output to stdout (for piping)
./cloudbuilder.py --generate-manifest -o -
# Serve the directory with a simple HTTP server
cd /var/lib/cloudbuilder/templates && python3 -m http.server 8080
# Then import on another host - sources are resolved relative to manifest URL
./cloudbuilder.py --import-manifest http://myserver:8080/imports.jsonGenerated manifests calculate sha256 for every image. For remote distribution,
publish the manifest through a trusted release channel (for example, a reviewed
GitHub Release) so the manifest and its digests are not controlled by the same
untrusted server as the image bytes.
# Specify custom paths
./cloudbuilder.py --config /path/to/templates.json --template-dir /path/to/templates --temp-dir /path/to/tmp
# Specify Proxmox storage (overrides automatic detection)
./cloudbuilder.py --storage local-zfs
# Set minimum VMID
./cloudbuilder.py --min-vmid 9000| Option | Description |
|---|---|
--status |
Show template status without making changes |
--update |
Update existing templates |
--rebuild |
Rebuild templates from scratch |
--build-only |
Build templates locally without importing to Proxmox (standalone mode) |
--self-update |
Manually update cloudbuilder from git after reviewing the intended release |
--check-self-update |
Fetch and report available updates without changing the host |
--setup-completions |
Set up shell autocompletions (bash/zsh) |
--import-manifest FILE/URL |
Import pre-built images from a manifest file or URL (JSON) |
--generate-manifest [DIR] |
Generate manifest JSON from a directory of qcow2/img files (default: template directory) |
--base-url URL |
Optional: prefix sources with full URL in generated manifest (by default, outputs just filenames which are resolved relative to manifest URL on import) |
-o, --output FILE |
Output file for generated manifest (default: imports.json, use '-' for stdout) |
--force |
Force operation: for imports, removes and re-imports existing templates; for --self-update, discards local changes |
--use-local-base |
Recovery only: permit an old local image when upstream acquisition fails |
--only LIST |
Process only specific templates (comma-separated) |
--except LIST |
Process all templates except specified ones (comma-separated) |
--config PATH |
Path to templates configuration file (default: templates.json) |
--storage NAME |
Storage location in Proxmox (if not specified, will auto-detect) |
--template-dir PATH |
Directory for storing templates (default: /var/lib/cloudbuilder/templates) |
--temp-dir PATH |
Base directory for temporary files (default: /var/lib/cloudbuilder/tmp) |
--log-dir PATH |
Directory for log files (default: /var/log/cloudbuilder) |
--min-vmid NUM |
Minimum VMID for templates (default: 9000) |
Cloudbuilder automatically detects and selects a compatible Proxmox storage for VM templates. It:
- Scans available storages in your Proxmox environment
- Identifies storages that support VM images (content types "images" or "rootdir")
- Selects the first compatible storage found
- Logs which storage was selected
You can override automatic detection by specifying a storage with --storage.
Cloudbuilder maintains metadata about templates in two places:
metadata.jsonin the template directory- Template notes in Proxmox (accessible via the web UI)
Example metadata.json:
{
"debian-12": {
"build_date": "2025-02-24 16:31:08",
"last_update": "2025-02-24 16:52:59",
"vmid": 9000
}
}cloudbuilder.py: Main entry point and orchestrationtemplate.py: Template models and managementproxmox.py: Proxmox integrationutils.py: Utilities and helpers
- Default mode: Ensures templates exist locally and in Proxmox
- Update mode: Updates existing templates while maintaining VMIDs
- Rebuild mode: Rebuilds templates from scratch while preserving VMIDs
- Minimal downtime: Templates are built locally first, then replaced in Proxmox one by one
Templates are prepared for cloning with these critical steps (handled in run_commands):
- SSH Host Keys: Deleted during build, regenerated on first VM boot
- Machine ID: Truncated (not deleted) so each VM gets a unique ID
- Cloud-init State: Cleared so cloud-init runs fresh on new VMs
See .claude/CLAUDE.md for stable project-wide
development guidance. Exact behavior remains documented in the source and
user-facing sections of this README.
You can test built templates locally using QEMU without importing to Proxmox. This is useful for verifying customizations before deployment.
# Set the template to test
IMAGE_FILE="/var/lib/cloudbuilder/templates/freebsd-15.qcow2"
# Create cloud-init data
mkdir -p /tmp/cidata
echo "instance-id: test-$(date +%s)" > /tmp/cidata/meta-data
cat > /tmp/cidata/user-data << 'EOF'
#cloud-config
hostname: freebsd-15
user: root
password: cloudbuilder
chpasswd: {expire: False}
ssh_pwauth: True
EOF
# Generate cloud-init ISO
genisoimage -output /tmp/cidata.iso -volid cidata -joliet -rock /tmp/cidata/
# Boot the template (console mode)
qemu-system-x86_64 -m 1024 \
-hda "$IMAGE_FILE" \
-cdrom /tmp/cidata.iso \
-nographic -serial mon:stdioNotes:
- Login with
root/cloudbuilder(or the default cloud user for the distro) - Press
Ctrl+AthenXto exit QEMU - The image is modified during boot - use a copy if you want to preserve the original
# Boot with port forwarding for SSH
qemu-system-x86_64 -m 1024 \
-hda "$IMAGE_FILE" \
-cdrom /tmp/cidata.iso \
-nographic -serial mon:stdio \
-net nic -net user,hostfwd=tcp::2222-:22
# In another terminal, SSH in:
ssh -p 2222 root@localhostqemu-system-x86_64(from qemu-system-x86 package)genisoimage(from genisoimage package)
Logs are written to both the console and cloudbuilder.log in the log directory.