I've been [using Proxmox](/ditching-vsphere-for-proxmox/) in my [homelab](/homelab/) for a little while now, and I recently expanded the environment a bit with the addition of two HP Elite Mini 800 G9 computers. I figured it was time to start automating the process of building and maintaining my VM templates. I already had functional [Packer templates for VMware](https://github.com/jbowdre/packer-vsphere-templates) so I used that content as a starting point for the [Proxmox builds](https://github.com/jbowdre/packer-proxmox-templates).
Once I had the builds working locally, I then explored how to automate them. I wound up setting up a GitHub Actions workflow and a rootless runner to perform the builds for me. I'll write up notes on *that* part of the process soon, but first let's run through how I set up Packer at all. That will be plenty to chew on for now.
This post will cover *a lot* of the Packer implementation details but may gloss over some general setup steps; you'll need at least passing familiarity with [Packer](https://www.packer.io/) and [Vault](https://www.vaultproject.io/) to take this on.
The only configuration I did on the Proxmox side of things was to [create a user account](https://pve.proxmox.com/pve-docs/chapter-pveum.html#pveum_users) that Packer could use. I called it `packer` but didn't set a password for it. Instead, I set up an [API token](https://pve.proxmox.com/pve-docs/chapter-pveum.html#pveum_tokens) for that account, making sure to **uncheck** the "Privilege Separation" box so that the token would inherit the same permissions as the user itself.
To use the token, I needed the ID (in the form `USERNAME@REALM!TOKENNAME`) and the UUID-looking secret, which is only displayed once so I made sure to record it in a safe place.
Speaking of privileges, the [Proxmox ISO integration documentation](https://developer.hashicorp.com/packer/integrations/hashicorp/proxmox/latest/components/builder/iso) doesn't offer any details on the minimum required permissions, and none of my attempts worked until I eventually assigned the Administrator role to the `packer` user. (I plan on doing more testing to narrow the scope a bit before running this in production, but this will do for my homelab purposes.)
I use [Vault](https://github.com/hashicorp/vault) to hold the configuration details for the template builds - not just traditional secrets like usernames and passwords, but basically *every environment-specific setting* as well. This approach lets others use my Packer code without having to change much (if any) of it; every value that I expect to change between environments is retrieved from Vault at run time.
Because this is just a homelab, I'm using [Vault in Docker](https://hub.docker.com/r/hashicorp/vault), and I'm making it available within my tailnet with [Tailscale Serve](/tailscale-serve-docker-compose-sidecar/) using the following `docker-compose.yaml`
And this `./serve-config.json` to tell Tailscale that it should proxy the Vault container's port `8200` and make it available on my tailnet at `https://vault.tailnet-name.ts.net/`:
I defined a [policy](https://developer.hashicorp.com/vault/docs/concepts/policies) which will grant the bearer read-only access to the data stored in the `packer` secrets as well as the ability to create and update its own token:
| `insecure_connection` | `true` | set to `false` if your Proxmox host has a valid certificate |
| `iso_path` | `local:iso` | path for (existing) ISO storage |
| `iso_storage_pool` | `local` | pool for storing created/uploaded ISOs |
| `network_bridge` | `vmbr0` | bridge the VM's NIC will be attached to |
| `node` | `proxmox1` | node name where the VM will be built |
| `token_id` | `packer@pve!packer` | ID for an [API token](https://pve.proxmox.com/wiki/User_Management#pveum_tokens), in the form `USERNAME@REALM!TOKENNAME` |
| `token_secret` | `3fc69f[...]d2077eda` | secret key for the token |
| `vm_storage_pool` | `zfs-pool` | storage pool where the VM will be created |
`linux` holds values for the created VM template(s)
Let's take a quick look at the variable definitions in `variables.pkr.hcl` first. All it does is define the available variables along with their type, provide a brief description about what the variable should hold or be used for, and set sane defaults for some of them.
{{% notice note "Input Variables and Local Variables" %}}
There are two types of variables used with Packer:
- **[Input Variables](https://developer.hashicorp.com/packer/docs/templates/hcl_templates/variables)** may have defined defaults, can be overridden, but cannot be changed after that initial override. They serve as build parameters, allowing aspects of the build to be altered without having to change the source code.
- **[Local Variables](https://developer.hashicorp.com/packer/docs/templates/hcl_templates/locals)** are useful for assigning a name to an expression. These expressions are evaluated at run time and can work with input variables, other local variables, data sources, and built-in functions.
Input variables are great for those predefined values, while local variables can be really handy for stuff that needs to be more dynamic.
{{% /notice %}}
```hcl
# torchlight! {"lineNumbers":true}
/*
Ubuntu Server 22.04 LTS variables using the Packer Builder for Proxmox.
*/
// BLOCK: variable
// Defines the input variables.
// Virtual Machine Settings
variable "remove_cdrom" {
type = bool
description = "Remove the virtual CD-ROM(s)."
default = true
}
variable "vm_name" {
type = string
description = "Name of the new template to create."
}
variable "vm_cpu_cores" {
type = number
description = "The number of virtual CPUs cores per socket. (e.g. '1')"
}
variable "vm_cpu_count" {
type = number
description = "The number of virtual CPUs. (e.g. '2')"
}
variable "vm_cpu_type" { # [tl! collapse:start]
type = string
description = "The virtual machine CPU type. (e.g. 'host')"
}
variable "vm_disk_size" {
type = string
description = "The size for the virtual disk (e.g. '60G')"
default = "60G"
}
variable "vm_bios_type" {
type = string
description = "The virtual machine BIOS type (e.g. 'ovmf' or 'seabios')"
default = "ovmf"
}
variable "vm_guest_os_keyboard" {
type = string
description = "The guest operating system keyboard input."
default = "us"
}
variable "vm_guest_os_language" {
type = string
description = "The guest operating system lanugage."
default = "en_US"
}
variable "vm_guest_os_timezone" {
type = string
description = "The guest operating system timezone."
default = "UTC"
}
variable "vm_guest_os_type" {
type = string
description = "The guest operating system type. (e.g. 'l26' for Linux 2.6+)"
}
variable "vm_mem_size" {
type = number
description = "The size for the virtual memory in MB. (e.g. '2048')"
}
variable "vm_network_model" {
type = string
description = "The virtual network adapter type. (e.g. 'e1000', 'vmxnet3', or 'virtio')"
default = "virtio"
}
variable "vm_scsi_controller" {
type = string
description = "The virtual SCSI controller type. (e.g. 'virtio-scsi-single')"
default = "virtio-scsi-single"
}
// VM Guest Partition Sizes
variable "vm_guest_part_audit" {
type = number
description = "Size of the /var/log/audit partition in MB."
}
variable "vm_guest_part_boot" {
type = number
description = "Size of the /boot partition in MB."
}
variable "vm_guest_part_efi" {
type = number
description = "Size of the /boot/efi partition in MB."
}
variable "vm_guest_part_home" {
type = number
description = "Size of the /home partition in MB."
}
variable "vm_guest_part_log" {
type = number
description = "Size of the /var/log partition in MB."
}
variable "vm_guest_part_root" {
type = number
description = "Size of the /var partition in MB. Set to 0 to consume all remaining free space."
default = 0
}
variable "vm_guest_part_swap" {
type = number
description = "Size of the swap partition in MB."
}
variable "vm_guest_part_tmp" {
type = number
description = "Size of the /tmp partition in MB."
}
variable "vm_guest_part_var" {
type = number
description = "Size of the /var partition in MB."
}
variable "vm_guest_part_vartmp" {
type = number
description = "Size of the /var/tmp partition in MB."
}
// Removable Media Settings
variable "cd_label" {
type = string
description = "CD Label"
default = "cidata"
}
variable "iso_checksum_type" {
type = string
description = "The checksum algorithm used by the vendor. (e.g. 'sha256')"
}
variable "iso_checksum_value" {
type = string
description = "The checksum value provided by the vendor."
}
variable "iso_file" {
type = string
description = "The file name of the ISO image used by the vendor. (e.g. 'ubuntu-<version>-live-server-amd64.iso')"
}
variable "iso_url" {
type = string
description = "The URL source of the ISO image. (e.g. 'https://mirror.example.com/.../os.iso')"
}
// Boot Settings
variable "vm_boot_command" {
type = list(string)
description = "The virtual machine boot command."
default = []
}
variable "vm_boot_wait" {
type = string
description = "The time to wait before boot."
}
// Communicator Settings and Credentials
variable "build_remove_keys" {
type = bool
description = "If true, Packer will attempt to remove its temporary key from ~/.ssh/authorized_keys and /root/.ssh/authorized_keys"
default = true
}
variable "communicator_insecure" {
type = bool
description = "If true, do not check server certificate chain and host name"
default = true
}
variable "communicator_port" {
type = string
description = "The port for the communicator protocol."
}
variable "communicator_ssl" {
type = bool
description = "If true, use SSL"
default = true
}
variable "communicator_timeout" {
type = string
description = "The timeout for the communicator protocol."
}
// Provisioner Settings
variable "cloud_init_apt_packages" {
type = list(string)
description = "A list of apt packages to install during the subiquity cloud-init installer."
default = []
}
variable "cloud_init_apt_mirror" {
type = string
description = "Sets the default apt mirror during the subiquity cloud-init installer."
default = ""
}
variable "post_install_scripts" {
type = list(string)
description = "A list of scripts and their relative paths to transfer and run after OS install."
default = []
}
variable "pre_final_scripts" {
type = list(string)
description = "A list of scripts and their relative paths to transfer and run before finalization."
default = []
} # [tl! collapse:end]
```
(Collapsed because I think you get the idea, but feel free to expand to view the whole thing.)
Now that I've told Packer about what variables I intend to use, I can then go about setting values for those variables. That's done in the `linux-server.auto.pkrvars.hcl` file. I've highlighted the most interesting bits:
As you can see, this sets up a lot of the properties which aren't strictly environment specific, like:
- partition sizes (ll. 14-23),
- virtual hardware settings (ll. 26-34),
- the hash and URL for the installer ISO (ll. 37-40),
- the command to be run at first boot to start the installer in unattended mode (ll. 47-53),
- a list of packages to install during the `cloud-init` install phase, primarily the sort that might be needed during later steps (ll. 62-67),
- a list of scripts to execute after `cloud-init` (ll. 71-78),
- and a list of scripts to run at the very end of the process (ll. 82-86).
We'll look at the specifics of those scripts shortly, but first...
#### Packer Build File
Let's explore the Packer build file, `linux-server.pkr.hcl`, which is the set of instructions used by Packer for performing the deployment. It's what ties everything else together.
This one is kind of complex so we'll take it a block or two at a time.
It starts by setting the required minimum version of Packer and identifying what plugins (and versions) will be used to perform the build. I'm using the [Packer plugin for Proxmox](https://github.com/hashicorp/packer-plugin-proxmox) for executing the build on Proxmox (*duh*), and the [Packer SSH key plugin](https://github.com/ivoronin/packer-plugin-sshkey) to simplify handling of SSH keys (we'll see how in the next block).
This first set of `locals {}` blocks take advantage of the dynamic nature of local variables. They call the [`vault` function](https://developer.hashicorp.com/packer/docs/templates/hcl_templates/functions/contextual/vault) to retrieve secrets from Vault and hold them as local variables. It's broken into a section for "standard" variables, which just hold configuration information like URLs and usernames, and one for "sensitive" variables like passwords and API tokens. The sensitive ones get `sensitive = true` to make sure they won't be printed in the logs anywhere.
- and use the [`templatefile()` function](https://developer.hashicorp.com/packer/docs/templates/hcl_templates/functions/file/templatefile) to process the `cloud-init` config file and insert appropriate variables (ll. 78-101)
The `source {}` block is where we get to the meat of the operation; it handles the actual creation of the virtual machine. This matches the input and local variables to the Packer options that tell it
- that `local.data_source_content` (which contains the rendered `cloud-init` configuration - we'll look at that in a moment) should be mounted as a virtual CD-ROM device (ll. 144-149),
- to download and verify the installer ISO from `var.iso_url`, save it to `local.proxmox_iso_storage_pool`, and mount it as the primary CD-ROM device (ll. 150-155),
- what command to run at boot to start the install process (l. 159),
- and how to communicate with the VM once the install is under way (ll. 163-168).
By this point, we've got a functional virtual machine running on the Proxmox host but there are still some additional tasks to perform before it can be converted to a template. That's where the `build {}` block comes in: it connects to the VM and runs a few `provisioner` steps:
- The `file` provisioner is used to copy any certificate files into the VM at `/tmp` (ll. 181-182) and to copy the []`join-domain.sh` script](https://github.com/jbowdre/packer-proxmox-templates/blob/main/scripts/linux/join-domain.sh) into the initial user's home directory (ll. 186-187).
- The first `shell` provisioner loops through and executes all the scripts listed in `var.post_install_scripts` (ll. 191-193). The last script in that list (`update-packages.sh`) finishes with a reboot for good measure.
- The second `shell` provisioner (ll. 197-203) waits for 30 seconds for the reboot to complete before it picks up with the remainder of the scripts, and it passes in the bootloader password for use by the hardening script.
Now let's back up a bit and drill into that `cloud-init` template file, `builds/linux/ubuntu/22-04-lts/data/user-data.pkrtpl.hcl`, which is loaded during the `source {}` block to tell the OS installer how to configure things on the initial boot.
The file follows the basic YAML-based syntax of a standard [cloud config file](https://cloudinit.readthedocs.io/en/latest/reference/examples.html), but with some [HCL templating](https://developer.hashicorp.com/packer/docs/templates/hcl_templates/functions/file/templatefile) to pull in certain values from elsewhere.
Some of the key tasks handled by this configuration include:
- stopping the SSH server (l. 10),
- setting the hostname (l. 12), inserting username and password (ll. 13-14),
`cloud-init` will reboot the VM once it completes, and when it comes back online it will have a DHCP-issued IP address and the accounts/credentials needed for Packer to log in via SSH and continue the setup in the `build {}` block.
After the `cloud-init` setup is completed, Packer control gets passed to the `build {}` block and the provisioners there run through a series of scripts to perform additional configuration of the guest OS. I split the scripts into two sets, which I called `post_install_scripts` and `pre_final_scripts`, with a reboot that happens in between them.
##### Post Install
The post install scripts run after the `cloud-init` installation has completed, and (depending on the exact Linux flavor) may include:
1.`wait-for-cloud-init.sh`, which just checks to confirm that `cloud-init` has truly finished before proceeding:
```shell
# torchlight! {"lineNumbers":true}
#!/usr/bin/env bash
# waits for cloud-init to finish before proceeding
set -eu
echo '>> Waiting for cloud-init...'
while [ ! -f /var/lib/cloud/instance/boot-finished ]; do
sleep 1
done
```
2.`cleanup-subiquity.sh` to remove the default network configuration generated by the Ubuntu installer:
```shell
# torchlight! {"lineNumbers":true}
#!/usr/bin/env bash
# cleans up cloud-init config from subiquity
set -eu
if [ -f /etc/cloud/cloud.cfg.d/99-installer.cfg ]; then
sudo rm /etc/cloud/cloud.cfg.d/99-installer.cfg
echo 'Deleting subiquity cloud-init config'
fi
if [ -f /etc/cloud/cloud.cfg.d/subiquity-disable-cloudinit-networking.cfg ]; then
3.`install-ca-certs.sh` to install any trusted CA certs which were in the `certs/` folder of the Packer environment and copied to `/tmp/certs/` in the guest:
```shell
# torchlight! {"lineNumbers":true}
#!/usr/bin/env bash
# installs trusted CA certs from /tmp/certs/
set -eu
if awk -F= '/^ID/{print $2}' /etc/os-release | grep -q debian; then