Home Lab Series: Proxmox + Terraform – A pre-Kubernetes story

H

Once again, despite my efforts to keep this blog updated and create something valuable to give back to the community, I fell short.

So, let’s shake it off and move forward. Lately, I’ve been exploring new topics to learn, and I decided it was time to level up my DevOps skills. Today, we’re going to look at something I really enjoyed building: a home lab with Proxmox, using Terraform to provision virtual machines. At this point, you might be asking yourself, “What is he planning to do with that?” If your guess is a Kubernetes cluster, you’re absolutely right. My goal is to build a Kubernetes cluster for a home lab where I can experiment, test ideas, and learn in a more practical way, without constantly creating and destroying VMs on cloud providers for simple tasks. This setup will not only give me a better space to explore, but it will also help me make more informed decisions and save money when it’s time to move workloads to the cloud. And of course, having a bit of on-premises infrastructure never hurt anybody.

In this post

  • A Proxmox-based home lab
  • A reusable VM template
  • A Terraform configuration to provision:
    • 3 control plane nodes
    • 3 worker nodes
  • A generated Ansible inventory for future automation

Prerequisites

  • Basic Linux knowledge
  • Proxmox VE installed
  • Terraform installed
  • SSH key pair

What you will do?

Here is the diagram of what we are trying to achieve.

High-level architecture of the Proxmox-based home lab, where Terraform provisions virtual machines from a template to form a Kubernetes cluster.

Let’s start

I’ll start by describing the setup I’m using to build this. This is simply my current setup, and you can adapt it to match your own situation.

I have a gaming desktop that I originally built for gaming—but, ironically, gaming is the one thing I’m not using it for. So, I bought a 1 TB SATA SSD, which is more than enough for this purpose. You could easily do the same with a 500 GB drive, so don’t worry if you have less storage available.

If you’re in a more comfortable financial position, you could also buy a Mini PC or even build a dedicated home server. That part is entirely up to you.

At a minimum, you’ll need a computer that supports virtualization in order to create this home lab.

What is Proxmox VE?

Proxmox VE (Virtual Environment) is a comprehensive, open-source server management platform designed for enterprise virtualization.

It enables you to create, deploy, and manage virtual machines through an intuitive web-based interface. This makes it easy to handle your virtual infrastructure efficiently, especially in clustered environments. Take a look at the image below, which was taken from the official Proxmox website. It is well worth exploring further.

Why Proxmox vs Cloud

Using cloud providers like AWS or Azure is incredibly convenient, but for this kind of experimentation, I wanted something more predictable and cost-efficient.

Running a home lab with Proxmox gives me:

  • Full control over the infrastructure
  • No ongoing costs for spinning resources up and down
  • A stable environment where I can break things without worrying about billing

In the cloud, even simple experiments can become expensive if you forget to tear resources down. With Proxmox, once the hardware is in place, I can experiment freely.

That said, I still see cloud as essential — this setup is not a replacement, but a complement for learning and testing before moving workloads to the cloud.

Why Terraform vs Ansible-only?

Ansible is great for configuration management, but when it comes to infrastructure provisioning, Terraform provides a more structured and predictable approach.

With Terraform, I get:

  • Declarative infrastructure (desired state)
  • Idempotency out of the box
  • A clear execution plan (terraform plan) before applying changes

While Ansible can create VMs, it’s not its primary strength. Terraform is purpose-built for provisioning infrastructure, while Ansible shines when configuring what runs inside those machines.

In this setup:

  • Terraform → creates the VMs
  • Ansible → configures the cluster (next step 👀)

Why Static IP vs DHCP Reservation?

For this lab, I chose to assign static IPs directly via cloud-init instead of relying on DHCP reservations.

The main reasons:

  • Predictability: IPs are defined in code
  • Portability: the setup works independently of router configuration
  • Reproducibility: I can recreate the same environment anywhere

DHCP reservations work well, but they introduce an external dependency (your router configuration), which makes the setup less portable.

By defining IPs in Terraform, everything becomes part of the infrastructure code.

Why not just use the Cloud?

This is a fair question — and honestly, for many scenarios, the cloud is the right answer.

However, for learning and experimentation:

  • Costs can add up quickly
  • You depend on internet connectivity
  • You may hesitate to experiment freely

With a home lab:

  • You have a safe environment to break things
  • No cost anxiety
  • Faster iteration for small tests

That said, I still plan to mirror this setup in the cloud later, which is where the real value comes in — validating ideas locally before scaling them.

