Linux Containers are nice, but sometimes you just need something more. Something more dedicated, something more standalone, something more… similar to a regular server?

There are multiple reasons why you would like to go with a Virtual Machine (VM) over a Linux Container (LXC) in your endeavours. Maybe your service does not work nicely in a shared environment (containers are sharing the kernel with the host :)) or maybe you are just old school and want a dedicated system.

Worry not! Proxmox supports Virtual Machines and so do I. Let me get you quickly through the setup and, as always, a little automation.

Your own virtual machine

If you are wondering what a virtual machine is here is an article from wikipedia: Virtual Machine. Or if you prefer simplicity over complexity - for now you can treat the VMs as full blown systems running on another system. There is much more to that but this assumption should get you through. The rest will come.

Getting the ISO image

Whenever you install a new operating system or some software that requires an installation image (remember DVDs?) you will need an ISO image of it. This is also the case here. Get to the website of the OS of your choosing and download the installer ISO. Debian or Ubuntu Server are my recommendations. If you were following my previous articles you are probably familiar with the Ubuntu page already.

Now navigate to your ProxmoxVE instance -> Datacenter -> your-name -> local -> ISO images. On the top you will see two buttons: [Upload] and [Download from URL]. Select the first one and point it to your downloaded ISO. Alternatively you can use the latter and point it directly to download URL. Mind you it is the URL from which the image was downloaded, NOT the downloads page (I wish I could say this less confusingly). You can get this by right clicking the ISO you downloaded in your browser’s downloads and selecting copy url or similar option.

Once everything is done refresh the page and you should see your new, shiny ISO image on the ProxmoxVE instance.

Spinning up a VM

On the top there is a very convincing button [Create VM] - Click it.

As always here are things that I suggest you change for the first time. Later it’s up to you.

  1. General

Name: vm-test

[Next]

  1. OS

ISO image: select my image

[Next]

The rest let’s leave as is for now. Skip to Confirm, tick [x] Start after created and [Finish].

Once your VM is up you will need to go through the installation and voila. You have your new VM.

A little automation

It’s nice that you can do it by hand and I know I did it more than a couple of times (and still do sometimes for a quick test). Real talk now. You want to spin those guys up in an automated way. The time you spent creating one could give you tens. Let’s get to it.

Repository cleanup

We are still on our beautiful HACK repository. Until today the only module residing there was an LXC one. We will need another one for VMs. Plus we would like to share the resources and .tfvars around. This requires a little bit of refactoring. I will try to make it as painless as possible, promise.

  1. Shared directory

First we would need to create a shared directory that will take care of the templates. Create one in the terraform folder alongside modules, e.g. terraform/proxmox/shared

  1. Move the templates and tfvars

Move over the templates.tf and terraform.tfvars from test (or however your project directory is named) to shared. We will keep those in one place rather than separate them in case you would like to have more states managed by Terraform in the future.

  1. Add main.tf and variables.tf

Time for configurations:

# ./terraform/proxmox/shared/main.tf

terraform {
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "0.98.1"
    }
  }
}

provider "proxmox" {
  endpoint  = var.proxmox_api_url
  api_token = "${var.proxmox_token_id}=${var.proxmox_token_secret}"
  insecure  = false
}
# ./terraform/proxmox/shared/variables.tf

# Proxmox connection
variable "proxmox_node" {
  type        = string
  description = "Proxmox node name"
}

variable "proxmox_api_url" {
  type        = string
  description = "Proxmox API URL"
}

variable "proxmox_token_id" {
  type        = string
  description = "Proxmox API token ID"
}

variable "proxmox_token_secret" {
  type        = string
  sensitive   = true
  description = "Proxmox API token secret"
}

variable "admin_password" {
  type        = string
  sensitive   = true
  description = "Admin user password"
}

Simple, yet powerful. These allow us to use the shared Terraform state.

  1. Create outputs.tf

We will need to create outputs.tf as an addition, as the shared state will only share the data that you tell it to share.

# ./terraform/proxmox/shared/outputs.tf

output "ubuntu_2404" {
  value = proxmox_virtual_environment_download_file.ubuntu_2404.id
}
  1. Add shared state to test directory

