Skip to main content

Proxmox VM Provisioning with OpenTofu

Proxmox VM Provisioning with OpenTofu

Automated VM provisioning on Proxmox VE using OpenTofu (Terraform-compatible), Ubuntu cloud images, and cloud-init. VMs are cloned from a template and configured with a static IP, SSH access, and a Hetzner Storage Box CIFS mount.


Architecture

Your Machine
    └── OpenTofu
            └── Proxmox API (port 8006)
                    └── Clone VM from template (Ubuntu cloud image)
                            └── cloud-init (first boot)
                                    ├── Create user + SSH key
                                    ├── Install cifs-utils + kernel modules
                                    └── Mount Hetzner Storage Box → /mnt/storagebox

Prerequisites

  • OpenTofu installed locally
  • Proxmox VE node accessible via HTTPS
  • Hetzner Storage Box (optional, for cold storage)

Part 1 — Proxmox Host Setup

1.1 Create an API Token

In the Proxmox web UI:

  1. Go to Datacenter → Permissions → API Tokens
  2. Add a token for root@pam (e.g. token ID: opentofu1)
  3. Uncheck Privilege Separation so it inherits root permissions
  4. Save the token secret — it is shown only once

The token format will be: root@pam!opentofu1=<uuid>


1.2 Create the Ubuntu Cloud Image Template

SSH into the Proxmox host and run:

# Download Ubuntu Noble (24.04) cloud image
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img

# Resize to 32G
qemu-img resize noble-server-cloudimg-amd64.img 32G

# Create the template VM (ID 112)
qm create 112 --name ubuntu-cloud-template --memory 2048 --cores 2 --net0 virtio,bridge=vmbr1

# Import the disk into your datastore
qm importdisk 112 noble-server-cloudimg-amd64.img data-thin

# Attach and configure the disk
qm set 112 --scsihw virtio-scsi-pci --scsi0 data-thin:vm-112-disk-0
qm set 112 --ide2 local:cloudinit
qm set 112 --boot c --bootdisk scsi0
qm set 112 --serial0 socket --vga serial0

# Convert to template
qm template 112

Note: Replace data-thin with your actual datastore name. Check available datastores with pvesm status.


1.3 Create the cloud-init Snippet

The cloud-init snippet configures the VM on first boot: creates the user, sets up SSH, installs packages, and mounts the storage box.

nano /var/lib/vz/snippets/cloud-init-user-data.yaml

Paste the following (no leading spaces on any line):

#cloud-config
users:
  - name: ubuntu
    groups: sudo
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:ALL
    ssh_authorized_keys:
      - ssh-ed25519 YOUR_PUBLIC_KEY_HERE
    lock_passwd: false
    passwd: YOUR_HASHED_PASSWORD_HERE
packages:
  - cifs-utils
runcmd:
  - apt-get install -y linux-modules-extra-$(uname -r)
  - modprobe cifs
  - mkdir -p /mnt/storagebox
  - |
    cat > /etc/storagebox-credentials << 'EOF'
    username=YOUR_STORAGEBOX_USER
    password=YOUR_STORAGEBOX_SAMBA_PASSWORD
    EOF
  - chmod 600 /etc/storagebox-credentials
  - echo "//YOUR_STORAGEBOX_HOST/backup /mnt/storagebox cifs credentials=/etc/storagebox-credentials,ip=YOUR_STORAGEBOX_IP,uid=0,gid=0,vers=3.0,sec=ntlmssp,iocharset=utf8,nodfs,nosharesock,_netdev,nofail 0 0" >> /etc/fstab
  - mount /mnt/storagebox
  - mkdir -p /mnt/storagebox/saas-factory

Generate a password hash on your local machine:

python3 -c "import crypt; print(crypt.crypt('YOUR_PASSWORD', crypt.mksalt(crypt.METHOD_SHA512)))"

Get your SSH public key:

cat ~/.ssh/id_ed25519.pub

Get the Hetzner Storage Box IP:

dig +short YOUR_STORAGEBOX_HOST

Important: The Samba/CIFS password for the Hetzner Storage Box is separate from the main account password. Set it in Hetzner Robot → Storage Box → Password.

Important: linux-modules-extra is required for the CIFS kernel module on Ubuntu cloud images. Without it, the storage box mount will fail silently.


Part 2 — Local OpenTofu Setup

2.1 Directory Structure

proxmox/
├── tofu-codebase/
│   ├── main.tf            # VM and provider configuration
│   ├── variables.tf       # Variable declarations
│   ├── terraform.tfvars   # Secret values (gitignored)
│   └── .terraform.lock.hcl
├── cloud-init/
│   └── readme.md
├── .gitignore
└── README.md

2.2 Configure Variables

Copy and fill in your values:

cd tofu-codebase
nano terraform.tfvars
proxmox_endpoint  = "https://YOUR_PROXMOX_IP:8006/"
proxmox_api_token = "root@pam!opentofu1=YOUR_TOKEN_UUID"
proxmox_insecure  = true

vm_username = "ubuntu"
vm_password = "YOUR_VM_PASSWORD"
vm_ssh_key  = "ssh-ed25519 YOUR_PUBLIC_KEY"

terraform.tfvars is gitignored — never commit it.


2.3 Initialize OpenTofu

cd tofu-codebase
tofu init

This downloads the bpg/proxmox provider.


2.4 Plan

Preview what will be created:

tofu plan

Expected output: 1 to add, 0 to change, 0 to destroy — one VM cloned from template 112.


2.5 Apply

tofu apply

Type yes when prompted. The VM will be:

  • Cloned from template 112
  • Assigned IP 172.16.10.30/24
  • Configured via cloud-init on first boot

Cloud-init takes 2–5 minutes on first boot.


Part 3 — Verify the VM

3.1 SSH into the VM

ssh ubuntu@172.16.10.30

3.2 Check cloud-init completed

cloud-init status
# Expected: status: done

3.3 Check storage box is mounted

mount | grep storagebox
df -TH | grep storagebox

3.4 Write a test file and verify from Proxmox host

# On the VM
echo "hello" > /mnt/storagebox/saas-factory/test.txt

# On the Proxmox host
cat /mnt/pve/backup/saas-factory/test.txt

3.5 If cloud-init failed, check logs

cat /var/log/cloud-init-output.log

Part 4 — Teardown

To destroy the VM:

tofu destroy

The template (VM 112) is not managed by OpenTofu and will not be deleted.


Troubleshooting

Problem Cause Fix
HTTP 595 Connection refused Wrong node_name Check node name: pvesh get /nodes
storage does not exist Wrong datastore in initialization Use local for cloud-init snippets
Permission denied (publickey) SSH key not injected Ensure user_data_file_id is set and VM was recreated
STATUS_LOGON_FAILURE on CIFS Wrong Samba password Reset Samba password in Hetzner Robot
CIFS mount fails silently Missing kernel module Add linux-modules-extra-$(uname -r) to cloud-init runcmd
cloud-init didn't run Template built from ISO not cloud image Rebuild template from Ubuntu cloud image