Migrating Jamf Pro to Terraform at Lloyds Banking Group
placeholder-logo
No precedent to follow - nobody had migrated a live, in-use Jamf Pro estate to Terraform at this scale before, let alone inside a bank.
The estate at the start - three Jamf Pro instances
Staging was supposed to mirror production. In practice it had drifted - and there was no history that could bring the two back in line. Hold that thought.
HCP Terraform runs plans and applies remotely on private runners inside the bank's network - state never touches a laptop. With the CLI restricted to development environments, every staging and production change flows through a VCS-triggered run.
Five teams touch Jamf Pro - three manage resources, Support takes actions only, auditors verify it. Because the HCL is self-documenting, the repo itself doubles as the audit trail and can be handed straight to auditors.
Blast radius and plan execution time. Guidance suggests roughly 500 resources per workspace; the estate holds 1,500+. At ~5,000, things break down.
Every change needs Mac team approval regardless of which repo it lives in. Splitting by team would just mean chasing that same approval across several repos instead of one - one repo keeps every approval flowing through a single place.
Static group membership stays unmanaged to protect the support experience - troubleshooting should not require a pull request.
Two more ideas fell later in the journey - they come up where they happened.
Staging and production had already drifted apart long before migration started - by November 2025 the two instances were no longer aligned.
Two divergent sets of configuration to import - and the drifted one gets validated first.
Riskier on paper. But production is the configuration that matters.
The control that made it safe: the Terraform API client held read-only scopes. Importing only reads - so state was built with no surface to change the server. Write access was widened only once imports were stable with no recurring diff. Joseph and Gordon held those keys.
An import is read-only by nature: Terraform records the resource in state without writing to the server. Scoping the API client read-only removed the write surface entirely, so a mistake during import could not change Jamf Pro. Write scopes were added back once imports were stable with no recurring diff.
jamf-resource-deleter - which backs each resource up to JSON before deleting it, with a restore path if anything was needed back.Settings panes are single-instance resources - there's only ever one. Import stayed the default; apply was the fallback.
Don't skip import if you can. Apply was only the fallback when no import statement existed.
Every settings resource under management in about a day.
resource "jamfpro_client_checkin" "jamfpro_client_checkin" { check_in_frequency = 30 // Valid values: 5, 15, 30, 60 create_startup_script = true startup_log = true // requires create_startup_script startup_ssh = true // requires create_startup_script startup_policies = true // requires create_startup_script create_hooks = true hook_log = true // requires create_hooks hook_policies = true // requires create_hooks enable_local_configuration_profiles = true allow_network_state_change_triggers = true } import {}
Most settings panes were imported like any other resource, tracked in state via terraform import. The apply-only path was reserved for the handful whose provider had no import statement - there, Terraform's "create" simply writes the values the UI already shows, a no-op change that leaves the resource tracked in state without ever touching the server.
At LBG, every Terraform run - across the bank's entire public cloud estate - passes through Sentinel policy checks before it can apply. One of those policies bans import blocks outright, because importing lets a workspace adopt resources someone else already manages.
Ruled out. Import blocks are banned outright, and with no CLI access to production workspaces there's no terraform import fallback either.
Excepted. A time-bound Sentinel exception, submitted - and chased - for every import window. Strings were pulled.
Resolved. We made the case to the Sentinel team: sole actors on these workspaces, nobody else's resources to adopt. Standing exception granted.
Sentinel is HashiCorp's policy-as-code engine: policies evaluate every HCP Terraform run before it can apply. At LBG the same rails govern the bank's Azure and GCP estates, which is why the import ban is organisation-wide rather than per team.
One resource type at a time, end to end in production - not one instance at a time. The order came from the matrix: a group-built spreadsheet of every resource type and its dependencies.
Tell the Mac team which resources are in this wave.
Communicate a change freeze for those resource types to affected teams.
Start the wave: revoke GUI write permissions for in-scope resources - no side-door edits mid-import.
Run the import.
Validate the import succeeded.
Publish example documentation showing how to manage the resource via Terraform in BAU.
Announce to all teams: the resource is now Terraform-managed.
PRUNE exported JSON of unused resources; jamf-resource-deleter (PyPI) deleted them, backing each one up to JSON with a restore path.local - duplicates and all.local; a single resource block with for_each, conditionals and dynamic blocks loops over it to create/import every instance of that type.terraform plan -generate-config-out: it emits one HCL block per resource; we wanted one block per resource type driven by data.
TODO Q9: confirm truncated sentenceresource "jamfpro_script" "homebrew" { name = "Install Homebrew" category_id = 9 # ... a dozen more attributes } resource "jamfpro_script" "homebrew_intel" { name = "Install Homebrew (Intel)" category_id = 9 # ... a dozen more attributes } # one block per script, for every script
locals { scripts = { "Install Homebrew" = "Engineering" "Install Homebrew (Intel)" = "Engineering" # BAU change = append one line here } } resource "jamfpro_script" "managed" { for_each = local.scripts name = each.key category_id = local.category_ids[each.value] }
Adding script number two hundred is a one-line diff. Reviews read the data, not the boilerplate.
The refinement passes, repeated until zero diff - Gordon imported verbosely; Joseph deduplicated, named the IDs and shared the locals, through February and March.
One resource block per type means provider changes are fixed in one place, pull requests diff data rather than boilerplate, and the HCL does not grow as the estate does. The maps started life generated from live config by the jamfpy scripts, then were refined by hand - duplicates stripped, raw IDs replaced with named lookups from locals built off the managed resources.
The parameter-map pattern is a default, not dogma.
When the map becomes harder to read than plain HCL, the pattern has stopped paying for itself.
$ terraform plan No changes. Your infrastructure matches the configuration.
terraform plan returns zero diff.
State is centralised in HCP and not locally inspectable - AI-assisted checks (Copilot CLI) confirmed each resource exists with the correct dependencies assigned.
Sweep for orphaned resources living outside the HCL config.
Only after all checks pass: remove UI write permissions for that resource type.
By March, one directory - terraform/jamfpro - served every instance, and instance-specific behaviour hung off conditionals keyed on the FQDN.
locals { is_staging = can(regex("staging", var.jamfpro_fqdn)) } resource "jamfpro_policy" "maintenance" { count = local.is_staging ? 0 : 1 # deployment behaviour inferred from a URL - everywhere }
It got out of hand. The pivot: shared modules own the configuration; thin per-instance roots own only what genuinely differs. TODO: real conditional example
terraform/ ├─ modules/ │ ├─ iam_main/ │ ├─ profiles_policies_main/ │ └─ root_main/ └─ prod/ ├─ lbgstaging/ │ ├─ iam/ → workspace │ ├─ profiles_policies/ → workspace │ └─ root/ → workspace └─ lbgbusiness/ ├─ iam/ → workspace ├─ profiles_policies/ → workspace └─ root/ → workspace
.mobileconfig profiles, scripts, Self Service icons, descriptions.specific_to_here.tf for genuine differences.profile_staging_only/.TODO: sandbox workspace layout TODO: plan-time figures
iam_main holds accounts, account groups and their privilege-set JSON. profiles_policies_main holds configuration profiles, policies and Self Service icons. root_main holds the rest - scripts, app installers and their descriptions. Payloads live next to the HCL that deploys them.
Production was fully code. Staging was still years of drift - importing that drift would only have enshrined it. So it was not imported at all.
Wipe. Staging cleared out, keeping only the essentials - APNS, cloud identity provider.
Apply. Production's configuration pointed at the empty instance.
Iterate. Plenty of errors, pass after pass of refinement, until the run came back clean.
Staging no longer claims parity with production - it inherits it, built from the same modules. TODO (Mac engineer): confirm kept-items and timing
This is also where the module split earned its keep - a lot was learned here about designing one repo to serve multiple environments cleanly.
The wipe kept the things a rebuild cannot recreate without touching the fleet - the APNS connection and the cloud identity provider integration. Everything else on the instance was recreated by pointing production's modules at it and iterating until the run came back clean.
Route to live, today
Brought in recently as the first stop on the route to live - a single CLI-triggered workspace.
TODO (Mac engineer): clarify instance rolesTrialled inside the route to live, then removed and kept separate - now a CLI-only instance.
The release cadence was hard to sustain and releases needed clearer tracking. Calendar versioning instead: when a flood of tickets lands on 1 December, the matching release and its change history are findable by date.
Calendar versioning tags releases by date rather than by semantic version, so an incident report from 1 December maps directly to the releases shipped around that date - no changelog archaeology required.
resource "jamfpro_category" "managed" {
for_each = local.categories
name = each.key
}
From Clicks to Code - Migrating Jamf Pro to Terraform at Lloyds Banking Group