# Proxmox VM Provisioning with OpenTofu

# 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](https://opentofu.org/docs/intro/install/) 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:

```bash
# 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.

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

```

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

```yaml
#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:

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

```

**Get your SSH public key:**

```bash
cat ~/.ssh/id_ed25519.pub

```

**Get the Hetzner Storage Box IP:**

```bash
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:

```bash
cd tofu-codebase
nano terraform.tfvars

```

```hcl
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

```bash
cd tofu-codebase
tofu init

```

This downloads the `bpg/proxmox` provider.

---

### 2.4 Plan

Preview what will be created:

```bash
tofu plan

```

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

---

### 2.5 Apply

```bash
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

```bash
ssh ubuntu@172.16.10.30

```

### 3.2 Check cloud-init completed

```bash
cloud-init status
# Expected: status: done

```

### 3.3 Check storage box is mounted

```bash
mount | grep storagebox
df -TH | grep storagebox

```

### 3.4 Write a test file and verify from Proxmox host

```bash
# 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

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

```

---

## Part 4 — Teardown

To destroy the VM:

```bash
tofu destroy

```

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

---

## Troubleshooting

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