DEMO/Infrastructure

← Back to dashboard

Building an image with Packer

Audience: operator · Profile: demo · Read this when: you are building a custom image from a base image and a provisioner script.

Part of Image Management.

The "I want a custom image baked from scratch" path. Four builders ship today, picked by the cloud the build runs in:

Builder Cloud Output
amazon-ebs AWS EBS-backed AMI
azure-arm Azure Managed Image in your subscription
googlecompute GCP Custom Image in your project
oracle-oci OCI Custom Image in your compartment

The Packer template is generated in-process from the deploy form (source AMI / image, instance type, provisioner script, output naming). Templates aren't pre-staged in the repo because build inputs are too varied to template statically. The build job streams Packer stdout/stderr to the live job log so you can watch the provision steps.

GCP source image: a family or an exact name

The GCP form's Source Image control has two modes, and the difference matters for anything you did not get from a public catalogue:

The optional Image project under Custom image emits source_image_project_id. Leave it blank unless the image lives in a separate shared image project — unset, the builder searches the build project first and the standard public image projects after it.

Picking the wrong mode is a build that runs, and then fails a minute in against the wrong OS. The provisioners that check what they were given (provisioners/net/vyos-cell.sh refuses a non-VyOS image) report it clearly; one that does not will happily bake the wrong thing. The job log's template step names the source it used either way.

OCI build prerequisites

The OCI builder has two requirements the other three don't, because Packer runs inside the dashboard's worker container — outside your tenancy's VCN — and reaches the throwaway build instance over the public internet:

A third, softer one: the base image's architecture must match the shape. Oracle encodes aarch64 in platform image display names, so the form warns when an Arm image is paired with an x86 shape or vice versa — advisory, because a custom image may be named anything.

The shape has to exist in your region

LaunchInstance answers three different mistakes with the same unattributed 404 NotAuthorizedOrNotFound — no field named, one second into the build:

  1. the shape isn't offered in that availability domain,
  2. the base image doesn't support the shape,
  3. a genuine IAM policy denial.

Case 2 can instead come back as 400 InvalidParameter naming both ("Shape VM.Standard.A1.Flex is not valid for image ocid1.image…"), which is the one legible failure of the three — but only after Packer has already created a key pair and started the build.

The first is the easy trap, because the default build shape is VM.Standard.E2.1.Micro, the Always-Free AMD micro — and E2 shapes exist only in the older OCI regions. A newer region such as us-chicago-1 offers no E2 shape at all, so the out-of-the-box default is unlaunchable there. Worse, the free tier has no x86 substitute: the other Always-Free shape, VM.Standard.A1.Flex, is Ampere, so it pairs only with an aarch64 base image.

There is a harder version of this trap, because ListShapes is scoped by your service limits, not just by what hardware the region has. A trial tenancy sees only the shapes it holds quota for — measured live in us-chicago-1 on 2026-08-19: all three availability domains returned exactly three shapes, every one of them Ampere (BM.Standard.A1.160, VM.Standard.A1.Flex, VM.Standard.A2.Flex), while the x86 Oracle-Autonomous-Linux-10.1 image listed 69 compatible shapes. The two lists are disjoint, so no x86 image can be built anywhere in that region under that tenancy, at any price. Switching to an aarch64 base image is the only way forward, not a matter of paying for a bigger shape.

The build form fills its shape list live and drops a default the region can't launch, and both the API route and the job runner precheck the placement before Packer starts (oci_service.check_launch_placement) — so cases 1 and 2 now fail with a message naming the shape and listing what would work. The two OCI deploy endpoints run the same check for the same reason — see the shape_not_launchable entry in Cloud VMs.

That precheck fails open when a lookup is silent — it can't reach OCI, or one of the two lists (ListShapes, or the image's compatibility entries) comes back empty and so tells us nothing. It does not fail open when both lists answer and share nothing: an empty intersection between two non-empty lists is OCI saying plainly that nothing offered here boots this image, and reading that as "unknown" is exactly what let an Ampere shape under an x86 image on 2026-08-19. So a bare 404 that survives all of the above is most likely case 3 — check the policies on the compartment holding the subnet and the image.

To build an x86 image in a region without E2, pick a paid shape (VM.Standard.E5.Flex at 1 OCPU costs cents for a build that lasts minutes) and acknowledge the free-tier warning — but check the shape picker first: if the only shapes offered are A1/A2, your tenancy has no x86 quota there and no amount of paying will change it. To stay inside the free tier, or to build at all in an Ampere-only tenancy, use an aarch64 base image with VM.Standard.A1.Flex.

Set storage_oci_bucket on the Storage page to have builds export to VHD and register in the image hub; without it the build still produces the custom image and reports export_skipped.

After the build succeeds, the resulting image's ID is captured in Job.extra_data and the artefact (when exported) is uploaded to your storage backend.