Why not use k3s instead of full Kubernetes?

k3s is an excellent lightweight Kubernetes distribution and is often the better choice for edge environments or low-resource setups.

However, my goal here is to:

  • Learn Kubernetes as close to production as possible
  • Understand the full control plane components
  • Gain hands-on experience with tools like kubeadm

Using full Kubernetes gives me deeper insight into:

  • Cluster bootstrapping
  • Networking
  • Control plane behavior

k3s abstracts some of that complexity, which is great in practice, but for learning purposes, I want to understand what’s happening under the hood.

What did I do?

I’ve downloaded the Proxmox VE ISO and created a bootable USB drive using balenaEtcher so I could boot my PC from it.

One important thing to keep in mind is:

Proxmox VE is built on Debian Linux, so when you install it, you are essentially installing a Debian-based Linux distribution with Proxmox VE on top.

The installation process is quite straightforward. You can watch a YouTube tutorial or simply try it yourself and explore as you go. I’ll leave that up to you. For now, here are a few tips that may be helpful:

  • You should reserve a range of IP addresses on your router (DHCP server).
    • Your router assigns IP addresses in your local network through DHCP server.
    • In most modern routers, there is a feature to reserve some ip addresses to not be used by the DHCP servers.
    • Example: on my personal network the DHCP server can only assign automatically the IPs from 192.168.0.100 to 192.168.0.250… 150 Ip addresses are more than enough for my personal devices and gadget.

So, on the Proxmox VE installation you will have the chance to set the FIXED IP address for your Proxmox VE. (In my case I’ve used 192.168.0.50). Follow up, the installation process and good luck.

If you are using a MiniPC, or another device to install Proxmox and will access it over your local network there is a thing you can do to make it cool.

Go on your hosts file and point the Proxmox server IP to an address, for example:

192.168.0.50 myproxmox.lab

Every time you type on your browser http://myproxmox.lab:8006 you will access the Proxmox VE interface (as shown on the image at the beginning)

Great…. what now?

Now, we will see where things get real.

What can I do now?

With Proxmox you can create Virtual Machines in the same way as we create on VMWare or VirtualBox. But, I don’t think that is a way of handling VMs when we are talking about things in production (even though we are doing thing on a home lab)

So, what can we do for this? How to work in a more productive way?

Well, we can use templates. So we will have a base line that will be used create new VM as we need.

This template can be created with qcow2 image.

A QCOW2 (QEMU Copy-On-Write) is a versatile virtual disk image format used primarily by the QEMU emulator and KVM virtualization to store virtual machine (VM) data. It supports thin provisioning (only uses space actually written to), snapshots, compression, and AES encryption, making it more efficient and flexible than raw disk images.

I took the chance to create a script to make this automated for me, feel free to copy, make your changes, don’t forget to share what improvements you made.

Basically the script above creates a template and add it to Proxmox.

I copied this script to my proxmox and ran it as root like this:

./create-debian12-template.sh 9000 local-lvm

Remember I create with Debian because I like it. You can use any distro you like which has qcow2 image. So, use your creativity.

Once this template is shown at your Proxmox VE interface you are able to use it to create your VMs with a baseline.

And now we will use it with terraform.

Creating VMS with terraform on proxmox ve

Since this is not a Linux, Shell Script not Terraform course I will talk about briefely what my script does. The full repo is here

#main.tf

terraform {
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.69"
    }
  }
}

provider "proxmox" {
  endpoint  = var.proxmox_host_address
  api_token = "${var.pm_api_token_id}=${var.pm_api_token_secret}"
  insecure  = true
}

Basically I am setting the Terraform required provider to use the “bpg/proxmox”, which more can be found here in the Terraform Registry

While on the provider proxmox to configure the endpoint as my Proxmox host, the api_token you can generate using the UI followint the steps below

  • Log in to the Proxmox web UI
  • Go to Datacenter > Permissions > API Tokens
  • Click Add
  • Select a user (or create a dedicated user first)
  • Set a Token ID
  • Uncheck “Privilege Separation” if you want the token to inherit the user’s permissions
  • Copy the token value (it is shown only once)

The API token format is <user>@<realm>!<tokenid>=<secret>

You can see the option insecure=true this is because the self-signed TLS certificate is in use: To solve that you can either:

  • Add a Promox certificate to Trusted Store OR
  • Use a valid TLS (Let’s Encrypt)

