As I mentioned in the newsletter for 5/30/2025, one of the key elements to making a team productive is to ensure consistency of environments and tooling. A big part of that is always a developers machine, and making sure that they can be spun up and spun down whenever necessary.
For me, my single favorite every extension for vscode is the remote development extension. But the key to this is having your linux virtual machine in the cloud. And as part of that I wanted to enable a virtual machine image for doing development and in this case kubernetes work.
Kubernetes is a great example, because there are so many tools that can make things possible. When it comes to doing this work, I used a tool called Packer from Hashicorp to build my images.
What is Packer?
At the core, Packer is a tool for building virtual machine images in code, and making it easy to create those “golden images” you want to use when you stand up new virtual machines. Under the hood, the way packer works, is that when you build an image, it actually deploys the vm in the cloud, and then runs automation called “provisioners” on the machine to update it’s configuration, and ultimately captures the machine as an image and cleans up the machine.
First question, how do you install packer, which full installation instructions can be found here. Below are the commands for ubuntu:
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install packer
Let’s take a look at what it looks like to build a packer image in code. The first thing to know is that all packer files have the “.pkr.hcl” extension on files.
First know that you must define the cloud you are working in by setting up the following in the file:
packer {
required_plugins {
azure = {
version = ">= 1.0.0"
source = "github.com/hashicorp/azure"
}
}
}
From there, you need to define the source image, and below is an example of a source I built out.
source "azure-arm" "ubuntu2404" {
subscription_id = var.subscription_id
cloud_environment_name = "AzureUSGovernmentCloud"
location = var.location
managed_image_resource_group_name = "packer-vm-images"
managed_image_name = "ubuntu2404-aks-linux-jumpbox-image"
vm_size = "Standard_DS1_v2"
os_type = "Linux"
image_publisher = "Canonical"
image_offer = "ubuntu-24_04-lts"
image_sku = "server"
image_version = "latest"
azure_tags = {
environment = "image-library"
}
use_azure_cli_auth = true
}
Now worth mentioning, above the “use_azure_cli_auth” means that it is going to use the existing logged in credentials for executing the vm creation and image capture. I generally recommend using this approach as it enables you to isolate the login logic away from the Infrastructure-as-Code.
From there, you define provisioners like below:
build {
sources = ["source.azure-arm.ubuntu2404"]
provisioner "shell" {
environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
inline = [
"echo 'Running the initial setup upgrade...'",
"sudo apt-get update",
"sudo apt-get upgrade -y"
]
}
You can find a complete list of provisioners here.
How do I deploy a linux vm with this?
Here is a full sample file of standing up a linux vm:
variable "subscription_id" {
type = string
default = ""
description = "Azure Subscription ID"
}
variable "location" {
type = string
default = "usgovvirginia"
description = "Azure region for the image"
}
packer {
required_plugins {
azure = {
version = ">= 1.0.0"
source = "github.com/hashicorp/azure"
}
}
}
source "azure-arm" "ubuntu2404" {
subscription_id = var.subscription_id
cloud_environment_name = "AzureUSGovernmentCloud"
location = var.location
managed_image_resource_group_name = "packer-vm-images"
managed_image_name = "ubuntu2404-aks-linux-jumpbox-image"
vm_size = "Standard_DS1_v2"
os_type = "Linux"
image_publisher = "Canonical"
image_offer = "ubuntu-24_04-lts"
image_sku = "server"
image_version = "latest"
azure_tags = {
environment = "image-library"
}
use_azure_cli_auth = true
}
build {
sources = ["source.azure-arm.ubuntu2404"]
provisioner "shell" {
environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
inline = [
"echo 'Running the initial setup upgrade...'",
"sudo apt-get update",
"sudo apt-get upgrade -y"
]
}
provisioner "shell" {
environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
inline = [
"echo 'Installing kubectl...'",
"curl -LO \"https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl\"",
"sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl",
"kubectl version --client"
]
}
provisioner "shell" {
environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
inline = [
"echo 'Install Azure CLI...'",
"curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash"
]
}
provisioner "shell" {
environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
inline = [
"echo 'Installing Helm...'",
"curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3",
"chmod 700 get_helm.sh",
"./get_helm.sh"
]
}
provisioner "shell" {
environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
inline = [
"echo 'Installing k9s...'",
"wget https://github.com/derailed/k9s/releases/download/v0.50.6/k9s_linux_amd64.deb",
"sudo apt install ./k9s_linux_amd64.deb",
"k9s version",
"rm k9s_linux_amd64.deb"
]
}
provisioner "shell" {
environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
inline = [
"echo 'Installing Terraform...'",
"wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg",
"echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/hashicorp.list",
"sudo apt update && sudo apt install terraform"
]
}
provisioner "shell" {
environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
inline = [
"echo 'Installing RegClient...'",
"curl -L https://github.com/regclient/regclient/releases/latest/download/regctl-linux-amd64 >regctl",
"chmod 755 regctl"
]
}
provisioner "shell" {
environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
inline = [
"echo 'Installing Docker...'",
"sudo apt-get install ca-certificates curl",
"sudo install -m 0755 -d /etc/apt/keyrings",
"sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc",
"sudo chmod a+r /etc/apt/keyrings/docker.asc",
"echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable\" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null",
"sudo apt-get update"
]
}
}
To run this build you would need to first initialize your environment, with the following command:
packer init ./kubernetes-linux-jumpbox.pkr.hcl
And then the following to perform the build:
SUBSCRIPTION_ID="<Id of the subscription>"
packer build -var "subscription_id=$SUBSCRIPTION_ID" -var "location=usgovvirginia" -force ./kubernetes-linux-jumpbox.pkr.hcl
This process can take several minutes to complete depending on the complexity of the operations being performed.
How do I deploy a windows vm with this?
Yes, it absolutely does. Here’s a sample of a windows machine with the same outcome for tooling:
variable "subscription_id" {
type = string
default = ""
description = "Azure Subscription ID"
}
variable "location" {
type = string
default = "usgovvirginia"
description = "Azure region for the image"
}
variable "packer_username" {
type = string
default = "packer"
description = "Username for the Packer image"
}
variable "packer_password" {
type = string
description = "Password for the Packer image"
}
packer {
required_plugins {
azure = {
version = ">= 1.0.0"
source = "github.com/hashicorp/azure"
}
}
}
source "azure-arm" "windows11" {
subscription_id = var.subscription_id
cloud_environment_name = "AzureUSGovernmentCloud"
location = var.location
managed_image_resource_group_name = "packer-vm-images"
managed_image_name = "windows11-aks-windows-jumpbox-image"
communicator = "winrm"
winrm_use_ssl = true
winrm_insecure = true
winrm_timeout = "5m"
winrm_username = var.packer_username
winrm_password = var.packer_password
vm_size = "Standard_DS1_v2"
os_type = "Windows"
image_publisher = "microsoftwindowsdesktop"
image_offer = "windows-11"
image_sku = "win11-22h2-entn"
image_version = "latest"
azure_tags = {
environment = "image-library"
}
use_azure_cli_auth = true
}
build {
sources = ["source.azure-arm.windows11"]
provisioner "powershell" {
inline = [
"Write-Host 'Running the initial setup upgrade...'",
"Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine",
"Install-WindowsFeature -Name UpdateServices",
"Install-WindowsUpdate -AcceptAll -IgnoreReboot"
]
}
provisioner "powershell" {
inline = [
"Write-Host 'Installing Docker Desktop...'",
"Invoke-WebRequest -UseBasicParsing -OutFile DockerDesktopInstaller.exe https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe",
"Start-Process -FilePath .\\DockerDesktopInstaller.exe -ArgumentList '/quiet', '/norestart' -Wait",
"Remove-Item .\\DockerDesktopInstaller.exe",
"Write-Host 'Docker Desktop installation complete.'"
]
}
# kubectl
provisioner "powershell" {
inline = [
"Write-Host 'Installing kubectl...'",
"Invoke-WebRequest -UseBasicParsing -OutFile kubectl.exe https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/windows/amd64/kubectl.exe",
"Move-Item -Path .\\kubectl.exe -Destination 'C:\\Program Files\\kubectl\\kubectl.exe'",
"Write-Host 'kubectl installation complete.'"
]
}
# Azure CLI
provisioner "powershell" {
inline = [
"Write-Host 'Installing Azure CLI...'",
"Invoke-WebRequest -UseBasicParsing -OutFile AzureCLI.msi https://aka.ms/installazurecliwindows",
"Start-Process msiexec.exe -ArgumentList '/i', 'AzureCLI.msi', '/quiet', '/norestart' -Wait",
"Remove-Item .\\AzureCLI.msi",
"Write-Host 'Azure CLI installation complete.'"
]
}
# helm
provisioner "powershell" {
inline = [
"Write-Host 'Installing Helm...'",
"Invoke-WebRequest -UseBasicParsing -OutFile get_helm.ps1 https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3",
"Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process",
".\\get_helm.ps1",
"Remove-Item .\\get_helm.ps1",
"Write-Host 'Helm installation complete.'"
]
}
# k9s
# Terraform
provisioner "powershell" {
inline = [
"Write-Host 'Installing Terraform...'",
"Invoke-WebRequest -UseBasicParsing -OutFile terraform.zip https://releases.hashicorp.com/terraform/1.5.0/terraform_1.5.0_windows_amd64.zip",
"Expand-Archive -Path terraform.zip -DestinationPath 'C:\\Program Files\\Terraform'",
"Remove-Item .\\terraform.zip",
"Write-Host 'Terraform installation complete.'"
]
}
# regclient
provisioner "powershell" {
inline = [
"Write-Host 'Installing RegClient...'",
"Invoke-WebRequest -UseBasicParsing -OutFile regctl.exe"
]
}
}
To run this build you would need to first initialize your environment, with the following command:
packer init ./kubernetes-windows-jumpbox.pkr.hcl
And then the following to perform the build:
SUBSCRIPTION_ID="<Id of the subscription>"
PACKER_PASSWORD="<The password for the vm>"
packer build -var "subscription_id=$SUBSCRIPTION_ID" -var "location=usgovvirginia" -var "packer_password=$PACKER_PASSWORD" -force ./kubernetes-windows-jumpbox.pkr.hcl
This process can take several minutes to complete depending on the complexity of the operations being performed.