Omegion

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

  1. A machine to install Proxmox VE on - I used a mini PC.
  2. Terraform.
  3. The bpg/proxmox and portainer/portainer Terraform 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:

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

shell
❯ 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:

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

yaml
name: whoami

services:
  whoami:
    container_name: whoami
    image: traefik/whoami:latest
    ports:
      - "8081:80"
    restart: unless-stopped
shell
❯ 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.2

The 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.