In our previous write-ups, we covered the basics of Infrastructure as Code with Terraform and Jamf, and then walked through the dedicated providers for Jamf Protect and the Jamf Platform. If you haven't read those yet, we'd recommend starting there — they cover Terraform fundamentals, project structure, state management, and how to define Jamf resources in code from scratch.
This time around, we're tackling a question we hear a lot: "This all sounds great, but I've already got hundreds of policies, profiles, scripts and groups in my Jamf environment. Do I really have to rewrite all of that by hand?"
The short answer is no. We've built a tool called jamformer to help with exactly this — but before we dive in, it's important to be upfront about what it is and what it isn't.
What is jamformer?
jamformer is an enablement and acceleration tool for teams adopting Jamf with Terraform. It connects to your existing Jamf instance, discovers everything it can find, and generates a Terraform project as a starting point — a realistic scaffold you can learn from and refine. Think of it as a bridge between where you are today (a fully configured Jamf environment managed through the console) and where you want to be (that same environment managed as code) — but the bridge is not a conveyor belt. You still walk across it.
It is deliberately not:
- A production code generator. The HCL it produces is a first draft. Before you manage real infrastructure with it, expect to review every file, refactor into your own module structure, apply naming conventions, tighten secret handling, and deal with provider-drift quirks.
- A substitute for learning Terraform or the Jamf providers. It flattens the learning curve; it does not remove it.
- A one-click migration button. Large or unusual Jamf environments will always need human judgment.
What it is good for:
- Seeing what's actually in your Jamf instance, expressed in the Terraform providers' own resource model — attributes, cross-references, lifecycle quirks and all.
- Bootstrapping proofs-of-concept, demos, workshops, internal training, and migration-planning sessions.
- Giving engineers a realistic scaffold to refactor into IaC rather than staring at a blank editor.
It's also read-only. jamformer doesn't modify, create or delete anything in your Jamf instance. The only thing it writes is a set of Terraform files on your local machine.
Keep that framing in mind for the rest of this post. When we walk through the workflow and show example output, you're looking at raw material for a human engineer to refine — not the finished article.
Why not just use terraform query?
You absolutely can, and if you've read the Jamf Platform provider write-up, you've already seen how terraform query with list blocks and terraform import can bring existing resources under management.
But if you've been managing a Jamf environment through the console for any length of time, you'll know there's a lot in there. Doing it by hand means inventorying every resource type, writing query and import blocks for each one, figuring out which policy references which script and which group and which category, extracting embedded scripts and configuration profile payloads into standalone files, and dealing with provider quirks around default values.
That's a lot of work. We wanted to make this easier.
What does it support?
jamformer works across four Terraform providers in the Jamf product family:
| Provider | Flag | How it discovers |
|---|---|---|
| Jamf Platform (default) | -provider jamfplatform |
terraform query |
| Jamf Protect | -provider jamfprotect |
terraform query |
| Jamf Pro | -provider jamfpro |
Jamf Pro API |
| Jamf Security Cloud (JSC) | -provider jsc |
Terraform data sources |
The headline change since our earlier posts is that the Jamf Platform provider now federates the full Jamf Pro surface under the jamfplatform_pro_* namespace, alongside its native Platform Services resources (blueprints, compliance benchmarks, device groups). That means a single provider — authenticated with a single OAuth client against the regional API gateway — can describe your policies, profiles, scripts, groups, prestages, packages and settings and your platform-level blueprints and benchmarks. It's now jamformer's default, and the focus of this guide.
A quick word of credit before we go on. The community Jamf Pro provider by Deployment Theory, first released in early 2024, is the project that started it all. It proved the depth of demand for managing Jamf as code and set the bar for what a Jamf provider could be. It's still independently community-maintained, and we hold it in high regard; jamformer supports it via -provider jamfpro.
For the Jamf Platform provider alone, jamformer covers policies, scripts, configuration profiles (macOS and mobile), Smart and Static groups, computer and mobile prestages, extension attributes, packages, categories, buildings, departments, LDAP servers, printers and much more — plus settings like Client Check-In, Cloud Distribution Point and Self Service — and native platform blueprints, compliance benchmarks and device groups. The authoritative, always-current list is jamformer -list-resources (optionally filtered with -provider).
What happens when you run it?
When you point jamformer at your Jamf instance, it goes through a few stages. We'll walk through what each one does so you know what to expect.
First, it discovers your resources. For the Jamf Platform provider it uses terraform query with list blocks (the same approach we covered in the Platform write-up) to enumerate resources directly through the provider, walking your environment in dependency order — categories and sites first, then scripts and packages, then policies and profiles — so it can map the relationships between resources. A handful of singleton settings (Client Check-In, SMTP and so on) have fixed IDs and are read directly rather than discovered.
Next, it generates import blocks. For each discovered resource, jamformer writes a Terraform import block with a label derived from the resource name. Spaces become underscores, special characters are removed, and if two resources end up with the same label, a numeric suffix is added to keep things unique. It also generates provider.tf, variables.tf and terraform.tfvars configured for the right provider.
Here's what those import blocks could look like:
import {
to = jamfplatform_pro_policy.software_update_enforce
id = "142"
}
import {
to = jamfplatform_pro_category.productivity
id = "5"
}
import {
to = jamfplatform_device_group.macos_26_plus
id = "a1b2c3d4-0000-0000-0000-000000000000"
}
Note the two ID styles: Jamf Pro objects (jamfplatform_pro_*) carry numeric Jamf IDs, while native Platform resources like device groups and blueprints carry Platform UUIDs.
Then it asks Terraform to generate the HCL. jamformer runs terraform query -generate-config-out (with terraform plan -generate-config-out for the fixed-ID singletons), which has the provider produce the HCL for each resource. Since the provider itself is doing the heavy lifting here, the generated configuration always matches what the provider expects.
If specific resources fail to read back — it happens, sometimes a provider can't read a particular resource cleanly — jamformer automatically removes the failing block and retries. You'll see a message about which resources were skipped so you can deal with them later.
Finally, it cleans up the output. The raw output from Terraform is a single large file with literal values everywhere. jamformer transforms it into something you'd actually want to work with:
- jamformer rewrites literal Jamf IDs to Terraform references. A policy that references category ID
5becomesjamfplatform_pro_category.productivity.id, so Terraform understands the relationships between your resources — just like you'd write it by hand. Smart-group and user-group "member of" criteria are resolved to references too, and a blueprint'sactivation_conditionsexpression has its embedded device-group UUIDs rewritten to point at the matchingjamfplatform_device_groupresource. - Embedded scripts go to individual files under
support_files/scripts/, configuration profile payloads tosupport_files/macos_configuration_profiles/andsupport_files/mobile_device_configuration_profiles/, extension attribute scripts tosupport_files/extension_attributes/, app configurations tosupport_files/app_configurations/, printer PPDs tosupport_files/printers/and signed provisioning profiles tosupport_files/mobile_device_provisioning_profiles/. jamformer updates the HCL withfile()references so these are proper files you can diff, review and edit independently. Device enrollment and volume purchasing tokens usefile(var.xxx)path variables — you place your token files (downloaded from Apple Business Manager) at the path you specify in the variable. - A few Jamf Pro resources have no list endpoint on the Platform API, so jamformer synthesises them:
jamfplatform_pro_iconresources are reconstructed from the Self Service icons referenced by your policies,jamfplatform_pro_self_service_branding_imagefrom the branding singletons, andjamfplatform_pro_jamf_connectfrom the configuration profile carrying its payload. Each gets alifecycle { ignore_changes }block to avoid unnecessary destroy/create on first apply. These steps require a tenant ID (more on that below). - Optional attributes that come back as
nullget stripped out, values known to cause issues are removed, and the single large file is split into per-resource-type files —pro_policy.tf,pro_script.tf,pro_category.tf,blueprints.tf,device_groups.tfand so on. - A validation pass runs
terraform validateto detect conditionally invalid attributes and auto-removes or repairs them so you don't have to chase down schema errors by hand.
After post-processing, jamformer scans the output for secrets. Jamf environments often contain credentials embedded in configuration profiles, scripts and resource attributes — things like LDAP bind passwords, SMTP credentials, Wi-Fi shared secrets and API tokens. jamformer uses gitleaks with Jamf-specific rules to find these before you accidentally commit them to Git.
When jamformer finds secrets, it shows you a report grouped by resource and attribute. In interactive mode, you can choose to remediate all findings automatically, walk through them individually or skip remediation entirely. Remediation moves secrets into sensitive Terraform variables — for .tf files, the secret value is replaced with a var. reference; for support files like scripts and profiles, the file is converted to a .tpl template using templatefile(). You can skip this step with -skip-secret-scan if you'd rather handle secrets yourself.
Getting started
If you followed along with the introduction article, you should already have Terraform and a text editor set up. The steps here are straightforward.
1. Install jamformer
Homebrew:
brew install Jamf-Concepts/tap/jamformer
Pre-built binaries and a .pkg installer are available from the releases page.
You don't need to install Terraform separately — jamformer automatically downloads it to a temporary directory, so it won't conflict with any existing Terraform installation on your machine. Discovery for the Jamf Platform provider uses terraform query, which needs Terraform 1.14+; jamformer takes care of that for you.
2. Create an API client
The Jamf Platform provider authenticates with OAuth2 against the regional API gateway. Head to Jamf Account and create an API client with read access to the resources you want to discover. You'll need three things: the client ID, the client secret, and your tenant ID.
3. Run it against your instance
The easiest way to get started is to just run jamformer with no arguments:
jamformer
Because the Jamf Platform provider is the default, this walks you through everything interactively — your API gateway URL, tenant ID and credentials. You enter secrets as hidden input so they won't show up in your terminal history.
Once you're comfortable with the tool or want to script it, set everything via environment variables:
export JAMF_CLIENT_ID='your-client-id'
export JAMF_CLIENT_SECRET='your-client-secret'
export JAMF_TENANT_ID='your-tenant-id'
jamformer -url https://us.apigw.jamf.com
Use the gateway for your region — https://us.apigw.jamf.com, https://eu.apigw.jamf.com or https://apac.apigw.jamf.com. The tenant ID is required for the Jamf Platform provider; jamformer will stop and ask for it if it's missing.
jamformer only reads credentials from environment variables or interactive prompts — never CLI flags — to avoid leaking secrets in shell history and process listings. It doesn't write credentials to terraform.tfvars and generates a .gitignore that excludes tfvars files, state files and the .terraform/ directory.
4. Importing everything at once is optional
If your environment is large and you'd rather start small, you can target specific resource types:
# Start with just policies, scripts, and categories
jamformer -include-resources "policy script category"
# Everything except packages
jamformer -exclude-resources "package"
# See all available filter names
jamformer -list-resources
When a dependency type isn't included (say you import policies but not categories), references to those types stay as literal IDs rather than Terraform expressions. You can always re-run with more types later as you get comfortable.
So, what does the output actually look like?
After jamformer finishes, you'll have a directory that looks something like this:
generated/
.gitignore
provider.tf
variables.tf
terraform.tfvars
pro_policy.tf
pro_policy_import.tf
pro_script.tf
pro_script_import.tf
pro_category.tf
pro_category_import.tf
blueprints.tf
blueprints_import.tf
device_groups.tf
device_groups_import.tf
compliance_benchmarks.tf
compliance_benchmarks_import.tf
...
support_files/
scripts/
install_rosetta.sh
extension_attributes/
battery_health.sh
macos_configuration_profiles/
wifi_corporate.mobileconfig
mobile_device_configuration_profiles/
...
printers/
hp_laserjet.ppd
branding_images/
self_service_icon.png
device_enrollment_tokens/ (empty - place your .p7m files here)
volume_purchasing_tokens/ (empty - place your .vpptoken files here)
Each resource type gets its own .tf file and a corresponding _import.tf file. The support_files/ directory contains extracted scripts, profiles, printer PPDs, branding images and app configurations referenced via file() expressions. jamformer creates the token directories as the recommended location for your Apple Business Manager token files — these use file(var.xxx) path variables since the API doesn't return token data.
Compact mode
By default, jamformer generates one resource block per object — a flat, explicit layout that's easy to read and review. But if your environment has dozens of similar resources (categories, buildings, departments), the output can feel repetitive.
The -compact flag consolidates eligible resource types into for_each + locals patterns, producing output that's closer to what you'd write by hand:
jamformer -compact
Without compact mode, you'd get individual blocks:
resource "jamfplatform_pro_category" "productivity" {
name = "Productivity"
}
resource "jamfplatform_pro_category" "security" {
name = "Security"
}
With compact mode enabled, these become:
locals {
categories = {
productivity = { name = "Productivity" }
security = { name = "Security" }
}
}
resource "jamfplatform_pro_category" "all" {
for_each = local.categories
name = each.value.name
}
jamformer automatically rewrites cross-resource references to use the new addressing — jamfplatform_pro_category.all["productivity"].id instead of jamfplatform_pro_category.productivity.id — so everything stays connected. It updates import blocks the same way.
jamformer determines eligibility dynamically at runtime. A resource type qualifies when the file contains two or more resource blocks that all share the same set of attributes and none have nested blocks (other than lifecycle). Attributes that are identical across all instances become literal values on the resource block; only the values that vary go into the locals map. This works across all four providers.
If you want finer control, you can target specific types:
# Only compact categories and buildings
jamformer -compact -compact-include "pro_category pro_building"
# Compact everything eligible except policies
jamformer -compact -compact-exclude "pro_policy"
The -compact-include and -compact-exclude flags match the output filename stem (e.g. pro_category, pro_building), while -include-resources and -exclude-resources use the shorter filter names from jamformer -list-resources.
Multi-environment support
If you manage more than one Jamf environment — staging and production, or dev and prod — jamformer can generate a Terraform project structured around a shared module with per-environment root directories, designed for a Git branching workflow where each long-lived branch represents an environment.
⚠️ Experimental and advanced. Multi-environment export is aimed at people already fluent in Terraform modules and long-lived branch workflows, and the output is more scaffold-grade than the single-environment mode, not less. If you're new to Terraform or still getting your first single-instance project working, start there and come back to this later.
Supply per-environment credentials with an environment-name suffix. For the Jamf Platform provider that means a regional gateway URL, OAuth2 credentials and a tenant ID for each environment:
export JAMF_URL_STAGING=https://us.apigw.jamf.com
export JAMF_CLIENT_ID_STAGING=xxx
export JAMF_CLIENT_SECRET_STAGING=xxx
export JAMF_TENANT_ID_STAGING=xxx
export JAMF_URL_PROD=https://us.apigw.jamf.com
export JAMF_CLIENT_ID_PROD=xxx
export JAMF_CLIENT_SECRET_PROD=xxx
export JAMF_TENANT_ID_PROD=xxx
jamformer -multi-env "staging prod"
jamformer runs discovery against each environment independently, matches resources by name, and produces a modules/jamf/ directory of shared resource definitions plus per-environment roots under environments/. Where attribute values differ across environments, jamformer extracts them to module variables and sets them per-environment in terraform.tfvars; resources that exist in only one environment are separated into clearly labelled files. Each environment directory has its own backend.tf placeholder, its own state and its own import blocks, so you can plan and apply each one independently.
This works best when you intentionally keep environments in sync — the more they match, the cleaner the output. If your instances are substantially different, you may be better off running jamformer separately for each and maintaining independent projects.
Working with the generated output
We said it at the top and we'll say it again here, because this is the part that matters most: the generated configuration is not production-ready Terraform. jamformer is an enablement and acceleration tool. It gives you a realistic starting point on the journey toward managing your Jamf environment as code — not a shortcut that gets you there immediately. Plan to spend real time reviewing, refining and understanding what it produced before anything drives real infrastructure changes.
The output is intentionally flat — one resource block per object, one file per type, no modules. In practice, you'll probably want to refactor it into a modular project structure, take advantage of HCL features like for_each and locals to reduce repetition, turn things like instance URLs and common settings into variables for your environment, and organize resources in a way that makes sense for your team. jamformer gives you the raw material to do that; how you shape it is up to you. That shaping work is where most of the Terraform learning actually happens — and it's the whole reason jamformer exists in the form it does.
With that in mind, here's the workflow we'd recommend.
The same commands you learned in the introduction apply here:
cd generated
terraform init # Initialize and download the provider
terraform plan # Review the import plan, check for provider errors
Expect diffs in your first plan. It's normal to see differences between what Terraform generates and what's actually in your Jamf instance. Some attributes may show values that don't match what you see in the console, some optional fields may have defaults the provider fills in that differ from the live configuration, and some resources may have validation errors due to write-only fields that the provider can't read back. These are provider-level quirks, not bugs in jamformer, and they'll need manual attention.
Work through the plan output, fix any errors and adjust attributes until you're happy with what Terraform is reporting. This review process is where you'll learn the most about how the provider represents your resources — and it's an important step before handing control over to Terraform.
Once the plan looks clean:
terraform apply # Import resources into state (you'll be asked to confirm)
Running terraform apply here imports your existing resources into Terraform state. It does not change anything in Jamf — it only tells Terraform "these resources already exist and here's what they look like."
Once you've successfully applied, the import blocks have done their job. You can remove them:
rm *_import.tf
Then run terraform plan again. Ideally you see no changes. If differences show up, these are cases where the provider's defaults don't quite match what's actually in Jamf — adjust the generated HCL until you get a clean plan.
At this point, you've got a Terraform project that represents your Jamf environment. Commit it to Git and you're ready to start the real work: refining the configuration, organizing it to suit your team's workflow, and gradually moving toward making changes through pull requests, code review and terraform apply rather than the console.
Things to keep in mind
Packages resident in the Jamf Cloud Distribution Service (JCDS) are downloaded by default when a tenant ID is set. For large environments with a lot of packages, this can take a while. Use -skip-package-downloads to skip this step and handle packages separately. Catalog packages whose bytes live elsewhere are kept as metadata plus server-supplied hashes.
Secret scanning runs automatically after generation. If you'd rather handle secret management yourself or are running in a headless environment, use -skip-secret-scan to skip it. Even if you skip the built-in scan, we'd strongly recommend running some form of secret detection before committing the output to Git.
Compact mode (-compact) is purely cosmetic — it changes the shape of the HCL but not what Terraform does. If you're not sure whether you want it, start without it and try it on a second run.
The generated provider.tf includes a minimum version constraint (>= X.Y.Z) based on the provider version that Terraform downloaded. If you'd rather pin an exact version, use -provider-version 1.2.3.
Jamf Protect and Jamf Platform discovery rely on terraform query, which requires Terraform 1.14+. jamformer downloads this automatically, but it's worth knowing if you're supplying your own Terraform binary via -terraform-path.
What's next
If you've been following along with this series, you've now seen how to write Terraform for Jamf from scratch, how to manage Jamf Protect and Jamf Platform configuration as code with a dedicated provider, and now how to bring an existing environment under Terraform management without starting over. There's more to come — we'll be covering additional topics in future posts as the providers and tooling continue to evolve.
We're actively working on jamformer and gathering feedback. If you try it out, we'd like to hear what works and what doesn't. Open an issue on the GitHub repository or reach out on the #jamformer channel on the MacAdmins Slack.
References
- jamformer on GitHub
- Managing Jamf Configuration with Terraform: An Introduction
- Managing Jamf Protect with Terraform: The Jamf Protect Provider
- Managing the Jamf Platform with Terraform: The Jamf Platform Provider
- Jamf Platform Provider
- Jamf Protect Provider
- Deployment Theory Jamf Pro Provider
- Jamf Security Cloud Provider
- Terraform Documentation