Automate Proxmox VMs with Cloud-Init
In my previous post, I showed how to provision VMs with NoCloud. That works great, but there’s an even faster approach: storing your cloud-init config directly in Proxmox’s snippets folder and referencing it with --cicustom.
Automate Proxmox VMs with Cloud-Init method is cleaner, more manageable, and scales better when you’re provisioning multiple VMs with similar configurations.
The Advantage of -cicustom
Instead of generating ISOs for each cloud-init config, you:
- Store YAML templates in Proxmox
- Reference them by path when cloning VMs
- Reuse the same config across multiple clones
- Easily update configurations in one place
Let’s walk through it.
Download the Cloud Image
SSH into your Proxmox server and download the latest Ubuntu 24.04 cloud image:
cd /var/lib/vz/template/iso
wget https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img
Verify the download:
ls -lah /var/lib/vz/template/iso/ | grep ubuntu
You should see the ubuntu-24.04-server-cloudimg-amd64.img file listed.
Create the VM Template
Create the base VM template that you’ll clone from:
qm create 501 \
--name ubuntu-template \
--memory 2048 \
--cores 4 \
--net0 virtio,bridge=vmbr0 \
--scsihw virtio-scsi-pci \
--ostype l26 \
--agent enabled=1 \
--vga serial0 \
--serial0 socket
--ostype l26– Tells Proxmox this is a Linux VM--agent enabled=1– Enables QEMU guest agent
Optional (Nice to have):--vga serial0and--serial0 socket– Enable serial console access.
This lets you watch cloud-init progress in real-time in the Proxmox console. Can be omitted if you don’t need to monitor boot output.
Import the cloud image disk:
qm set 501 --scsi0 local-zfs:0,import-from=/var/lib/vz/template/iso/ubuntu-24.04-server-cloudimg-amd64.img
This imports the cloud image (~3.5GB). You need to resize the disk to provide adequate space for cloud-init
Resize the disk after import:
qm resize 501 scsi0 +60G
Attach the cloud-init disk:
qm set 501 --ide2 local-zfs:cloudinit
Set boot order:
qm set 501 --boot order=scsi0
Convert to a template:
qm template 501
Now you have a reusable Ubuntu template in Proxmox. All future clones will inherit this configuration.
Create Your Cloud-Init YAML
SSH into your Proxmox server and create the snippets folder:
cd /var/lib/vz/snippets
nano cloud-init-config.yaml
Paste your cloud-init configuration. Here’s a production-ready example that includes SSH hardening, QEMU Guest Agent, Fail2Ban, UFW firewall, and Docker:
#cloud-config
users:
- name: kaf <-- Swap this with your own username
groups: users, admin, docker
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... <-- Your public ssh key here
packages:
- fail2ban
- ufw
- ca-certificates
- curl
- gnupg
- lsb-release
- qemu-guest-agent
package_update: true
package_upgrade: true
write_files:
- path: /etc/ssh/sshd_config.d/ssh-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
Port 22
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
MaxAuthTries 2
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
AuthorizedKeysFile .ssh/authorized_keys
AllowUsers kaf <-- Swap this with your own username
- path: /etc/docker/daemon.json
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
runcmd:
# Fail2Ban setup
- printf "[sshd]\nenabled = true\nport = ssh, 22\nbanaction = iptables-multiport" > /etc/fail2ban/jail.local
- systemctl enable fail2ban
- systemctl start fail2ban
# UFW Firewall
- ufw allow 22/tcp
- ufw allow 80/tcp
- ufw allow 443/tcp
- ufw enable
# Docker Installation (Docker v29 - DEB822 format)
- mkdir -p /etc/apt/keyrings
- curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
- chmod a+r /etc/apt/keyrings/docker.asc
- |
tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
- apt-get update
- apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Enable Docker daemon
- systemctl enable docker
- systemctl start docker
# Add user to docker group
- usermod -aG docker kaf <-- Swap this with your own username
Save the file (in nano: Ctrl+X, then Y, then Enter).
Clone Your VM Template
Now clone your pre-built template (from the NoCloud article) and reference the cloud-init YAML:
qm clone 501 106 --name ubuntu-vm02
Apply the Cloud-Init Config
Reference the YAML file stored in your snippets folder:
qm set 106 --cicustom "user=local:snippets/cloud-init-config.yaml"
This tells Proxmox to use the cloud-init config from /var/lib/vz/snippets/cloud-init-config.yaml.
Configure Networking
For static IP:
qm set 106 --ipconfig0 ip=10.160.0.61/24,gw=10.160.0.1
For DHCP:
qm set 106 --ipconfig0 ip=dhcp
Start the VM
qm start 106
That’s it! Cloud-init will run on first boot and configure:
- SSH hardening (no root login, no password auth)
- Fail2Ban (brute-force protection)
- UFW firewall (allows SSH, HTTP, HTTPS)
- Docker (latest version with proper logging)
- User with sudo access and SSH key authentication

Verify Docker

Verify Fail2Ban is Running

Verify UFW Firewall Rules

Verify QEMU Guest Agent


Troubleshooting
If something doesn’t work as expected, SSH into the VM and check the logs.
To check for errors:
sudo cat /var/log/cloud-init.log | grep -i error
To see the last 100 lines of output:
sudo tail -100 /var/log/cloud-init-output.log
Why -cicustom is Better
Compared to the NoCloud ISO approach:
- Faster: No need to generate ISOs—just reference a file
- Reusable: Same YAML for multiple clones
- Maintainable: Update one file, all new clones use the latest config
- Cleaner: No ISO files cluttering your storage
- Flexible: Mix and match different YAML templates for different VM roles
Production-Ready Out of the Box
Your VMs are locked down from day one:
- SSH keys only (no password logins)
- SSH hardening config applied
- Fail2Ban running to prevent brute-force attacks
- UFW firewall enabled
- Docker installed and ready to use
All automated. No manual setup needed.
I use this in my own homelab for quickly spinning up test environments, production servers.
Building More Automation?
I recently started a Skool community called Build & Automate dedicated to infrastructure and automation. Whether you’re provisioning VMs, building Docker stacks, automating workflows with n8n, or managing cloud infrastructure, the community is a place to solve these problems together.
If you’re working on infrastructure automation like this and want hands-on support, templates, and a community of people doing the same thing, come check it out.
Have questions about your cloud-init setup? Reach out on my socials or GitHub. I share all my homelab configs and automation tools there.