We will add a small block to the end of main.tf in test directory to let it know about our new, shared state.

#./terraform/proxmox/test/main.tf

# ...

data "terraform_remote_state" "shared" {
  backend = "local"
  config = {
    path = "../shared/terraform.tfstate"
  }
}
  1. Alter definition of the lxc-test (or other)

Now that we have a shared state, please change a line in your LXCs’ definitions to point them to the proper image.

# e.g. ./terraform/proxmox/test/lxc-test.tf

# ...

template_file_id = data.terraform_remote_state.shared.outputs.ubuntu_2404

# ...
  1. (Optional) Makefile updates

As we will need now to run shared Terraform targets before provisioning our services with tf-test-… we can create some calls for us to use.

# ...

# Terraform
## Shared
tf-shared-init:
	bash scripts/tf-common.sh shared init

tf-shared-plan:
	bash scripts/tf-common.sh shared plan

tf-shared-apply:
	bash scripts/tf-common.sh shared apply 

tf-shared-destroy:
	bash scripts/tf-common.sh shared destroy

# ...

# Provisioning
tf-init:
	${MAKE} tf-shared-init
	${MAKE} tf-test-init

tf-plan:
	${MAKE} tf-shared-plan
	${MAKE} tf-test-plan

tf-apply:
	${MAKE} tf-shared-apply
	${MAKE} tf-test-apply

tf-destroy:
	${MAKE} tf-shared-destroy
	${MAKE} tf-test-destroy
  1. Test it

Pray and spray! Test if everything works like intended (like before) and let’s proceed!

Cloud init

Before we jump into creating the VM I would like to make you aware of the cloud-init concept. This is one of the ways that could be used to provision the VM and seemingly is the closest to using an LXC template like we used to before. It is a preconfiguration for the server that you can use to configure the server during boot time. It can be also used with bare metal servers (like the Wyse we set up before).

Here is the description from official documentation:

Cloud-init is the industry standard multi-distribution method for cross-platform cloud instance initialization. It is supported across all major public cloud providers, provisioning systems for private cloud infrastructure, and bare-metal installations. During boot, cloud-init identifies the cloud it is running on and initializes the system accordingly. Cloud instances will automatically be provisioned during first boot with networking, storage, SSH keys, packages and various other system aspects already configured. Cloud-init provides the necessary glue between launching a cloud instance and connecting to it so that it works as expected. For cloud users, cloud-init provides no-install first-boot configuration management of a cloud instance. For cloud providers, it provides instance setup that can be integrated with your cloud.

The way to deploy

  1. Add templates for virtual machines

First things first. We would need a template or two that will serve as a basis for our machines. We already have a place to store them in shared/templates.tf. These additions will make sure we have the images ready to work on.

content_type = "import"

is not enabled in ProxmoxVE by default. Please see your local storage on the Proxmox Node and turn it on.

# ./terraform/proxmox/shared/templates.tf

# ...

resource "proxmox_virtual_environment_download_file" "ubuntu_2604_cloud" {
  node_name    = var.proxmox_node
  content_type = "import"
  datastore_id = "local"

  url       = "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img"
  file_name = "resolute-server-cloudimg-amd64.qcow2"

  overwrite = true
}

resource "proxmox_virtual_environment_download_file" "debian_13_cloud" {
  node_name    = var.proxmox_node
  content_type = "import"
  datastore_id = "local"

  url       = "https://gemmei.ftp.acc.umu.se/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2"
  file_name = "debian-13-generic-amd64.qcow2"

  overwrite = true
}
  1. Create virtual machine module

We have images. Now onto a module. Create a new module directory terraform/proxmox/modules/vm and let’s put some definitions there. This will be a bit different from the LXC one, but only a bit.

Please make note we are putting here

agent {
  enabled = true
}

which refers to qemu-guest-agent. It is not part of defualt cloud-init images. Make sure to install it on your own (or via Ansible!) OR just change it to false.

# ./terraform/proxmox/modules/vm/main.tf

terraform {
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "0.98.1"
    }
  }
}

locals {
  repository_root = "${path.module}/../../../.."
}

