Creating Ubuntu Template Images with Proxmox Cloud-Init

Getting Started
I'm running a Proxmox cluster on my personal hardware.
At first, I was just grateful that VMs could be created at all. But humans are fickle creatures.

Gradually, installing Ubuntu started to feel tedious.
So, just like clicking a button in the AWS Console, I initially used cloned VMs from pre-configured setups.
But then, issues like ID conflicts and DHCP failures started piling up, making things annoying again.
While looking around for solutions, I discovered that AWS actually uses something called cloud-init for one-click installations. Today, I'm going to document what I learned about it.
1. Download Ubuntu Cloud Image and Resize Disk
First, download the latest Jammy (22.04) image file and expand the disk size.
wget https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
qemu-img resize jammy-server-cloudimg-amd64.img 50G
- Ubuntu's official Cloud Image comes with cloud-init pre-installed, making it ideal for automating initial setup.\
- Since the default size is small, we expand it to 50GB using the qemu-img resize command.
2. Create Proxmox VM
qm create 99001 --name=ubuntu-22-04 --memory=2048 --net0 virtio,bridge=vmbr1
- VM ID: 99001
- Name: ubuntu-22-04
- Memory: 2GB
- Network: VirtIO + Bridge (vmbr1)
The network bridge can be changed to vmbr0 or vmbr1 depending on your environment.
3. Register Cloud Image as VM Disk
qm set 99001 --virtio0 iSCSI-2T:0,import-from=/mnt/pve/NFS-AN/template/iso/jammy-server-cloudimg-amd64.img
- Connect as virtio0 disk\
- Use the import-from option to directly import the existing Cloud Image to the specified storage (iSCSI-2T)\
- During import, automatic conversion from QCOW2 to RAW occurs
4. Add Cloud-Init Drive
qm set 99001 --ide2 iSCSI-2T:cloudinit
- Add a Cloud-Init configuration drive to inject user metadata during initial boot\
- Commonly used to automate SSH keys, hostname, network settings, etc.
5. Boot and Console Configuration
qm set 99001 --boot order=virtio0
qm set 99001 --serial0 socket --vga serial0
- Set boot order to virtio0\
- Configure serial0 console to enable text-based terminal access. You can access the console via qm terminal 99001
6. Specify Cloud-Init User Script
qm set 99001 --cicustom "user=NFS-GS:snippets/ubuntu-init.yaml"
- Specify a custom Cloud-Init YAML file\
- The snippets/ubuntu-init.yaml file can include user, package, SSH settings, etc.
Example (ubuntu-init.yaml):
#cloud-config
package_update: true
package_upgrade: true
timezone: Asia/Seoul
packages:
- qemu-guest-agent
- curl
- ca-certificates
- sudo
users:
- default
- name: yangs
gecos: Yangs Admin
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
groups: [sudo, docker]
lock_passwd: false
ssh_authorized_keys:
- "ssh-rsa ..."
runcmd:
- |
echo "=== Setting timezone (Asia/Seoul) ==="
timedatectl set-timezone Asia/Seoul
echo "Current timezone: $(timedatectl show --property=Timezone --value)"
- |
echo "=== Installing Docker ==="
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
chmod +x /tmp/get-docker.sh
sh /tmp/get-docker.sh
- |
echo "=== Installing Docker Compose ==="
curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" \
-o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
- |
echo "=== Docker service configuration: auto-start and restart policy ==="
systemctl enable docker
mkdir -p /etc/docker
echo '{' > /etc/docker/daemon.json
echo ' "log-driver": "json-file",' >> /etc/docker/daemon.json
echo ' "log-opts": {' >> /etc/docker/daemon.json
echo ' "max-size": "50m",' >> /etc/docker/daemon.json
echo ' "max-file": "3"' >> /etc/docker/daemon.json
echo ' }' >> /etc/docker/daemon.json
echo '}' >> /etc/docker/daemon.json
systemctl daemon-reload
systemctl restart docker
sudo usermod -aG docker yangs || true
echo "=== Docker configuration complete ==="
- |
echo "=== Enabling QEMU Guest Agent ==="
systemctl enable qemu-guest-agent
systemctl start qemu-guest-agent
- |
echo "=== Creating Prometheus Node Exporter container ==="
sudo docker run --name=node-exporter \
--restart=always \
-d \
--net="host" \
--pid="host" \
-v "/:/host:ro,rslave" \
quay.io/prometheus/node-exporter:latest \
--path.rootfs=/host || true
echo "=== Verifying Node Exporter execution ==="
sudo docker ps | grep node-exporter || echo "Node Exporter container creation complete"
- |
echo "=== Starting Ubuntu Clone initialization ==="
set -e
echo "[1/6] Initializing machine-id..."
rm -f /etc/machine-id
dbus-uuidgen --ensure=/etc/machine-id
systemd-machine-id-setup
echo "[2/6] Initializing SSH host keys..."
rm -f /etc/ssh/ssh_host_*
dpkg-reconfigure -f noninteractive openssh-server
echo "[4/6] Clearing netplan / udev network cache..."
rm -f /etc/udev/rules.d/70-persistent-net.rules
rm -f /etc/netplan/*.bak
echo "[5/6] Initializing hostname..."
new_hostname="ubuntu-$(openssl rand -hex 3)"
hostnamectl set-hostname "$new_hostname"
echo "New hostname: $new_hostname"
echo "[6/6] Cleaning up logs and cache..."
rm -rf /var/log/*
rm -rf /tmp/*
rm -rf /var/tmp/*
echo "=== Initialization complete ==="
echo "Please reboot now with 'sudo reboot'."
power_state:
mode: reboot
timeout: 30
message: "Automatic reboot after Ubuntu initialization is complete."
7. Convert to Template
qm template 99001
- Convert the configured VM to a template\
- You can then quickly create multiple instances using the qm clone command