In our previous write-ups, we covered the basics of Infrastructure as Code with Terraform and Jamf Pro and then walked through the dedicated Terraform 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 import?
You absolutely can, and if you've read the Protect write-up, you've already seen how terraform import and terraform query with list blocks 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. Manually importing everything means inventorying every resource type, writing hundreds of import blocks with the correct Jamf IDs, 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 with four Terraform providers across the Jamf product family:
| Provider | How it discovers |
|---|---|
| Jamf Pro (default) | Jamf Pro API |
| Jamf Protect | terraform query |
| Jamf Platform | terraform query |
| Jamf Security Cloud (JSC) | Terraform data sources |
For Jamf Pro alone, that covers policies, scripts, configuration profiles, Smart Groups, Static Groups, computer prestages, mobile device profiles, extension attributes, packages, categories, buildings, departments and much more — plus settings like Client Check-In, Cloud Distribution Point and Self Service configuration. 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 Jamf Pro, it authenticates to your instance (OAuth2 or basic auth) and queries the API for each supported resource type. It walks through your environment in dependency order — sites and categories first, then scripts and packages, then policies and profiles — so it can map the relationships between resources. For Jamf Protect and Jamf Platform, it uses terraform query with list blocks (the same approach we covered in the Protect write-up) to discover resources directly through the provider.
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 = jamfpro_policy.software_update_enforce
id = "142"
}
import {
to = jamfpro_script.install_rosetta
id = "87"
}
import {
to = jamfpro_category.productivity
id = "5"
}
Then it asks Terraform to generate the HCL. jamformer runs terraform plan -generate-config-out (or terraform query -generate-config-out for Protect and Platform), which has the provider produce the HCL for each imported 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 import — it happens, sometimes a provider can't read a particular resource back cleanly — jamformer automatically removes the failing import 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
5becomesjamfpro_category.productivity.id, so Terraform understands the relationships between your resources — just like you'd write it by hand. - Embedded scripts go to individual files under
support_files/scripts/, configuration profile payloads tosupport_files/macos_configuration_profiles/, extension attribute scripts tosupport_files/extension_attributes/, and mobile device app configurations tosupport_files/app_configurations/. jamformer updates the HCL withfile()references so your scripts and profiles 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. - jamformer resolves icon resources to CDN URLs (icons aren't downloaded locally) and adds a
lifecycle { ignore_changes }block to prevent unnecessary destroy/create cycles on first apply. - Optional attributes that come back as
nullget stripped out, values known to cause issues (likecategory_id = -1for uncategorized resources) are removed, and the single large file is split into per-resource-type files —policies.tf,scripts.tf,categories.tfand so on. - A validation pass runs
terraform validateto detect conditionally invalid attributes — for example, attributes that are only valid for a specific CDN type — and auto-removes 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.
2. Run it against your instance
The easiest way to get started is to just run jamformer with no arguments:
jamformer
It will walk you through everything interactively — which provider you want to use, your instance URL and your credentials. You enter passwords and client secrets as hidden input so they won't show up in your terminal history.
Shorthand URLs work here too — you can just type yourinstance and jamformer will expand it to yourinstance.jamfcloud.com. For Protect, your-tenant expands to your-tenant.protect.jamfcloud.com.
Once you're comfortable with the tool or want to script it, you can set credentials via environment variables:
# Jamf Pro with OAuth2
export JAMF_CLIENT_ID='your-client-id'
export JAMF_CLIENT_SECRET='your-client-secret'
jamformer -url https://yourinstance.jamfcloud.com
# Jamf Protect
export JAMF_CLIENT_ID='your-client-id'
export JAMF_CLIENT_SECRET='your-client-secret'
jamformer -provider jamfprotect -url https://your-tenant.protect.jamfcloud.com
jamformer only reads credentials from environment variables or interactive prompts — never CLI flags — to avoid leaking secrets in shell history and process listings. You can pass the URL as a flag (-url) or via JAMF_URL.
jamformer doesn't write credentials to terraform.tfvars and generates a .gitignore that excludes tfvars files, state files and the .terraform/ directory.
3. 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 "policies scripts categories"
# Everything except packages and icons
./jamformer -exclude-resources "packages icons"
# 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
policies.tf
policies_import.tf
scripts.tf
scripts_import.tf
categories.tf
categories_import.tf
...
support_files/
scripts/
install_rosetta.sh
set_wallpaper.sh
extension_attributes/
battery_health.sh
macos_configuration_profiles/
wifi_corporate.mobileconfig
filevault_enforce.mobileconfig
device_enrollment_tokens/ (empty - place your .p7m files here)
volume_purchasing_tokens/ (empty - place your .vpptoken files here)
app_configurations/
managed_browser.xml
Each resource type gets its own .tf file and a corresponding _import.tf file. The support_files/ directory contains extracted scripts, profiles and app configurations referenced via file() expressions. jamformer creates token directories (device_enrollment_tokens/ and volume_purchasing_tokens/) 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 "jamfpro_category" "productivity" {
name = "Productivity"
}
resource "jamfpro_category" "security" {
name = "Security"
}
With compact mode enabled, these become:
locals {
categories = {
productivity = { name = "Productivity" }
security = { name = "Security" }
}
}
resource "jamfpro_category" "all" {
for_each = local.categories
name = each.value.name
}
jamformer automatically rewrites cross-resource references to use the new addressing — jamfpro_category.all["productivity"].id instead of jamfpro_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 "categories buildings"
# Compact everything eligible except policies
jamformer -compact -compact-exclude "policies"
Multi-environment support
⚠️ Experimental and highly advanced. This feature targets people who are already fluent in Terraform modules, long-lived branch workflows and the Jamf provider's resource model. The output is more scaffold-grade than the single-environment mode, not less — expect to edit the generated module, the per-environment roots, and the variable extraction before any of it is usable. Treat it as a research/enablement feature, not a supported production workflow. If you're new to Terraform or still getting your first single-instance project working, start there and come back to this later.
If you manage multiple Jamf Pro environments — staging and production, or dev and prod — you've probably thought about how to keep them in sync. 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.
export JAMF_URL_STAGING=https://staging.jamfcloud.com
export JAMF_CLIENT_ID_STAGING=xxx
export JAMF_CLIENT_SECRET_STAGING=xxx
export JAMF_URL_PROD=https://prod.jamfcloud.com
export JAMF_CLIENT_ID_PROD=xxx
export JAMF_CLIENT_SECRET_PROD=xxx
jamformer -multi-env "staging prod"
jamformer runs discovery against each environment independently, matches resources by name, and produces output that looks like this:
generated/
modules/jamf/ # shared resource definitions
policies.tf # resources present in all environments
scripts.tf
categories.tf
policies_staging_only.tf # resources only in staging
variables.tf # module input variables
providers.tf
support_files/ # files identical across environments
scripts/
macos_configuration_profiles/
environments/
staging/
main.tf # provider config + module call
backend.tf # placeholder for your state backend
variables.tf
terraform.tfvars # environment-specific values
imports.tf # import blocks with module.jamf. prefix
support_files/ # files that differ from the other env
prod/
main.tf
backend.tf
variables.tf
terraform.tfvars
imports.tf
support_files/
The shared module in modules/jamf/ contains resource definitions common to all environments. Where attribute values differ across environments — a policy with a different category_id or a profile with a different description — jamformer extracts those values to module variables and sets them per-environment in terraform.tfvars. Cross-resource references stay as proper Terraform expressions (e.g., jamfpro_category.productivity.id), so the relationships between resources stay intact.
This works best when you intentionally keep your environments in sync — the same policies, scripts, profiles and groups deployed across instances. The more they match, the cleaner the output. That said, jamformer handles the common real-world differences:
- Same resource, different settings (e.g., a policy with a different category or frequency in staging vs prod) — the differing attributes become module variables, with each environment's values in its own
terraform.tfvars. jamformer sorts variables alphabetically and groups them by resource type for readability. - Resources that exist in some environments but not others (e.g., a test policy only in staging) — jamformer separates these into clearly labeled files like
policies_staging_only.tfwithin the module. On the branch for another environment, you simply delete those files. - Support files that differ (e.g., a script with slightly different content across instances) — files identical across all environments live in the shared module's
support_files/directory. When files differ, jamformer puts them in each environment's ownsupport_files/directory and passes them to the module as variables.
Each environment directory has its own backend.tf placeholder, its own state and its own import blocks prefixed with module.jamf.. This means you can run terraform plan and terraform apply independently per environment, in parallel if needed, without any risk of one environment's state interfering with another.
jamformer uses the first environment listed as the source of truth for the shared resource definitions; use -source-env to override this. It matches resources by name, so a policy called "Software Update - Enforce" in staging will match a policy with the same name in prod regardless of their Jamf IDs.
The intended workflow is to commit the output to a repository, create a long-lived branch per environment, configure each branch's backend.tf with the appropriate state backend, and set up CI to run terraform apply in the corresponding environments/<env>/ directory when code lands on that branch. Changes flow from feature branches into the first environment, then promote through branches via pull requests.
The more your environments diverge, the more variables and environment-specific files the output will contain. If your instances are substantially different, you may be better off running jamformer separately for each one and maintaining independent Terraform 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 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
By default, jamformer downloads packages from the Cloud Distribution Point. 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.
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. You can also use -compact-include and -compact-exclude to target specific resource types while leaving others flat.
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 require Terraform 1.14+ for terraform query support. 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 Jamf Nation.
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
- Deployment Theory Jamf Pro Provider
- Jamf Protect Provider
- Jamf Platform Provider
- Jamf Security Cloud Provider
- Terraform Documentation