> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Proxmox + Cloud-Init — Spinning Up an Ubuntu VM in 30 Seconds
- URL: https://blog.sudowl.com/proxmox-cloud-init-vm-template-ubuntu/
- Published: 2026-04-23T11:00:00.000Z
- Updated: 2026-10-11T08:47:37.000Z
- Description: Build a reusable Ubuntu cloud-image template on Proxmox with cloud-init. Clone, set hostname, IP, SSH key, boot — full VM in under a minute. The setup I run on node1.
- Author: Lukas
- Tags: Linux, Ubuntu, Debian

Manually clicking through `Create VM` for the 50th time on the same Proxmox host gets old. Build a cloud-init template once, clone in a single command, get a fully provisioned Ubuntu VM in 30 seconds — hostname, IP, SSH key, no console interaction.

This is what I run on node1\. Five-minute setup, every VM after that is one command.

**TL;DR — once the template exists:**

```bash
qm clone 9000 110 --name web02 --full
qm set 110 --ipconfig0 ip=10.10.10.110/24,gw=10.10.10.1
qm set 110 --sshkeys ~/.ssh/authorized_keys
qm resize 110 scsi0 +30G
qm start 110

```

Wait 30 seconds, `ssh ubuntu@10.10.10.110`, you're in.

## Build the template once

Download the Ubuntu cloud image (24.04 LTS shown — substitute newer release as you like):

```bash
cd /var/lib/vz/template/iso
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img

```

Cloud images are pre-installed Ubuntu with cloud-init enabled — that's what makes the runtime configuration work. No installer, no console.

Create the template VM (ID 9000 — high enough to never collide with real VMs):

```bash
qm create 9000 --name ubuntu-2404-template --memory 2048 --cores 2 \
  --net0 virtio,bridge=vmbr0 --ostype l26 --machine q35 --bios ovmf
qm set 9000 --efidisk0 local-lvm:0,format=raw,efitype=4m,pre-enrolled-keys=1
qm importdisk 9000 noble-server-cloudimg-amd64.img local-lvm
qm set 9000 --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-9000-disk-1
qm set 9000 --ide2 local-lvm:cloudinit
qm set 9000 --boot c --bootdisk scsi0
qm set 9000 --serial0 socket --vga serial0
qm set 9000 --agent enabled=1
qm template 9000

```

The serial-console + qemu-agent lines matter. Without them, `qm start` hangs at boot waiting for graphical console initialization. Cloud images don't ship a graphical console.

`qm template 9000` converts to template — read-only, can be cloned but not started directly.

## Clone — the actual fast bit

