Proxmox Homelab: VMs and Portainer with Terraform
Introduction
Alongside the Talos Kubernetes cluster I’ve written about, I picked up another mini PC and wanted a general-purpose box for the stuff that doesn’t need Kubernetes at all - Pi-hole, Home Assistant, that kind of thing. Spinning up pods for every small self-hosted app felt like the wrong tool. Proxmox VE gives me VMs and LXC containers directly on the hardware instead, and I wanted the VM creation itself to be Terraform, not a manual install wizard. This post is that: Proxmox installed, one VM created by Terraform, Docker and Portainer on it, and a dummy container deployed into Portainer the same declarative way.
Prerequisites
- A machine to install Proxmox VE on - I used a mini PC.
- Terraform.
- The
bpg/proxmoxandportainer/portainerTerraform providers (declared in code below, nothing to install by hand).
Installing Proxmox
Flashed the Proxmox VE ISO to a USB
stick, installed it on the mini PC, and got the web UI at
https://192.168.1.70:8006. One thing Terraform genuinely cannot do for
you: create its own API credentials. Before anything else, I created a
token under Datacenter > Permissions > API Tokens - Terraform
authenticates with that token, it can’t bootstrap it.
Creating the VM with Terraform
Instead of another manual ISO install for the VM itself, I import a Debian cloud image and let cloud-init configure it - faster, and it’s the only way this stays fully declarative:
module "portainer_vm" {
source = "./modules/vm"
node_name = var.proxmox_node_name
name = "portainer"
description = "Docker + Portainer host - managed by Terraform"
tags = ["terraform", "portainer"]
cores = 4
memory_mb = 8192
disk_datastore_id = var.vm_disk_datastore_id
disk_size_gb = 40
image_url = "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2"
image_datastore_id = "local"
bridge = "vmbr0"
ipv4_address = "${var.portainer_vm_ip}/24"
ipv4_gateway = var.lan_gateway
username = "debian"
ssh_public_keys = [trimspace(file(pathexpand(var.ssh_public_key_path)))]
ssh_private_key = file(pathexpand(var.ssh_private_key_path))
provision_commands = [
templatefile("${path.module}/templates/portainer-bootstrap.sh.tftpl", {
admin_password = var.portainer_admin_password
}),
]
}provision_commands runs over SSH once the VM is up: install Docker, run
Portainer, wait for its API to answer. One real bug from getting this
working - the bootstrap script must not exit 0 on success. When you
concatenate more than one script into provision_commands, an early exit
on the first one silently skips everything after it.
❯ terraform apply -target=module.portainer_vm
...
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
❯ terraform output portainer_vm_ip
"192.168.1.110"That -target is required on a from-scratch apply, not optional: the
portainer provider block authenticates against the VM’s IP immediately,
before any resource exists, so a plain apply on an empty state fails
before it gets anywhere near creating the VM that IP belongs to.
A Dummy Container, Deployed Declaratively
The issue I wrote for myself said “use Portainer to create a dummy
container,” which is normally a few clicks - Stacks > Add stack, paste a
compose file, deploy. Since the VM itself is already Terraform, I deployed
the stack the same way instead, through the portainer provider:
resource "portainer_stack" "this" {
name = var.name
deployment_type = "standalone"
method = "string"
endpoint_id = var.endpoint_id
stack_file_content = var.compose_content
prune = true
}pointed at a genuinely throwaway service:
name: whoami
services:
whoami:
container_name: whoami
image: traefik/whoami:latest
ports:
- "8081:80"
restart: unless-stopped❯ terraform apply
...
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
❯ curl -s http://192.168.1.110:8081 | head -3
Hostname: a1b2c3d4e5f6
IP: 127.0.0.1
IP: 172.18.0.2The full setup - the VM module, the Portainer stack module, this compose
file - is in omegion/proxmox-portainer-example
on GitHub, sanitized of anything specific to my own network.
Conclusion
One VM, created by Terraform, running Docker and Portainer, with a stack deployed into it the same declarative way - no manual clicking anywhere in this loop. It only exists on the LAN right now and there’s nothing backing it up. Next post: getting it backed up to a NAS.