resource "proxmox_virtual_environment_vm" "template" {
  node_name   = var.proxmox_node
  vm_id       = var.vm_id
  name        = var.hostname
  tags        = var.tags
  description = "${var.hostname}\n VM. Managed by Terraform."

  started       = true

  agent {
    enabled = true
  }

  initialization {
    datastore_id = var.storage
    ip_config {
      ipv4 {
        address = var.ip
        gateway = var.gateway
      }
    }
    user_account {
      username = var.admin_username
      password = trimspace(var.admin_password)
      keys     = [trimspace(data.local_file.ssh_public_key.content)]
    }
  }

  network_device {
    bridge = var.bridge
  }

  operating_system {
    type = "l26"
  }

  cpu {
    cores   = var.cores
    sockets = var.sockets
    type    = var.cpu_type
  }

  memory {
    dedicated = var.memory
  }

  disk {
    datastore_id = var.storage
    import_from  = var.template_file_id
    interface    = "virtio0"
    iothread     = true
    size         = var.disk_size
    discard      = "on"
    file_format  = "raw"
  }
}
# ./terraform/proxmox/modules/vm/variables.tf

# Proxmox
## Configuration
variable "proxmox_node" {
  type        = string
  description = "Proxmox node name"
}

variable "vm_id" {
  type        = number
  description = "Proxmox VMID"
}

variable "tags" {
  type        = list(string)
  description = "List of Proxmox tags e.g. [\"iac\", \"vm\"]"
  default     = []
}

variable "hostname" {
  type        = string
  description = "VM hostname"
}

variable "template_file_id" {
  type        = string
  description = "cloud-init OS template file ID from proxmox_virtual_environment_download_file"
}

variable "admin_username" {
  type        = string
  description = "Admin username"
}

variable "admin_password" {
  type        = string
  sensitive   = true
  description = "Admin user password"
}

## Network
variable "ip" {
  type        = string
  description = "Static IP with prefix e.g. 192.168.1.21/24"
}

variable "gateway" {
  type        = string
  description = "Default gateway"
}

variable "bridge" {
  type        = string
  default     = "vmbr0"
  description = "Proxmox network bridge"
}

## CPU
variable "cores" {
  type    = number
  default = 2
}

variable "sockets" {
  type    = number
  default = 1
}

variable "cpu_type" {
  type        = string
  default     = "x86-64-v2-AES"
  description = "QEMU CPU type"
}

## Memory
variable "memory" {
  type        = number
  default     = 2048
  description = "RAM in MB"
}

## Storage
variable "storage" {
  type    = string
  default = "local-lvm"
}

variable "disk_size" {
  type        = number
  default     = 20
  description = "Disk size in GB"
}
# ./terraform/proxmox/modules/vm/data.tf

data "local_file" "ssh_public_key" {
  filename = pathexpand("~/.ssh/id_ed25519.pub")
}
  1. Create vm-test.tf

Now to put it to use we will create the vm-test.tf in our test directory. It will serve as a definition of a real VM we want. Just like lxc-test.tf or whatever you already added there.

# ./terraform/proxmox/test/vm-test.tf

module "vm_test" {
  source = "../modules/vm"

  vm_id            = var.vm_ids["vm_test"]
  hostname         = var.hostnames["vm_test"]
  tags             = var.tags["vm_test"]
  proxmox_node     = var.proxmox_node
  template_file_id = data.terraform_remote_state.shared.outputs.debian_13_cloud
  admin_username   = "admin"
  admin_password   = var.admin_password
  cores            = 2
  memory           = 2048
  disk_size        = 16
  ip               = var.ips["vm_test"]
  gateway          = "192.168.1.1"
}

As you can see we refer to some variables there. Make sure that the ones relevant for you are updated in terraform/proxmox/test/variables.tf. Once you are done…

  1. Test it!

You can run now our makefile procedure

make tf-init
make tf-plan
make tf-apply

If all goes well after a couple of minutes you will have a fully functional Debian VM alongside your lxc-test.

Feel free to experiment and add more if you want!

Summary

After today you have more tools in your toolbelt. We have gone through preparation and creation of VMs in ProxmoxVE, so you can spin them on your demand and keep them in your IAC repository.

Don’t forget to update the documentation, so you don’t lose it or just treat your code as a reminder of what goes where.

And most importantly…

HAVE FUN!