Linked clone (fast, shares base disk — quick boot, can't migrate cleanly):

```bash
qm clone 9000 110 --name web02

```

Full clone (recommended, takes 10-20 seconds depending on disk):

```bash
qm clone 9000 110 --name web02 --full

```

Set the cloud-init config:

```bash
qm set 110 --ipconfig0 ip=10.10.10.110/24,gw=10.10.10.1 --nameserver 1.1.1.1
qm set 110 --sshkeys ~/.ssh/authorized_keys
qm set 110 --ciuser ubuntu --cipassword $(openssl rand -base64 12)
qm resize 110 scsi0 +30G
qm start 110

```

Cloud images default to a tiny 2.2 GB disk. `qm resize +30G` grows it; cloud-init expands the filesystem at first boot.

`--ipconfig0 ip=dhcp` if you'd rather DHCP. I prefer static — DHCP on a homelab gets messy when the lease database goes stale.

Wait 20-30 seconds for cloud-init to finish first-boot. Then:

```bash
ssh ubuntu@10.10.10.110

```

Default user is `ubuntu`. Cloud-init dropped your SSH key into `~ubuntu/.ssh/authorized_keys`. Sudo is passwordless for the `ubuntu` group.

## What cloud-init actually does

On first boot, cloud-init reads the cloud-init disk (the `--ide2 local-lvm:cloudinit` we attached) and runs configuration:

1. Sets hostname from `--name` (override with `--searchdomain` and `--ipconfig0`)
2. Configures network from `--ipconfig0`
3. Resizes the root partition + filesystem to fill the resized disk
4. Writes SSH keys to `~ubuntu/.ssh/authorized_keys`
5. Optionally sets `ubuntu` user password from `--cipassword`
6. Runs any custom user-data scripts you pass via `--cicustom`

Then it disables itself for subsequent boots (touches `/etc/cloud/cloud-init.disabled` style flags). Reboots are normal Ubuntu.

## Custom user-data — the hidden multiplier

`--cicustom` is where this stops being "fast clone" and starts being "fully provisioned". Drop a snippet at `/var/lib/vz/snippets/web-baseline.yaml`:

```yaml
#cloud-config
packages:
  - nginx
  - ufw
  - fail2ban
runcmd:
  - ufw allow 22/tcp
  - ufw allow 80/tcp
  - ufw allow 443/tcp
  - ufw --force enable
  - systemctl enable --now nginx
  - timedatectl set-timezone Europe/Berlin
write_files:
  - path: /etc/ssh/sshd_config.d/hardening.conf
    content: |
      PermitRootLogin no
      PasswordAuthentication no
      ChallengeResponseAuthentication no

```

Then on clone:

```bash
qm set 110 --cicustom "user=local:snippets/web-baseline.yaml"

```

The new VM boots with nginx, UFW configured, [SSH hardened](/ssh-hardening-ubuntu-checklist/), timezone set. Total elapsed: 45 seconds. Every web VM I provision uses this baseline.

## A wrapper script

I have this saved as `/root/bin/newvm.sh`:

```bash
#!/bin/bash
set -e
ID=$1
NAME=$2
IP=$3
qm clone 9000 $ID --name $NAME --full
qm set $ID --ipconfig0 ip=$IP/24,gw=10.10.10.1 --nameserver 1.1.1.1
qm set $ID --sshkeys /root/.ssh/authorized_keys
qm set $ID --cicustom "user=local:snippets/web-baseline.yaml"
qm resize $ID scsi0 +30G
qm start $ID
echo "VM $ID ($NAME) starting at $IP — 30s to ready"

```

Then `newvm.sh 110 web02 10.10.10.110` and walk away.

## Updating the template

When Ubuntu releases a new point release, update the template:

```bash
qm stop 9000  # template can't be started, but if you converted back...
# Easiest: build a new template (e.g. 9001) and update the wrapper script

```

I don't update in place — I number templates by date (`ubuntu-2404-2026-05` etc.) and bump the script when a new one is ready. Old VMs stay on the image they were cloned from; they get patched via [unattended-upgrades](/ubuntu-automatic-updates-unattended-upgrades/).

## Gotchas

- **No serial console = silent boot failure.** If you skip `--serial0 socket --vga serial0`, the VM boots but you can't tell — and `qm start` returns success while nothing happened. Always include them.
- **Linked clone + template move = broken VMs.** Linked clones share the template's base disk. Move the template to different storage, every linked clone breaks. Use full clones unless you know what you're doing.
- **Cloud-init runs once.** Changing `--ipconfig0` after first boot doesn't re-apply. To re-trigger, run `cloud-init clean` inside the VM and reboot — but at that point you're past the "fast" promise.
- **`--cipassword` is plaintext in the cloud-init disk.** Anyone who can read the cloud-init disk image can read the password. Use SSH keys + a random throwaway password.
- **Disk fills slowly.** A 30 GB disk on `local-lvm` is thin-provisioned by default. The actual disk fills as the VM uses it. Means you can over-allocate, but means you can also fill the host's storage by accident — monitor with `pvesh get /nodes/<node>/storage/local-lvm/status`.

## Why this beats Terraform on a homelab

For 5 VMs on one host, Terraform's overhead (provider config, state file, plan/apply ceremony) costs more time than typing `qm clone` directly. The wrapper script above is 15 lines, lives on the Proxmox host, has zero state.

For 50 VMs across multiple hosts and DCs — yes, Terraform. But that's a different post.

For homelab context, also see [unattended-upgrades](/ubuntu-automatic-updates-unattended-upgrades/) and [Linux user management](/linux-user-management-adduser-sudo-ssh-keys/) — the things you'd otherwise repeat per VM.

---

### Related posts

- [rsync Backups That Actually Work — No Excuses](https://blog.diengdoh.com/rsync-backup-linux/?ref=blog.sudowl.com)
- [Linux Disk Full: Finding What's Eating Your Space](https://blog.diengdoh.com/linux-disk-full-forensics/?ref=blog.sudowl.com)
- [Run Your Own Daemon with systemd — Writing Service Unit Files](https://blog.diengdoh.com/systemd-service-unit-file/?ref=blog.sudowl.com)