Loading the provisioner script from storage

Every Packer form (AWS / Azure / GCP / OCI) has a Load from storage dropdown next to the Provisioner Script field. It lists every .sh / .bash asset across every configured storage backend (local, S3, Azure Blob, GCS) tagged with the backend it lives on — setup.sh (S3), setup.sh (Local) — so identical filenames on different backends are unambiguous.

Selecting an entry fetches the script via GET /api/storage/fetch/{backend}/{name} and drops the text into the textarea. The operator can still tweak it before submitting; a blue subtitle echoes which backend + name the script came from so an edited version doesn't quietly drift from its stored copy.

This means you can keep your hardening scripts version-controlled on disk or in object storage, upload them once via Storage Management, and pick them from the dropdown for every build instead of copy-pasting. Useful when the same script is reused across cloud providers — store it once on a cloud backend and load it for all three builds.

Passing environment variables to the provisioner

Once a provisioner script is loaded, every Packer form (AWS / Azure / GCP / OCI) shows an Environment variables panel — a repeatable name/value table whose entries are handed to the shell provisioner as environment_vars. Use it to parameterise a reused script (package versions, feature flags, registration endpoints) instead of forking the script per build.

Each row has a secret ref toggle:

Secret values are deliberately kept out of the template: a resolved secret is passed through a Packer sensitive variable (PKR_VAR_* injected into the build subprocess), and the template only ever contains the declaration plus a ${var.…} reference. So a secret never lands in the archived template (when "Archive template" is on, the .pkr.hcl is uploaded to your storage backend) and Packer redacts it from the build log. Literal values, having no such protection, should not be used for secrets.

Environment-variable names are validated ([A-Za-z_][A-Za-z0-9_]*) so a name can't break out of the environment_vars array.

BeyondTrust provisioner options. Above the generic table sits a dedicated BeyondTrust provisioner options block (admin user, Install EPM-L deb/rpm, Entitle SSH public key) — a convenience layer over the same mechanism that drives the bt-ready-* scripts. It sets BT_ADMIN_USER / BT_ENTITLE_PUBKEY and, for EPM-L, resolves a fresh BeyondTrust presigned package URL into BT_EPML_URL via the EPM-L integration at build-launch (those links expire ~30 min). On Azure the panel shows only for Linux builds — the Windows path uses a PowerShell provisioner, not the shell environment_vars mechanism.

Windows builds (Azure)

The Azure Packer form builds Windows managed images too — pick a Windows preset (Windows Server 2022 / 2022 Core) or set os_type: "Windows" on POST /api/packer/azure/build. Differences from the Linux path:

A ready-made starter provisioner ships at provisioners/beyondtrust/bt-ready-windows.ps1: it installs OpenSSH Server, enables RDP + NLA, sets the SSH default shell to PowerShell, and (optionally) authorizes an SSH public key — turning a Windows Server Core image into one you can reach by ssh like a Linux VM, plus agentless RDP through the Gateway. Upload it to /storage (the layer tags .ps1 as powershell) and Load it, or paste it in. See provisioners/beyondtrust/README.md.

Deploying a Windows image (deploy form, bulk deploy, or a Desktops pool) generates a strong local-admin password per VM, stores it in the configured secrets backend (the database backend works out of the box), and records only the (backend, ref) pair in job metadata. Retrieve it per VM via Azure → VMs → Password (GET /api/azure/vms/{name}/admin-password). Azure can't inject an SSH key into a Windows VM at deploy time (that's Linux-only), so the BeyondTrust Shell Jump (SSH) step is skipped at deploy — but if the image baked OpenSSH + your key (above), you can SSH in directly, and otherwise broker access with an RDP jump item. The image registry records os_type per image so cross-cloud promotes import Windows VHDs as Windows (registry rows predating the column default to Linux).

Windows 11 desktop images differ from the Windows Server path above. Win 11 requires Trusted Launch (Secure Boot + vTPM), and Azure cannot create a managed image from a Trusted Launch VM — so the builder publishes a Compute Gallery image version instead of a managed image. Pick the Windows 11 multi-session (24H2 AVD) preset (microsoftwindowsdesktop / windows-11 / win11-24h2-avd), which sets trusted_launch. What the build does differently:

Multi-session ≠ AVD here. The 24H2 AVD SKU is a multi-session OS, but the dashboard does not install the AVD agent or register an AVD host pool — VMs stay PRA-RDP-brokered, one per seat; the multi-session capability is latent.

Prerequisites. Windows 11 client images are only visible to eligible offers — Visual Studio / dev-test subscriptions, or AVD / Microsoft 365 E3+ (multi-tenant hosting rights). On an ineligible subscription the build VM creation fails with an image-not-found error. The build and deploy sizes must be Gen2 / Trusted-Launch capable (e.g. Standard_D2s_v3) — B-series do not support Trusted Launch.