Tools: Update: Automate Proxmox VMs with Cloud-Init

Tools: Update: Automate Proxmox VMs with Cloud-Init

The Advantage of -cicustom

Download the Cloud Image

Create the VM Template

Create Your Cloud-Init YAML

Clone Your VM Template

Apply the Cloud-Init Config

Configure Networking

Start the VM

Verify Docker

Verify Fail2Ban is Running

Verify UFW Firewall Rules

Verify QEMU Guest Agent

Troubleshooting

Why -cicustom is Better

Production-Ready Out of the Box

Building More Automation? 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. 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. SSH into your Proxmox server and download the latest Ubuntu 24.04 cloud image: You should see the ubuntu-24.04-server-cloudimg-amd64.img file listed. Create the base VM template that you’ll clone from: --ostype l26 – Tells Proxmox this is a Linux VM

--agent enabled=1 – Enables QEMU guest agentOptional (Nice to have):

--vga serial0 and --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: 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: Attach the cloud-init disk: Convert to a template: Now you have a reusable Ubuntu template in Proxmox. All future clones will inherit this configuration. SSH into your Proxmox server and create the snippets folder: Paste your cloud-init configuration. Here’s a production-ready example that includes SSH hardening, QEMU Guest Agent, Fail2Ban, UFW firewall, and Docker: Save the file (in nano: Ctrl+X, then Y, then Enter). Now clone your pre-built template (from the NoCloud article) and reference the cloud-init YAML: Reference the YAML file stored in your snippets folder: This tells Proxmox to use the cloud-init config from /var/lib/vz/snippets/cloud-init-config.yaml. 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 If something doesn’t work as expected, SSH into the VM and check the logs. To see the last 100 lines of output: 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 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 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ cd /var/lib/vz/template/iso -weight: 500;">wget https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img cd /var/lib/vz/template/iso -weight: 500;">wget https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img cd /var/lib/vz/template/iso -weight: 500;">wget https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img ls -lah /var/lib/vz/template/iso/ | grep ubuntu ls -lah /var/lib/vz/template/iso/ | grep ubuntu ls -lah /var/lib/vz/template/iso/ | grep ubuntu 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 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 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 qm set 501 --scsi0 local-zfs:0,import-from=/var/lib/vz/template/iso/ubuntu-24.04-server-cloudimg-amd64.img qm set 501 --scsi0 local-zfs:0,import-from=/var/lib/vz/template/iso/ubuntu-24.04-server-cloudimg-amd64.img qm set 501 --scsi0 local-zfs:0,import-from=/var/lib/vz/template/iso/ubuntu-24.04-server-cloudimg-amd64.img qm resize 501 scsi0 +60G qm resize 501 scsi0 +60G qm resize 501 scsi0 +60G qm set 501 --ide2 local-zfs:cloudinit qm set 501 --ide2 local-zfs:cloudinit qm set 501 --ide2 local-zfs:cloudinit qm set 501 --boot order=scsi0 qm set 501 --boot order=scsi0 qm set 501 --boot order=scsi0 qm template 501 qm template 501 qm template 501 cd /var/lib/vz/snippets nano cloud-init-config.yaml cd /var/lib/vz/snippets nano cloud-init-config.yaml cd /var/lib/vz/snippets nano cloud-init-config.yaml #cloud-config users: - name: kaf <-- Swap this with your own username groups: users, admin, -weight: 500;">docker -weight: 600;">sudo: ALL=(ALL) NOPASSWD:ALL shell: /bin/bash ssh_authorized_keys: - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... <-- Your public ssh key here packages: - fail2ban - ufw - ca-certificates - -weight: 500;">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/-weight: 500;">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 - -weight: 500;">systemctl -weight: 500;">enable fail2ban - -weight: 500;">systemctl -weight: 500;">start fail2ban # UFW Firewall - ufw allow 22/tcp - ufw allow 80/tcp - ufw allow 443/tcp - ufw -weight: 500;">enable # Docker Installation (Docker v29 - DEB822 format) - mkdir -p /etc/-weight: 500;">apt/keyrings - -weight: 500;">curl -fsSL https://download.-weight: 500;">docker.com/linux/ubuntu/gpg -o /etc/-weight: 500;">apt/keyrings/-weight: 500;">docker.asc - chmod a+r /etc/-weight: 500;">apt/keyrings/-weight: 500;">docker.asc - | tee /etc/-weight: 500;">apt/sources.list.d/-weight: 500;">docker.sources > /dev/null <<EOF Types: deb URIs: https://download.-weight: 500;">docker.com/linux/ubuntu Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") Components: stable Signed-By: /etc/-weight: 500;">apt/keyrings/-weight: 500;">docker.asc EOF - -weight: 500;">apt-get -weight: 500;">update - -weight: 500;">apt-get -weight: 500;">install -y -weight: 500;">docker-ce -weight: 500;">docker-ce-cli containerd.io -weight: 500;">docker-buildx-plugin -weight: 500;">docker-compose-plugin # Enable Docker daemon - -weight: 500;">systemctl -weight: 500;">enable -weight: 500;">docker - -weight: 500;">systemctl -weight: 500;">start -weight: 500;">docker # Add user to -weight: 500;">docker group - usermod -aG -weight: 500;">docker kaf <-- Swap this with your own username #cloud-config users: - name: kaf <-- Swap this with your own username groups: users, admin, -weight: 500;">docker -weight: 600;">sudo: ALL=(ALL) NOPASSWD:ALL shell: /bin/bash ssh_authorized_keys: - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... <-- Your public ssh key here packages: - fail2ban - ufw - ca-certificates - -weight: 500;">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/-weight: 500;">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 - -weight: 500;">systemctl -weight: 500;">enable fail2ban - -weight: 500;">systemctl -weight: 500;">start fail2ban # UFW Firewall - ufw allow 22/tcp - ufw allow 80/tcp - ufw allow 443/tcp - ufw -weight: 500;">enable # Docker Installation (Docker v29 - DEB822 format) - mkdir -p /etc/-weight: 500;">apt/keyrings - -weight: 500;">curl -fsSL https://download.-weight: 500;">docker.com/linux/ubuntu/gpg -o /etc/-weight: 500;">apt/keyrings/-weight: 500;">docker.asc - chmod a+r /etc/-weight: 500;">apt/keyrings/-weight: 500;">docker.asc - | tee /etc/-weight: 500;">apt/sources.list.d/-weight: 500;">docker.sources > /dev/null <<EOF Types: deb URIs: https://download.-weight: 500;">docker.com/linux/ubuntu Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") Components: stable Signed-By: /etc/-weight: 500;">apt/keyrings/-weight: 500;">docker.asc EOF - -weight: 500;">apt-get -weight: 500;">update - -weight: 500;">apt-get -weight: 500;">install -y -weight: 500;">docker-ce -weight: 500;">docker-ce-cli containerd.io -weight: 500;">docker-buildx-plugin -weight: 500;">docker-compose-plugin # Enable Docker daemon - -weight: 500;">systemctl -weight: 500;">enable -weight: 500;">docker - -weight: 500;">systemctl -weight: 500;">start -weight: 500;">docker # Add user to -weight: 500;">docker group - usermod -aG -weight: 500;">docker kaf <-- Swap this with your own username #cloud-config users: - name: kaf <-- Swap this with your own username groups: users, admin, -weight: 500;">docker -weight: 600;">sudo: ALL=(ALL) NOPASSWD:ALL shell: /bin/bash ssh_authorized_keys: - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... <-- Your public ssh key here packages: - fail2ban - ufw - ca-certificates - -weight: 500;">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/-weight: 500;">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 - -weight: 500;">systemctl -weight: 500;">enable fail2ban - -weight: 500;">systemctl -weight: 500;">start fail2ban # UFW Firewall - ufw allow 22/tcp - ufw allow 80/tcp - ufw allow 443/tcp - ufw -weight: 500;">enable # Docker Installation (Docker v29 - DEB822 format) - mkdir -p /etc/-weight: 500;">apt/keyrings - -weight: 500;">curl -fsSL https://download.-weight: 500;">docker.com/linux/ubuntu/gpg -o /etc/-weight: 500;">apt/keyrings/-weight: 500;">docker.asc - chmod a+r /etc/-weight: 500;">apt/keyrings/-weight: 500;">docker.asc - | tee /etc/-weight: 500;">apt/sources.list.d/-weight: 500;">docker.sources > /dev/null <<EOF Types: deb URIs: https://download.-weight: 500;">docker.com/linux/ubuntu Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") Components: stable Signed-By: /etc/-weight: 500;">apt/keyrings/-weight: 500;">docker.asc EOF - -weight: 500;">apt-get -weight: 500;">update - -weight: 500;">apt-get -weight: 500;">install -y -weight: 500;">docker-ce -weight: 500;">docker-ce-cli containerd.io -weight: 500;">docker-buildx-plugin -weight: 500;">docker-compose-plugin # Enable Docker daemon - -weight: 500;">systemctl -weight: 500;">enable -weight: 500;">docker - -weight: 500;">systemctl -weight: 500;">start -weight: 500;">docker # Add user to -weight: 500;">docker group - usermod -aG -weight: 500;">docker kaf <-- Swap this with your own username qm clone 501 106 --name ubuntu-vm02 qm clone 501 106 --name ubuntu-vm02 qm clone 501 106 --name ubuntu-vm02 qm set 106 --cicustom "user=local:snippets/cloud-init-config.yaml" qm set 106 --cicustom "user=local:snippets/cloud-init-config.yaml" qm set 106 --cicustom "user=local:snippets/cloud-init-config.yaml" qm set 106 --ipconfig0 ip=10.160.0.61/24,gw=10.160.0.1 qm set 106 --ipconfig0 ip=10.160.0.61/24,gw=10.160.0.1 qm set 106 --ipconfig0 ip=10.160.0.61/24,gw=10.160.0.1 qm set 106 --ipconfig0 ip=dhcp qm set 106 --ipconfig0 ip=dhcp qm set 106 --ipconfig0 ip=dhcp qm -weight: 500;">start 106 qm -weight: 500;">start 106 qm -weight: 500;">start 106 -weight: 600;">sudo cat /var/log/cloud-init.log | grep -i error -weight: 600;">sudo cat /var/log/cloud-init.log | grep -i error -weight: 600;">sudo cat /var/log/cloud-init.log | grep -i error -weight: 600;">sudo tail -100 /var/log/cloud-init-output.log -weight: 600;">sudo tail -100 /var/log/cloud-init-output.log -weight: 600;">sudo tail -100 /var/log/cloud-init-output.log - Store YAML templates in Proxmox - Reference them by path when cloning VMs - Reuse the same config across multiple clones - Easily -weight: 500;">update configurations in one place - 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 -weight: 600;">sudo access and SSH key authentication - 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 - 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