Manual template creation works once. Install OS, configure, convert to template. Done. Until you need to update it. Then you do it again manually. And again. Eventually, no one remembers what’s in the template or how it was built.
A golden image pipeline treats templates like software: version controlled, automatically built, tested before use. When you need an update, you change the code and the pipeline builds a new template.
Images must be reproducible. If you can’t rebuild an identical image from code, you have a unique snowflake, not a template.
What Makes a Golden Image
A golden image is a VM template with:
- Known contents: Every package, config, and file is documented
- Reproducible build: Same inputs = same output
- Versioned: v1, v2, v3 with change history
- Tested: Verified before production use
- Immutable: Never modified after creation
Manual Pipeline (Simple Start)
Before Packer, understand the process:
1. Download Cloud Image
# Ubuntu cloud imagewget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
# Debian cloud imagewget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2
# AlmaLinux cloud imagewget https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow22. Create VM and Import
# Create VMqm create 9000 --name "ubuntu-2404-base" --memory 2048 --cores 2 \ --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci
# Import cloud imageqm importdisk 9000 noble-server-cloudimg-amd64.img local-zfs
# Attach diskqm set 9000 --scsi0 local-zfs:vm-9000-disk-0
# Add cloud-init driveqm set 9000 --ide2 local-zfs:cloudinit
# Boot settingsqm set 9000 --boot c --bootdisk scsi0qm set 9000 --serial0 socket --vga serial03. Customize (Optional)
# Start VM with temporary cloud-initqm set 9000 --ciuser temp --cipassword temp123qm start 9000
# SSH in and customizessh temp@<ip>sudo apt update && sudo apt upgrade -ysudo apt install -y qemu-guest-agent vim htop curl git
# Clean upsudo cloud-init cleansudo rm -rf /var/log/*.logsudo rm -rf /tmp/*sudo shutdown -h now4. Convert to Template
# Remove temporary cloud-init settingsqm set 9000 --delete ciuser,cipassword
# Convert to templateqm template 90005. Version and Document
# Rename with versionqm set 9000 --name "ubuntu-2404-v1"
# Add descriptionqm set 9000 --description "Ubuntu 24.04 LTSVersion: 1Date: 2025-01-08Base: noble-server-cloudimg-amd64.imgPackages: qemu-guest-agent, vim, htop, curl, gitChanges: Initial release"Packer Pipeline (Automated)
Packer automates the entire process.
Install Packer
# HashiCorp repositorywget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpgecho "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.listapt update && apt install packer
# Install Proxmox pluginpacker plugins install github.com/hashicorp/proxmoxDirectory Structure
packer-templates/├── templates/│ ├── ubuntu-2404/│ │ ├── ubuntu-2404.pkr.hcl│ │ ├── variables.pkr.hcl│ │ ├── http/│ │ │ └── user-data│ │ └── scripts/│ │ ├── base.sh│ │ ├── cleanup.sh│ │ └── cloud-init.sh│ └── debian-12/│ └── ...├── common/│ ├── scripts/│ │ └── common-packages.sh│ └── files/│ └── motd└── MakefilePacker Template
packer { required_plugins { proxmox = { version = ">= 1.1.0" source = "github.com/hashicorp/proxmox" } }}
source "proxmox-iso" "ubuntu-2404" { # Proxmox connection proxmox_url = var.proxmox_url username = var.proxmox_username token = var.proxmox_token node = var.proxmox_node insecure_skip_tls_verify = true
# VM settings vm_id = var.vm_id vm_name = "ubuntu-2404-v${var.version}"
# ISO (get checksum from releases.ubuntu.com) iso_url = "https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso" iso_checksum = "sha256:YOUR_CHECKSUM_HERE" # Get from SHA256SUMS file iso_storage_pool = "local" unmount_iso = true
# Hardware cores = 2 memory = 2048 cpu_type = "host"
# Disk scsi_controller = "virtio-scsi-pci" disks { disk_size = "32G" storage_pool = var.storage_pool type = "scsi" }
# Network network_adapters { model = "virtio" bridge = "vmbr0" }
# Cloud-init cloud_init = true cloud_init_storage_pool = var.storage_pool
# Boot boot_command = [ "<esc><wait>", "e<wait>", "<down><down><down><end>", " autoinstall ds=nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/", "<f10>" ]
boot_wait = "5s"
# HTTP server for autoinstall http_directory = "http"
# SSH ssh_username = "packer" ssh_password = "packer" ssh_timeout = "20m"
# Template template_name = "ubuntu-2404-v${var.version}" template_description = "Ubuntu 24.04 LTS - Built ${timestamp()}"}
build { sources = ["source.proxmox-iso.ubuntu-2404"]
# Base configuration provisioner "shell" { scripts = [ "scripts/base.sh", "../../common/scripts/common-packages.sh" ] }
# Copy files provisioner "file" { source = "../../common/files/motd" destination = "/tmp/motd" }
provisioner "shell" { inline = ["sudo mv /tmp/motd /etc/motd"] }
# Cleanup provisioner "shell" { scripts = [ "scripts/cleanup.sh", "scripts/cloud-init.sh" ] }}Variables
variable "proxmox_url" { type = string default = "https://proxmox.lab.local:8006/api2/json"}
variable "proxmox_username" { type = string default = "packer@pve!automation"}
variable "proxmox_token" { type = string sensitive = true}
variable "proxmox_node" { type = string default = "pve1"}
variable "vm_id" { type = number default = 9000}
variable "storage_pool" { type = string default = "local-zfs"}
variable "version" { type = string default = "1"}Provisioning Scripts
#!/bin/bashset -ex
# Wait for cloud-initcloud-init status --wait
# Update systemsudo apt updatesudo apt upgrade -y
# Install packagessudo apt install -y \ qemu-guest-agent \ vim \ htop \ curl \ wget \ git \ jq \ unzip
# Enable guest agentsudo systemctl enable qemu-guest-agent#!/bin/bashset -ex
# Clean aptsudo apt autoremove -ysudo apt clean
# Clean logssudo journalctl --rotatesudo journalctl --vacuum-time=1ssudo rm -rf /var/log/*.logsudo rm -rf /var/log/*.gz
# Clean tempsudo rm -rf /tmp/*sudo rm -rf /var/tmp/*
# Clean SSH keys (regenerate on first boot)sudo rm -f /etc/ssh/ssh_host_*
# Clean machine-idsudo truncate -s 0 /etc/machine-idsudo rm -f /var/lib/dbus/machine-id
# Clean historyhistory -csudo rm -f /root/.bash_historysudo rm -f /home/*/.bash_history#!/bin/bashset -ex
# Reset cloud-initsudo cloud-init clean
# Remove network config (cloud-init will regenerate)sudo rm -f /etc/netplan/*.yaml
# The template is now readyecho "Template preparation complete"Build Template
# Set tokenexport PKR_VAR_proxmox_token="xxxx-xxxx-xxxx"
# Validatepacker validate templates/ubuntu-2404/
# Buildpacker build -var "version=2" templates/ubuntu-2404/Cloud Image Pipeline (Faster)
Skip ISO install by starting with cloud images:
source "proxmox-clone" "ubuntu-2404" { # Clone from uploaded cloud image clone_vm = "ubuntu-2404-cloud-base"
# ... rest of config}Pre-upload cloud image:
# Download and upload cloud image to Proxmoxwget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
# Create base VM from cloud image (one-time)qm create 8000 --name "ubuntu-2404-cloud-base" ...qm importdisk 8000 noble-server-cloudimg-amd64.img local-zfsqm set 8000 --scsi0 local-zfs:vm-8000-disk-0qm template 8000Packer then clones from base, customizes, creates versioned template.
CI/CD Integration
GitLab CI
stages: - validate - build - test
variables: PACKER_VERSION: "1.10.0"
validate: stage: validate script: - packer init templates/ubuntu-2404/ - packer validate templates/ubuntu-2404/
build: stage: build script: - packer build -var "version=${CI_PIPELINE_IID}" templates/ubuntu-2404/ only: - main
test: stage: test script: - ./scripts/test-template.sh ubuntu-2404-v${CI_PIPELINE_IID} only: - mainGitHub Actions
name: Build Template
on: push: branches: [main] paths: - 'templates/**'
jobs: build: runs-on: self-hosted # Need access to Proxmox steps: - uses: actions/checkout@v4
- name: Setup Packer uses: hashicorp/setup-packer@main with: version: "1.10.0"
- name: Init Packer run: packer init templates/ubuntu-2404/
- name: Build Template env: PKR_VAR_proxmox_token: ${{ secrets.PROXMOX_TOKEN }} run: | packer build -var "version=${{ github.run_number }}" templates/ubuntu-2404/Testing Templates
Automated Test Script
#!/bin/bashTEMPLATE=$1TEST_VM_ID=9999
echo "Testing template: ${TEMPLATE}"
# Clone templateqm clone $(qm list | grep "${TEMPLATE}" | awk '{print $1}') ${TEST_VM_ID} --name "template-test"
# Configure cloud-initqm set ${TEST_VM_ID} --ciuser test --sshkeys ~/.ssh/id_ed25519.pubqm set ${TEST_VM_ID} --ipconfig0 ip=dhcp
# Start VMqm start ${TEST_VM_ID}
# Wait for bootsleep 60
# Get IPIP=$(qm guest cmd ${TEST_VM_ID} network-get-interfaces | jq -r '.[1]["ip-addresses"][0]["ip-address"]')
# Run testsecho "Testing SSH..."ssh -o StrictHostKeyChecking=no test@${IP} "echo 'SSH OK'"
echo "Testing packages..."ssh test@${IP} "which vim htop curl git"
echo "Testing guest agent..."qm agent ${TEST_VM_ID} ping
# Cleanupqm stop ${TEST_VM_ID}qm destroy ${TEST_VM_ID}
echo "All tests passed!"Test Checklist
[ ] VM boots successfully[ ] Cloud-init completes[ ] SSH works with key auth[ ] Required packages installed[ ] Guest agent responds[ ] No leftover sensitive data[ ] Machine-id regenerated[ ] SSH host keys regeneratedVersioning Strategy
Semantic Versioning
ubuntu-2404-v1.0.0 # Major: Breaking changesubuntu-2404-v1.1.0 # Minor: New featuresubuntu-2404-v1.1.1 # Patch: Bug fixesDate-Based Versioning
ubuntu-2404-20250108 # Date of buildubuntu-2404-202501 # Month of buildBuild Number
ubuntu-2404-b42 # CI build numberTracking Active Version
# Create symlink-style aliasqm set 9002 --description "... LATEST: true"
# Or use tagsqm set 9002 --tags "latest,ubuntu,2404"The Lesson
Images must be reproducible. Otherwise, they’re unique snowflakes.
A template you clicked together manually is:
- Undocumented (what’s in it?)
- Unreproducible (can you rebuild it exactly?)
- Untested (does it actually work?)
- Unversioned (which version is this?)
A golden image pipeline produces templates that are:
- Documented (code shows everything)
- Reproducible (same code = same image)
- Tested (automated tests before use)
- Versioned (clear history of changes)
The investment in automation pays off when:
- Security update needed → rebuild all templates
- New requirement → change code, rebuild
- “What’s in this template?” → read the code
- Audit requirement → show build history
Start simple: document your manual process. Then automate it. Then add CI/CD. Each step makes your templates more reliable and less mysterious.