From Clicks to Code

Migrating Jamf Pro to Terraform at Lloyds Banking Group

Joseph Little · Gordon Deacon · Dafydd Watkins

Lloyds Banking Group

placeholder-logo

Who we are

Context, requirements, constraints

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

Sandboxno parity Stagingparity on paper Productionthe real estate
  • No hardcoded secrets, anywhere.
  • Every production change gated by peer review.
  • Align with the bank's strategic tooling: GitHub, VCS-triggered Terraform runs, internally hosted HashiCorp Vault, HCP Terraform for remote state and run execution on HCP private runners.
  • Terraform CLI available only in development environments.
  • One shared source of truth for every team - resources are not split by org structure.
  • Post-migration, all create/update/delete operations happen through Terraform only.
  • All HCL must satisfy org linters.
  • Versioned releases, with change history findable when it is needed.
  • Tidy up each tenant before import - stale, non-active resources don't get carried into Terraform.

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.

More detail

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.

Who touches Jamf Pro

Support

Actions only

Mac@LBG

Resource management

Security

Resource management

App Packaging & Deployment

Resource management

Auditors

Read-only, on demand

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.

Migration objectives and design decisions

Governance

  • 1A monorepo is the single source of truth for every instance.
  • 2Every merge to main requires at least one peer review from another engineer.
  • 3Post-migration, all engineers drop to read-only in Jamf Pro; only the Terraform machine identity holds full CRUD.
  • 4Break-glass account available, held in PAM (Privileged Access Management).

Architecture and DX

  • 5One Terraform definition drives all instances.
  • 6Secrets centralised in an internally hosted HashiCorp Vault - set once by humans, read by Terraform via data sources.
  • 7Branching strategy supports three environments with VCS-based triggers (one instance is CLI-triggered).
  • 8Developer experience: BAU change is a one-line edit to a parameter map, not hand-written HCL.

Ideas rejected, and why

Rejected A single workspace for all resources

Blast radius and plan execution time. Guidance suggests roughly 500 resources per workspace; the estate holds 1,500+. At ~5,000, things break down.

Rejected Per-team Terraform modules scoped to each team's resources

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.

Rejected Managing everything with Terraform

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.

Instance migration order

Staging and production had already drifted apart long before migration started - by November 2025 the two instances were no longer aligned.

Option

Staging → Production

Two divergent sets of configuration to import - and the drifted one gets validated first.

Chosen

Production 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.

More detail

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.

Instance prep

  • Big tidy-up: used PRUNE to export JSON of every unused resource, then removed them with a bespoke tool - jamf-resource-deleter - which backs each resource up to JSON before deleting it, with a restore path if anything was needed back.
  • Verified API credentials per client were valid and complete.
  • Confirmed the Jamf Pro instance version met provider requirements.
  • Tested the connection to Terraform Cloud end-to-end and confirmed every client's credentials were being read correctly.
  • Stored credentials for every instance's API clients in the secrets manager.
  • Split the estate into resource-type sections up front, so migration could proceed safely, one section at a time.

Singletons first

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 {}
More detail

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.

Getting past Sentinel

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.

  1. 1

    Ruled out. Import blocks are banned outright, and with no CLI access to production workspaces there's no terraform import fallback either.

  2. 2

    Excepted. A time-bound Sentinel exception, submitted - and chased - for every import window. Strings were pulled.

  3. 3

    Resolved. We made the case to the Sentinel team: sole actors on these workspaces, nobody else's resources to adopt. Standing exception granted.

More detail

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.

Resource sequencing TODO (Gordon Deacon): real resource-type names per band

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.

1Singletons 2Resources without dependencies 3Resources that depended on those 4Larger dependency resources - profiles and policies

Migration wave workflow

  1. 1

    Tell the Mac team which resources are in this wave.

  2. 2

    Communicate a change freeze for those resource types to affected teams.

  3. 3

    Start the wave: revoke GUI write permissions for in-scope resources - no side-door edits mid-import.

  4. 4

    Run the import.

  5. 5

    Validate the import succeeded.

  6. 6

    Publish example documentation showing how to manage the resource via Terraform in BAU.

  7. 7

    Announce to all teams: the resource is now Terraform-managed.

Tools and helpers

jamfpro Python SDK Per-resource script Structured map Terraform local for_each resource block Import
  • Tidy-up tooling: PRUNE exported JSON of unused resources; jamf-resource-deleter (PyPI) deleted them, backing each one up to JSON with a restore path.
  • Extraction driven by the jamfpro Python SDK.
  • Each migration candidate got a script that pulled live config and emitted a structured map, shaped for a Terraform local - duplicates and all.
  • Map pasted into a local; a single resource block with for_each, conditionals and dynamic blocks loops over it to create/import every instance of that type.
  • Regex mop-up pass to tidy the generated output.
  • Rejected 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 sentence
  • AI-assisted validation of configuration and dependency links - mostly Copilot CLI.

Dynamic creation with for_each TODO: paste sanitised real HCL

generate-config-out style

resource "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

Ours, after the passes

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.

More detail

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.

for_each exceptions

The parameter-map pattern is a default, not dogma.

Policies

  • Payload complexity
  • Readability

Dock Items

  • Payload complexity
  • Readability

When the map becomes harder to read than plain HCL, the pattern has stopped paying for itself.

Validating a migration

$ terraform plan

No changes. Your infrastructure matches the configuration.
  1. 1

    terraform plan returns zero diff.

  2. 2

    State is centralised in HCP and not locally inspectable - AI-assisted checks (Copilot CLI) confirmed each resource exists with the correct dependencies assigned.

  3. 3

    Sweep for orphaned resources living outside the HCL config.

  4. 4

    Only after all checks pass: remove UI write permissions for that resource type.

Growing pains

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

The module structure

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
  • Three shared modules hold the configuration - and its payloads: privilege-set JSON, .mobileconfig profiles, scripts, Self Service icons, descriptions.
  • Each instance calls the modules from thin roots, plus a small specific_to_here.tf for genuine differences.
  • Every root maps one-to-one to an HCP Terraform workspace - three per instance, bounding the blast radius of any single run.
  • Divergence is visible, not hidden: staging-only profiles sit in the module as profile_staging_only/.

TODO: sandbox workspace layout TODO: plan-time figures

More detail

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.

Rebuilding staging

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.

  1. 1

    Wipe. Staging cleared out, keeping only the essentials - APNS, cloud identity provider.

  2. 2

    Apply. Production's configuration pointed at the empty instance.

  3. 3

    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.

More detail

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.

Refinements along the way

Route to live, today

DevTest Staging Production Sandbox - CLI-only

DevTest joins

Brought in recently as the first stop on the route to live - a single CLI-triggered workspace.

TODO (Mac engineer): clarify instance roles

Sandbox steps out

Trialled inside the route to live, then removed and kept separate - now a CLI-only instance.

Release Please → CalVer

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.

More detail

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.

By the numbers

Apr 2025 → May 2026 provider development to v1.0.0
14+ contributors
526+ pull requests
1,902 commits
127 Terraform files
19,000+ lines of HCL

Questions

From Clicks to Code - Migrating Jamf Pro to Terraform at Lloyds Banking Group

Links

TODO Q12: URLs, contact and socials