NOTE: You may have noticed that I am not concerned with security at in this article, and you are right! This is for a home lab, and I am focusing on get things done, of course security is a must, but for this one let’s keep it simple and fun, we are not creating things for production environment with sensitive stuff, we are having fun! So for this setup, I’m intentionally keeping things simple. Also, it’s is also a great opportunity for you to dig into security and do it yourself in your own way

# control-planes.tf

resource "proxmox_virtual_environment_vm" "control_planes" {
  count     = var.control_plane_nr
  name      = format("%s-%d", var.control_plane_naming, count.index + 1)
  node_name = var.target_node
  vm_id     = var.control_plane_id_range + count.index + 1

  on_boot = true
  started = true # Explicitly start VM after creation so cloud-init runs

  clone {
    vm_id        = var.template_vmid
    full         = true
    datastore_id = "local-lvm"
  }

  agent {
    enabled = true # The template must have qemu-guest-agent installed and enabled, otherwise this will not work correctly.
  }

  cpu {
    cores   = var.control_plane_cores
    sockets = var.control_plane_sockets
  }

  memory {
    dedicated = var.control_plane_memory
  }

  disk {
    datastore_id = "local-lvm"
    file_format  = "raw"
    interface    = "scsi0"
    size         = var.control_plane_disksize
  }

  network_device {
    bridge = var.bridge_network
    model  = "virtio"
    # firewall is omitted (defaults to false) -- no Proxmox firewall on this NIC
  }

  initialization {
    datastore_id = "local-lvm"
    interface    = "ide2"

    user_account {
      username = var.ciuser
      keys     = [var.ssh_keys]
    }
    ip_config {
      ipv4 {
        address = "${cidrhost(var.bridge_cidr_range, var.control_plane_network_range + count.index)}/24"
        gateway = cidrhost(var.bridge_cidr_range, 1)
      }
    }
  }

  # Proxmox may report network info changes after cloud-init runs;
  # ignore those to prevent unnecessary plan diffs.
  lifecycle {
    ignore_changes = [network_device]
  }

  tags = ["masters"]
}

In this file, we are setting up how the control planes should be created. This will use the templates that we created previously as baseline and here we are customizing the way we like.

The workers will be created in the same way you can check it out on repo that I provided above.

I want to show you the variables.tf file below, this is where the configuration shines.

We can pass all the parameters we need in this file, take some time and read it so you will understand it better.

variable "pm_api_token_id" {
  type    = string
  default = "<generated_token_id_on_proxmox_ui>" #Example: "terraform@pve!terraform-token"
}

variable "pm_api_token_secret" {
  type    = string
  default = "<generated_token_secret_on_proxmox_ui>" #Example: "abc123def456ghi789jkl012mno345pq"
}

#### Global Parameters ####
variable "bridge_network" {
  type    = string
  default = "vmbr0" #Update with your bridge network name if different (e.g. vmbr1)
}

variable "bridge_cidr_range" {
  type    = string
  default = "<your_bridge_cidr_range>" #Example: "192.168.1.0/24"
}

variable "ssh_keys" {
  type    = string
  default = "<your_ssh_public_keys>" #Example: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3... user@hostname"
}

variable "ciuser" {
  type    = string
  default = "proxuser" #Update with your desired cloud-init username
}

variable "target_node" {
  type    = string
  default = "proxmox-node1" #Update with the name of the Proxmox node where you want to create the VMs
}

variable "template_vmid" {
  type    = number
  default = 9000 #if you ran the template creation script, this should be 9000, otherwise update with the VMID of your cloud-init template
}

#### Control Plane Parameters ###
variable "control_plane_nr" {
  type    = number
  default = 3 #Update with the number of control plane nodes you want to create
}

variable "control_plane_id_range" {
  type    = number
  default = 400 #Update with the range of IDs for control plane nodes this will generate something like 400, 401, 402 for 3 control plane nodes. Make sure this range does not overlap with any existing VMIDs in your Proxmox cluster.
}

variable "control_plane_network_range" {
  type    = number
  default = 60 #The IP for three control planes will end with .60, .61, .62 if you use the default bridge_cidr_range of 192.168.1.0/24
}

variable "control_plane_naming" {
  type    = string
  default = "cp" #This will generate VM names like cp-400, cp-401, cp-402 for the control plane nodes. Update if you want a different naming convention.
}

variable "control_plane_cores" {
  type    = number
  default = 4 #Update with the number of CPU cores you want to allocate to each control plane node
}

