Packer in Proxmox VE
Building Templates in Proxmox VE using Packer
Automating VM templates is something that comes in mind of any Homelab or DevOps enthusiast. Recently, I managed to get a final version of this automation using Packer in Proxmox
What started as a single Packer script evolved into a modular, secure pipeline using SOPS and age. Here is the story of that journey, the hurdles I hit, and the final architecture.
🛠️ Prerequisites & Installation
Before diving into the journey, you’ll need a few tools in your belt. Here is how to get set up on a Linux-based system (like Ubuntu or WSL):
1. HashiCorp Packer
Packer is the engine that builds the VM.
1
2
3
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install packer
2. SOPS (Secrets Operations)
SOPS is used to encrypt our secrets file.
1
2
3
4
5
# Download the latest binary from GitHub
Q_VERSION=$(curl -s https://api.github.com/repos/getsops/sops/releases/latest | grep -Po '"tag_name": "v\K[0-9.]+')
wget "https://github.com/getsops/sops/releases/download/v${Q_VERSION}/sops-v${Q_VERSION}.linux.amd64" -O sops
chmod +x sops
sudo mv sops /usr/local/bin/
3. Age - File Encryption
Age is the modern encryption backend that SOPS uses to manage keys.
1
2
3
4
sudo apt install age
# Generate your master key (Save this somewhere safe!)
mkdir -p ~/.sops
age-keygen -o ~/.sops/age.agekey
4. Create API Configuration (The “Least Privilege” Setup)
Before Packer can talk to Proxmox, we need to give it a specific identity and the right set of “keys.” Instead of using the root account, we’ll create a dedicated role and user.
Create a dedicated Role for Packer
You can do this in two ways, via Proxmox VE GUI or console.
On GUI:
- Go to Datacenter -> Permissions -> Roles -> Create
- Specify the name
- Add the necessary Privilages:
VM.Allocate VM.Config.Disk VM.Config.CPU VM.Config.Memory VM.Config.Network VM.Config.HWType VM.Config.Options VM.Monitor VM.Audit VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit Sys.Audit
On CLI:
- Go to shell
- Type the following:
1
pveum role add PackerRole -privs "VM.Allocate VM.Config.Disk VM.Config.CPU VM.Config.Memory VM.Config.Network VM.Config.HWType VM.Config.Options VM.Monitor VM.Audit VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit Sys.Audit"
Create a Packer user
on GUI:
- Go to Datacenter -> Permissions -> Users -> Add
- Type the name, e.g.
packer
on CLI:
- Go to shell
- Type the following:
1
pveum user add packer@pam
Generate API token
API tokens are better than passwords because they can be revoked easily.
On GUI:
- Go to Datacenter -> Permissions -> API Tokens -> Add
- Select the user created early
- Type the same name on ID field, e.g.
packer - Uncheck Privilage Separation
- Add a comment if you want or expiration date
- Hit the
AddbuttonImportant: Copy the
valueshown after this, it won’t be shown again! Place it somewhere safe!
On CLI:
- Go to shell
- Type the following:
1
pveum user token add packer@pam packer-token --privsep 0Important: Copy the
valueshown after this, it won’t be shown again! Place it somewhere safe!
Bind the Role to the user
Now, we tell Proxmox that our new user (and its token) has the permissions defined in PackerRole across the entire cluster (/).
On GUI:
- Go to Datacenter -> Permissions -> Groups -> Create
- Name the group, e.g.
packer - Go to Datacenter -> Permissions -> Users
- Go to packer user -> Edit
- Assign
packergroup
On CLI:
- Go to shell
- Type the following:
1 2
pveum acl modify / -user packer@pam -role PackerRole pveum acl modify / -token 'packer@pam!packer' -role PackerRole
The Starting Point: The “Monolith” Problem
Initially, everything was in one giant ubuntu-2404.pkr.hcl file. It worked, but it was messy. My Proxmox API tokens, SSH passwords, and hardware specs were all mashed together.
The Goal:
- Modularize: Treat Packer like Terraform (split variables, sources, and build blocks).
- Secure: Use SOPS (Secrets Operation) to encrypt sensitive data so I can safely commit my code to GitHub.
Challenge #1: Modularizing HCL2
Packer HCL2 allows you to split files, but it behaves slightly differently than Terraform.
The Fix: I split the monolith into:
packer.pkr.hcl(Plugins)variables.pkr.hcl(Definitions)source.pkr.hcl(Hardware/ISO)build.pkr.hcl(Provisioning scripts)
Key Takeaway: When you split files, you can no longer run packer build file.pkr.hcl. You must run packer build . to tell Packer to merge all files in the current directory.
Challenge #2: The SOPS & Age Learning Curve
I wanted to use SOPS with age keys for encryption. This is where the real “debugging journey” began.
The “Malformed Key” Mystery
I kept getting malformed recipient errors. The Lesson: Age keys are checksummed. A single extra space or a missing character during a copy-paste into .sops.yaml will break everything. I learned to use age-keygen -y to derive the public key directly from the private key to ensure accuracy.
The “Invalid Character ‘P’” Error
When I tried to encrypt my secrets file, SOPS crashed with: Could not unmarshal input data: invalid character 'P'. The Lesson: I was trying to use HCL syntax inside a .json file. SOPS is a structured encryption tool. If the file extension is .json, the content must be valid JSON. Once I converted my variables to a JSON object, SOPS worked like a charm. 😄
Challenge #3: The Packer Pipe Problem
In a perfect world, I wanted to decrypt secrets directly into memory using process substitution: packer build -var-file=<(sops -d secrets.enc.json) .
The Problem: Packer refused to read it, complaining: Could not guess format of /dev/fd/63. Packer requires a file extension (.json or .hcl) to know how to parse variables.
The Solution: The “Trap” Strategy. I wrote a build.sh wrapper that:
- Decrypts the secrets to a temporary
tmp_secrets.jsonfile. - Uses a shell
trapto ensure that even if the build fails or I hitCtrl+C, the temporary plaintext file is immediately deleted.
1
2
3
4
# The magic shell trap
trap 'rm -f tmp_secrets.json' EXIT
sops -d secrets.enc.json > tmp_secrets.json
packer build -var-file=tmp_secrets.json .
Key Takeaways
- JSON for Secrets, HCL for Definitions Keep your variable definitions in HCL so you can write comments and documentation. Keep your values in JSON because SOPS handles it better, allowing you to see which “keys” changed in Git while keeping the “values” encrypted.
- The Power of
.sops.yamlDon’t pass your public keys in the command line. Create a.sops.yamlfile. It allows you to run a simplesops -e -i secrets.jsonwithout remembering long strings of characters. - Cleanup is Part of the Build A template is only good if it’s “clean.” My build process now includes a shell provisioner that:
- Truncates
/etc/machine-id - Cleans
cloud-init - Removes SSH host keys. This ensures that every VM I clone from this template gets a unique identity.
- Truncates
Repository
You can find the full source code, including the modular HCL files and the build script, in my GitHub repository:
👉 Link to your GitHub Repo Here
Conclusion
Moving from a single plaintext file to a modular, encrypted pipeline felt like “overkill” at first, but the peace of mind is worth it. I can now push my Homelab configuration to GitHub without fear, knowing my Proxmox credentials are safe behind an Age key.
Tools used:
- Proxmox (Hypervisor)
- Packer (Image Automation)
- SOPS (Encryption)
- Age (Key Management)
Next:
I will be using Terraform to deploy VMs using this template created with Packer.
