Packer Variables, Var Files and Best Practices
Use Packer variables, locals, .pkrvars.hcl and auto var files, PKR_VAR_ environment variables and sensitive values, then test and build images in CI.
TLDR: Declare inputs with variable blocks and compute derived values in locals. Set values with PKR_VAR_<name> environment variables, *.auto.pkrvars.hcl files (loaded automatically), -var-file and -var, in that order of increasing priority. Every variable you set must be declared, and every variable you use must have a value. Mark secrets sensitive, keep templates split into files in one directory, check them with packer fmt -check and packer validate on every change, and build in CI with short-lived credentials.
Variables let you write a template once and use it for different regions, environments, versions or clouds. This part covers variables in depth, with real output for the errors you are most likely to hit, and then the practices that keep an image pipeline maintainable: project layout, testing, versioning, CI/CD and security.
Prerequisites
- A working template (Part 3).
- For the CI/CD section, a GitHub or GitLab repository and an AWS role you can assume from CI.
Defining Variables
Declare each input in a variable block. By convention, variables go in a variables.pkr.hcl file next to the template:
variable "region" {
type = string
default = "us-east-1"
description = "AWS region to build in"
}
variable "app_version" {
type = string
description = "Application version baked into the image"
# No default: the value must be provided for every build
validation {
condition = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.app_version))
error_message = "The app_version must be a semantic version such as 1.4.0."
}
}
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "enable_monitoring" {
type = bool
default = true
}
variable "extra_packages" {
type = list(string)
default = ["curl", "jq"]
}
variable "tags" {
type = map(string)
default = {
Team = "platform"
ManagedBy = "packer"
}
}
variable "api_token" {
type = string
sensitive = true
default = ""
}
To try these variables without a cloud account, put this build.pkr.hcl in the same directory. It uses the built-in null builder and only prints three of the values. Most local runs in this part use this directory:
source "null" "app" {
communicator = "none"
}
build {
sources = ["source.null.app"]
provisioner "shell-local" {
inline = ["echo app_version=${var.app_version} region=${var.region} token=${var.api_token}"]
}
}
The supported types are string, number, bool, list(...), set(...), map(...), object({...}) and tuple([...]). Since Packer 1.16, object attributes can be marked optional(), optionally with a default, so callers can leave them out:
variable "disk" {
type = object({
size_gb = number
type = optional(string, "gp3")
})
default = { size_gb = 20 }
}
Using Variables
Reference a variable as var.<name>, on its own or inside a string:
source "amazon-ebs" "app" {
region = var.region
instance_type = var.instance_type
ami_name = "app-${var.app_version}-${local.timestamp}"
tags = var.tags
}
build {
sources = ["source.amazon-ebs.app"]
provisioner "shell" {
environment_vars = [
"APP_VERSION=${var.app_version}",
"MONITORING=${var.enable_monitoring}",
]
inline = ["sudo apt-get install -y ${join(" ", var.extra_packages)}"]
}
}
Two limits to know: a variable's default cannot refer to other variables (use a local for derived values), and the packer block, including required_plugins, cannot use variables at all.
Providing Variable Values
You can set a variable in five ways. When the same variable is set in several places, the later source in this list wins:
- the
defaultin thevariableblock; - an environment variable named
PKR_VAR_<name>, such asPKR_VAR_region; - any
*.auto.pkrvars.hcl(or*.auto.pkrvars.json) files in the directory, in alphabetical order of their names; - each
-var-file=<file>on the command line, in the order you give them (a later file wins); - each
-var name=valueon the command line, which beats every var file, wherever it appears on the command line.
Here is the order in action, in a new directory with a single region variable (default us-east-1), a null build like the one above, and a prod.pkrvars.hcl file that sets region = "eu-central-1". packer console prints the value of an expression without building anything:
Variable Files
A variable file (.pkrvars.hcl) contains only assignments, no blocks:
region = "us-east-1"
app_version = "2.0.0"
instance_type = "t3.small"
tags = {
Team = "platform"
ManagedBy = "packer"
Environment = "production"
}
Pass one or more of them with -var-file; later files override earlier ones, so a common pattern is a shared file plus one per environment:
packer build -var-file=common.pkrvars.hcl -var-file=production.pkrvars.hcl .
The Default Variable File: *.auto.pkrvars.hcl
Unlike Terraform's terraform.tfvars, Packer has no single default variable file name. Instead, it loads every file whose name ends in .auto.pkrvars.hcl (or .auto.pkrvars.json) in the template directory automatically, without a flag. Use them for values everybody on the team shares, or for personal overrides that you keep out of Git. Because they load silently, they can surprise people; when a value is not what you expect, check for an auto file first.
Every Value Needs a Declaration
Packer warns when a variable file sets a name that no variable block declares, which usually means a typo:
The template is still valid, and region keeps its old value. A -var on the command line for an undeclared name is an error, not a warning.
The "Unset Variable" Error
A variable without a default must get a value from one of the sources above. If it does not, packer validate and packer build fail with an error that names the variable:
To fix it, pass the value (-var, a var file or PKR_VAR_app_version), or give the variable a default if it is not required.
Splitting a Template Into Files
Packer treats all .pkr.hcl files in a directory as one configuration. A common layout is variables.pkr.hcl, sources.pkr.hcl and build.pkr.hcl. Always run Packer on the directory. If you pass a single file, Packer reads only that file and does not see the variables declared in the others:
Validation Rules
A validation block rejects bad values before a build starts. The condition must be true for valid values, and can() turns an error (such as a regular expression that does not match) into false:
Validate everything that ends up in names and tags: AWS, Azure and Google Cloud each have their own rules for image names, and it is better to fail in a second than after a ten-minute build.
Sensitive Variables
Mark secrets with sensitive = true. Packer then replaces their value with <sensitive> everywhere in its output:
sensitive only hides the value in Packer's output. It does not encrypt anything, and if a provisioner writes the value to a file, it is in the image. Commands on the build machine can print it too, for example with set -x.
Locals
Locals are values computed from variables, functions and other locals. They keep long expressions out of your sources:
variable "app_name" {
type = string
default = "web"
}
variable "app_version" {
type = string
default = "1.4.0"
}
variable "git_commit" {
type = string
default = "unknown"
}
locals {
build_time = formatdate("YYYYMMDD-hhmm", timestamp())
image_name = "${var.app_name}-${replace(var.app_version, ".", "-")}-${local.build_time}"
common_tags = {
Application = var.app_name
Version = var.app_version
GitCommit = var.git_commit
BuildTime = local.build_time
}
}
packer console is the quickest way to check an expression before you put it in a template:
The time in the output is the moment of the run, in UTC. Packer evaluates a local once per run, so every source that uses local.build_time gets the same value. Call timestamp() in one local rather than in each source.
Secrets: env(), Vault and AWS Secrets Manager
Never write secrets into templates or committed var files. Packer has three ways to fetch them at run time:
# 1. From the environment, in a variable default
variable "api_token" {
type = string
sensitive = true
default = env("API_TOKEN")
}
# 2. From HashiCorp Vault (reads VAULT_ADDR and VAULT_TOKEN)
locals {
db_password = vault("secret/data/packer/db", "password")
}
# 3. From AWS Secrets Manager with a data source
data "amazon-secretsmanager" "registry" {
name = "packer/registry"
key = "password"
}
vault() works in locals (as above) but not in variable defaults. Packer evaluates locals even for packer validate, so a template that calls vault() needs Vault access to validate. env() is the opposite: it only works in a variable's default. Anywhere else, Packer does not know it:
Read the value into a variable (default = env("HOME")) and use var.home instead. For anything secret, prefer a secrets manager over environment variables, and give the build machine only the secrets that a step really needs.
packer validate does not run data sources, so their values are unknown during validation, and a few checks reject unknown values (for example, environment_vars reports Environment variable not in format 'key=value': <unknown>). Add -evaluate-datasources to resolve them during validation; it calls the cloud API, so it needs credentials.
Project Structure
Keep one directory per image and share scripts and files between them:
packer-images/
├── images/
│ ├── base/ # hardened base image, built weekly
│ │ ├── build.pkr.hcl
│ │ ├── sources.pkr.hcl
│ │ ├── variables.pkr.hcl
│ │ └── production.pkrvars.hcl
│ └── web/ # application image, built from base
│ ├── build.pkr.hcl
│ ├── sources.pkr.hcl
│ ├── variables.pkr.hcl
│ ├── staging.pkrvars.hcl
│ └── production.pkrvars.hcl
├── scripts/
│ ├── install-nginx.sh
│ └── cleanup.sh
├── files/
│ ├── nginx.conf
│ └── app.service
├── ansible/
│ ├── playbook.yml
│ └── roles/
├── tests/
│ └── test-image.sh
├── .github/workflows/packer.yml
└── Makefile
Paths in a template are relative to the directory you run Packer from, not to the template file. Use ${path.root} to make them relative to the template directory instead, for example script = "${path.root}/../../scripts/cleanup.sh".
A Makefile keeps the commands the same for everybody:
IMAGE ?= web
ENV ?= staging
DIR = images/$(IMAGE)
.PHONY: init fmt validate build test-local
init:
packer init $(DIR)
fmt:
packer fmt -recursive .
validate: init
packer fmt -check -recursive .
packer validate -var-file=$(DIR)/$(ENV).pkrvars.hcl $(DIR)
build: validate
packer build -var-file=$(DIR)/$(ENV).pkrvars.hcl $(DIR)
test-local: init
packer build -only='docker.*' -var-file=$(DIR)/$(ENV).pkrvars.hcl $(DIR)
Testing Images
Test at three levels, from cheapest to most thorough.
1. Before the build: packer fmt -check -recursive . and packer validate catch formatting, syntax, unset variables and invalid settings in seconds. For templates like the ones in this guide they need no cloud credentials, because validation skips data sources; functions such as vault() in locals are the exception. Run them on every pull request.
2. A local build: add a docker source with discard = true next to the cloud sources (see Part 4) and run packer build -only='docker.*' .. Your provisioning scripts run in a container in about a minute, with no cloud costs.
3. After the build: launch an instance from the new image and check that it does its job. A small script is enough to start with:
#!/usr/bin/env bash
# tests/test-image.sh: launch the newest AMI from manifest.json and check that Nginx answers
set -euo pipefail
ARTIFACT=$(jq -r '.builds[-1].artifact_id' manifest.json) # region:ami-id
export AWS_REGION=${ARTIFACT%%:*} AWS_DEFAULT_REGION=${ARTIFACT%%:*}
AMI_ID=${ARTIFACT#*:}
INSTANCE_ID=$(aws ec2 run-instances \
--image-id "$AMI_ID" \
--instance-type t3.micro \
--subnet-id "$TEST_SUBNET_ID" \
--security-group-ids "$TEST_SG_ID" \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Purpose,Value=image-test}]' \
--query 'Instances[0].InstanceId' --output text)
# Always terminate the test instance, even when a check fails
trap 'aws ec2 terminate-instances --instance-ids "$INSTANCE_ID" >/dev/null' EXIT
aws ec2 wait instance-status-ok --instance-ids "$INSTANCE_ID"
IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
# Retry for up to a minute while services start
for i in $(seq 1 12); do
if curl -fsS --max-time 5 "http://$IP/" | grep -q '<h1>Hello from Packer</h1>'; then
echo "image $AMI_ID passed"
exit 0
fi
sleep 5
done
echo "image $AMI_ID failed: no response from Nginx" >&2
exit 1
The script runs from a CI runner or host inside the VPC, so it can reach the private IP. For deeper checks (packages, users, open ports, file permissions), use a server testing tool such as Goss or Testinfra, either as the last provisioner in the build or against the test instance.
Versioning and Tracking Images
Every image should answer three questions: what is in it, where did it come from, and is it the latest good one?
- Name: application, version and build time, as in
local.image_nameabove. Names must be unique, and a sortable time stamp makes the newest one easy to find. - Tags or labels: version, Git commit, base image and build time. Pass the commit from CI with
-var git_commit=$GITHUB_SHA. - Manifest: add your own fields to
manifest.jsonso later pipeline steps can read them:
post-processor "manifest" {
output = "manifest.json"
strip_path = true
custom_data = {
git_commit = var.git_commit
version = var.app_version
}
}
- Promotion: do not let every build replace production. Mark an image as approved (a tag such as
Status=approved, an Azure gallery version, a Google image family, or an HCP Packer channel) after it passes the tests, and let Terraform deploy only approved images.
HCP Packer
HCP Packer is HashiCorp's hosted registry for image metadata. It does not store the images, only where they are and how they were built. Builds publish to a bucket per image, you assign versions to channels such as production, and other Packer templates read the image ID for a channel with the hcp-packer-version and hcp-packer-artifact data sources. Terraform does the same with the HCP provider's hcp_packer_version and hcp_packer_artifact data sources. Since Packer 1.13, the hcp_packer_registry block goes at the top level of the template:
hcp_packer_registry {
bucket_name = "web"
description = "Nginx web server image"
bucket_labels = {
team = "platform"
}
build_labels = {
os = "ubuntu-24.04"
}
}
A build with this block needs HCP credentials, from the HCP_CLIENT_ID and HCP_CLIENT_SECRET environment variables or a credentials file, or it fails before it starts. If you do not use HCP, the manifest file and your cloud's own tags give you most of the same tracking.
CI/CD Integration
Run the cheap checks on every pull request and the real build on a tag or on the main branch. Use OpenID Connect (OIDC), so the pipeline assumes a cloud role with short-lived credentials and no access keys are stored in CI.
GitHub Actions
name: Build image
on:
pull_request:
paths: ['images/**', 'scripts/**', 'files/**']
push:
tags: ['v*']
permissions:
contents: read
id-token: write # needed for OIDC
env:
PACKER_VERSION: '1.16.2'
IMAGE_DIR: images/web
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-packer@v3
with:
version: ${{ env.PACKER_VERSION }}
- run: packer init "$IMAGE_DIR"
- run: packer fmt -check -recursive .
- run: packer validate -var-file="$IMAGE_DIR/production.pkrvars.hcl" -var app_version=0.0.0 "$IMAGE_DIR"
build:
if: startsWith(github.ref, 'refs/tags/v')
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-packer@v3
with:
version: ${{ env.PACKER_VERSION }}
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/packer-ci
aws-region: us-east-1
- run: packer init "$IMAGE_DIR"
- name: Build
run: |
packer build \
-var-file="$IMAGE_DIR/production.pkrvars.hcl" \
-var app_version="${GITHUB_REF_NAME#v}" \
-var git_commit="$GITHUB_SHA" \
"$IMAGE_DIR"
- uses: actions/upload-artifact@v7
with:
name: manifest
path: manifest.json
The IAM role packer-ci trusts GitHub's OIDC provider for this repository only and has the permissions from Part 2. The versions above are the current major versions in October 2026; for supply-chain safety, pin third-party actions to a full commit SHA.
GitLab CI
stages: [validate, build]
variables:
PACKER_VERSION: '1.16.2'
IMAGE_DIR: images/web
default:
image:
name: hashicorp/packer:${PACKER_VERSION}
entrypoint: [''] # the image's entrypoint is /bin/packer; GitLab needs a shell
validate:
stage: validate
script:
- packer init "$IMAGE_DIR"
- packer fmt -check -recursive .
- packer validate -var-file="$IMAGE_DIR/production.pkrvars.hcl" -var app_version=0.0.0 "$IMAGE_DIR"
build:
stage: build
rules:
- if: $CI_COMMIT_TAG
id_tokens:
AWS_ID_TOKEN:
aud: sts.amazonaws.com
script:
- echo "$AWS_ID_TOKEN" > /tmp/web-identity-token
- export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/web-identity-token
- export AWS_ROLE_ARN=arn:aws:iam::123456789012:role/packer-ci
- packer init "$IMAGE_DIR"
- packer build -var-file="$IMAGE_DIR/production.pkrvars.hcl" -var app_version="${CI_COMMIT_TAG#v}" -var git_commit="$CI_COMMIT_SHA" "$IMAGE_DIR"
artifacts:
paths: [manifest.json]
The hashicorp/packer image's entrypoint is the packer binary, so GitLab cannot run a script in it until you reset the entrypoint. The id_tokens block gives the job an OIDC token, and the AWS SDK inside the Amazon plugin exchanges it for role credentials through AWS_WEB_IDENTITY_TOKEN_FILE and AWS_ROLE_ARN.
Security Best Practices
- No long-lived keys. Use OIDC in CI, SSO profiles on laptops, and instance roles on build servers. Give the build identity only the permissions from Part 2.
- No secrets in the image. Fetch them at boot from a secrets manager, not at build time into a file.
- Lock down the build machine: restrict the temporary SSH rule to your IP, or use private subnets with SSM (Part 4), and require IMDSv2.
- Encrypt images (
encrypt_booton AWS) and share them only with the accounts that need them. - Start from official base images and rebuild often, so security updates reach production through new images rather than in-place patching.
- Scan and record what you ship: scan the test instance or image with your vulnerability scanner, and keep a software bill of materials (SBOM) per image.
Packer 1.16 can also write a signed record of how an image was built. The new provenance post-processor creates an in-toto statement in the SLSA provenance format, with the artifact's digest, the Packer version and the build name, and it can sign it with a local key, a cloud KMS key or Sigstore. Here it runs unsigned on the built-in file builder, which turns a file into an artifact:
source "file" "artifact" {
content = "hello image"
target = "image.txt"
}
build {
sources = ["source.file.artifact"]
post-processor "provenance" {}
}
In a real pipeline, set signing_mode to key, kms or keyless so consumers can verify the statement with packer verify-attestation.
Performance Tips
- Use a bigger build instance. A build runs for minutes, so a faster instance type costs little extra and saves time. Instances launched from the image can be any compatible size.
- Layer your images. Build a hardened base image weekly, and quick application images on top of it, so most builds skip the slow OS updates.
- Avoid repeated large downloads: mirror artifacts in an S3 bucket or registry in the same region as the build.
- Let independent platforms build in parallel, and use
-parallel-buildsto limit it when a CI runner or provider quota is the bottleneck. - Test provisioning with the Docker builder instead of waiting for cloud builds while you develop scripts.
Common Mistakes
- Running
packer build file.pkr.hclwhen variables live in another file. Run Packer on the directory. - Committing secrets in
.pkrvars.hclfiles. Commit only non-secret var files, and read secrets withenv(), Vault or a data source. - Forgetting about auto var files, then wondering where a value comes from.
- Using
-forcein production pipelines, which deregisters the existing AMI of the same name. - No retention policy: image storage grows with every build. Delete old images and their snapshots on a schedule.
Summary
- Declare inputs with
variableblocks (types, defaults, validation,sensitive), and derive values withlocals. - Values come from defaults,
PKR_VAR_variables,*.auto.pkrvars.hclfiles,-var-fileand-var, with the later ones winning. - Run Packer on directories, test expressions with
packer console, and check every change withfmt -checkandvalidate. - Test images after the build, version and tag them, and promote only images that passed.
- Build in CI with OIDC credentials and pinned Packer and plugin versions.
A good next step is to wire the AMI ID from manifest.json (or an HCP Packer channel) into a Terraform launch template, so deployments always use your latest tested image.
Keep Learning
- Terraform Infrastructure as Code Best Practices
- CI/CD Pipeline Hardening: A Practical Guide to Securing Your Build Infrastructure
- Secrets Management Guide: Vault, AWS Secrets Manager, and Azure Key Vault
- CI/CD Pipeline Setup Checklist and HashiCorp Vault Secrets Management Checklist
- Complete CI/CD Pipeline with GitHub Actions (exercise)
- Interview question: secrets management
Tags
Found an issue?