variable "control_plane_sockets" {
  type    = number
  default = 1 #Update with the number of CPU sockets you want to allocate to each control plane node
}

variable "control_plane_memory" {
  type    = number
  default = 8192 #Update with the amount of RAM in MB you want to allocate to each control plane node (e.g. 8192 for 8GB) Feel free to use less
}

variable "control_plane_disksize" {
  type    = number
  default = 30 #Update with the disk size in GB you want to allocate to each control plane node (e.g. 30 for 30GB) Feel free to use less
}

#### Worker Node Parameters ###
variable "worker_nr" {
  type    = number
  default = 3 #Update with the number of worker nodes you want to create
}

variable "worker_id_range" {
  type    = number
  default = 500 #Update with the range of IDs for worker nodes this will generate something like 500, 501, 502 for 3 worker nodes. Make sure this range does not overlap with any existing VMIDs in your Proxmox cluster.
}

variable "worker_network_range" {
  type    = number
  default = 70 #The IP for three worker nodes will end with .70, .71, .72 if you use the default bridge_cidr_range of 192.168.1.0/24
}

variable "worker_naming" {
  type    = string
  default = "worker" #This will generate VM names like worker-500, worker-501, worker-502 for the worker nodes. Update if you want a different naming convention.
}

variable "worker_cores" {
  type    = number
  default = 2 #Update with the number of CPU cores you want to allocate to each worker node
}

variable "worker_sockets" {
  type    = number
  default = 1 #Update with the number of CPU sockets you want to allocate to each worker node
}

variable "worker_memory" {
  type    = number
  default = 4096 #Update with the amount of RAM in MB you want to allocate to each worker node (e.g. 4096 for 4GB) Feel free to use less
}

variable "worker_disksize" {
  type    = number
  default = 20 #Update with the disk size in GB you want to allocate to each worker node (e.g. 20 for 20GB) Feel free to use less
}

variable "proxmox_host_address" {
  type        = string
  description = "Proxmox VE API endpoint URL (e.g. https://192.168.0.50:8006/)"
  default     = "https://192.168.0.50:8006/" #Update with your Proxmox VE API endpoint
}

After you have configured it the way you like you just go and run

  • terraform validate
  • terraform plan
  • terraform apply – either with --auto-approve or not, you choose.

And see the magic, follow it up on Proxmox VE UI and see your VMs being created for your Kubernetes Cluster in the future.

But wait there is a inventory.tf file?! What is it?

Well seen, Watson!

This file right below is responsible to get the information we provided and create an Static Inventory file with a template named inventory.tmpl, so we will be able to use it with ANSIBLE. Cool ain’t it?!

resource "local_file" "ansible_inventory" {
  content = templatefile("inventory.tmpl",
    {
      control_plane = {
        index      = range(var.control_plane_nr)
        ip_address = [for i in range(var.control_plane_nr) : cidrhost(var.bridge_cidr_range, var.control_plane_network_range + i)]
        user       = [for i in range(var.control_plane_nr) : var.ciuser]
        vm_name    = proxmox_virtual_environment_vm.control_planes[*].name
      }
      worker = {
        index      = range(var.worker_nr)
        ip_address = [for i in range(var.worker_nr) : cidrhost(var.bridge_cidr_range, var.worker_network_range + i)]
        user       = [for i in range(var.worker_nr) : var.ciuser]
        vm_name    = proxmox_virtual_environment_vm.workers[*].name
      }
    }
  )
  filename        = "inventory.ini"
  file_permission = "0600"
}

Well, that’s it!

Nothing much to add at this stage, as it is part of a series posts, in the next post, I’ll bootstrap Kubernetes using kubeadm and configure networking with CNI.

Meanwhile you can get check the full repo here.
👉 Full source code: https://github.com/PellizzoniCode/proxmox_terraform

This is just the beginning of the journey.

In the next post, I’ll bootstrap a Kubernetes cluster using kubeadm and configure networking with a CNI plugin.

If you have any questions or suggestions, feel free to reach out — I’d love to hear your feedback.

See you in the next one =)

Add Comment

GioPellizzoni

Passionate about all things C# and .NET, I enjoy sharing insights from years of experience building scalable, maintainable software solutions. From unraveling complex software architecture problems to diving deep into the latest .NET features, my blog is a space for practical tips, in-depth analysis, and real-world examples. Whether you're a beginner or a seasoned developer, there's something here for everyone who's curious about the craft of building great software.

Get in touch

Would like you to get in touch? Please find me on Linkedin or send me an e-mail