You can keep the whole Zentral configuration as code and apply it with Terraform, using the official Zentral Terraform Provider (zentralopensource/zentral). It is a Partner provider on the Terraform Registry, written and maintained by us, and it covers MDM, Munki, osquery, Santa, Turbo, compliance checks, tags and event stores. Here is why we chose Terraform in 2022 instead of building our own system, and what came out of it.
What we needed: configuration as code, with review
At the beginning of 2022, we were looking for a way to manage the changes made in Zentral.
Zentral already had an event pipeline to push audit events. With them, you can see what changed in Zentral, and who changed it. An event only tells you about a change once it is done, though.
What we did not have was a way to:
- keep the configuration as code, in files,
- see a change before it is made,
- review and approve a change before it is applied,
- handle the secrets that are part of the configuration (tokens, passwords, keys) without writing them in these files.
Why not build our own change management?
There were two ways to build it ourselves, and we looked at both.
The first was a change management store inside Zentral: changes saved as drafts, shown as a diff, reviewed, approved, and only then applied. The more we designed it, the more it looked like git, with its history, its diffs and its reviews. We would have been reinventing git inside Zentral, and it was getting complicated.
The second kept the configuration outside Zentral, in files, in a machine-readable format like JSON or YAML. Files can live in git, so the history and the reviews come with it. A file does nothing on its own, though. The tool that applies the files to the Zentral API would have been ours to write too: create an object if it does not exist, update it if it does, and delete it if it is still in Zentral but no longer in the files. Secrets cannot be written in those files either, so the tool would need its own way to get them. It would have had to do all of this in the right order. A Santa configuration profile needs the secret of its enrollment, so the enrollment has to be created, and its secret read back, before the profile can be built. Every object that points to another one adds to that dependency tree.
At the time, we were already deploying Zentral with Terraform, and it suddenly clicked: Terraform is exactly that tool. Building our own version of it would have cost us a lot of time.
Many admins already know Terraform, because they use it to manage AWS, Okta or GitHub. For them, Zentral is one more provider in a language they already write. For the others, it is an industry standard worth learning, and what they learn for Zentral stays useful for the other tools they manage.
How Terraform works, in three steps
Terraform is a tool made by HashiCorp. It works in three steps:
- You describe what you want in text files, with the Terraform language.
terraform plancompares these files with what exists, and shows what it would create, change or delete. Nothing is changed yet.terraform applymakes these changes.
Terraform talks to each system through a provider: a plugin that knows how to use the API of this system. The Terraform Registry has providers for AWS, Google Cloud, GitHub, Okta, and many more.
Can Terraform manage configuration, not only infrastructure?
Most people use Terraform for infrastructure: servers, networks, databases. Watching talks from Terraform conferences, we saw that it could be used for configuration too. The GitHub and Okta providers, for example, manage repositories, teams, users and groups. No servers at all.
Terraform already had what we needed:
-
A language to describe resources, with many useful built-in functions. For example,
filereads a script kept in its own file,filebase64loads a configuration profile, andjsonencodewrites the JSON of an Apple DDM declaration. Zentral needs itsIdentifierand itsServerToken, andsha256gives it a newServerTokenevery time the declaration changes:resource "zentral_mdm_artifact" "passcode" { name = "Passcode settings" type = "Configuration" channel = "Device" platforms = ["macOS"] } locals { passcode = { Type = "com.apple.configuration.passcode.settings" Identifier = "com.example.passcode" Payload = { MinimumLength = 12 } } } resource "zentral_mdm_declaration" "passcode" { artifact_id = zentral_mdm_artifact.passcode.id source = jsonencode(merge(local.passcode, { ServerToken = sha256(jsonencode(local.passcode)) })) macos = true version = 1 } -
A preview of the changes.
terraform planshows what will be created, changed or deleted, and nothing happens beforeterraform apply. -
Sensitive values. Secrets are not written in the code. The CI/CD system passes them to Terraform when it runs, for example as
TF_VAR_environment variables. Terraform hides sensitive values in the plan output, so the people reviewing see the change without seeing the secret. -
Secrets passed from one resource to another. Other secrets are created by Zentral itself. For example, each Santa enrollment has its own secret, and the Santa configuration profile needs it. With Terraform, the profile is built from a template with
templatefile, and the template gets the secret directly from the enrollment:resource "zentral_santa_enrollment" "default" { configuration_id = zentral_santa_configuration.default.id meta_business_unit_id = zentral_meta_business_unit.default.id } resource "zentral_mdm_artifact" "santa" { name = "Santa" type = "Profile" channel = "Device" platforms = ["macOS"] } resource "zentral_mdm_profile" "santa" { artifact_id = zentral_mdm_artifact.santa.id source = base64encode(templatefile("${path.module}/santa.mobileconfig.tftpl", { enrollment_secret = zentral_santa_enrollment.default.secret })) macos = true version = 1 }The secret is never written in the code, so you can share a configuration without sharing its secrets. In the plan, the profile shows as
(sensitive value). -
Many well documented ways to run it in CI/CD. Terraform was already a standard, so there were guides to run it in automation, with GitHub Actions, GitLab and the other main CI/CD systems. A pull request shows the plan, and merging it applies the change. This is the GitOps workflow we use for Zentral.
-
A place in the repository you already have. Many organizations keep the configuration of their infrastructure and of their tools in a single repository, a monorepo, and apply it with Terraform. For them, Terraform is the easiest way to add the MDM to it. The Zentral configuration sits next to the AWS, Okta or GitHub one, and goes through the same pull requests and the same pipelines.
The most important point: Terraform is more than a language to describe resources. It is also the tool that applies them. It reads the files, checks what exists today, works out the changes, and makes them in the right order. It keeps track of what it manages, so when something is removed from the files, it is removed from the system too. This is the part we did not want to build ourselves.
Building the Zentral Terraform Provider
Instead of building our own system, we wrote a Terraform provider for Zentral. Work started on July 13, 2022, and it went well: a week later, the first version was on the Terraform Registry.
In October 2026, the provider had 59 resources, and 37 data sources to read existing objects. They cover MDM (blueprints, profiles, declarations, enrollments, FileVault and recovery passwords, software updates), Munki and Monolith, osquery, Santa, Turbo, compliance checks, tags, event stores, and more.
It is listed as a Partner provider on the Terraform Registry: a provider written and maintained by the company that makes the product, as part of the HashiCorp partner program. In October 2026, it had been downloaded more than 92,000 times. Its code is on GitHub.
In August 2023, a year after we started the provider, HashiCorp moved Terraform to the Business Source License. OpenTofu, the open source fork, uses the same providers, and the Zentral provider works with it. It is on the OpenTofu registry too.
Short-lived API tokens for Terraform in GitHub Actions and GitLab
Zentral can exchange an OIDC identity token for a Zentral API token, with OIDC API token issuers. A Terraform workflow running in GitHub Actions exchanges its own OIDC identity token for a Zentral API token that is only valid for a short time. No Zentral secret is stored in the CI/CD system. GitLab pipelines can do the same.
Each issuer mints tokens for one service account, and only for the workflows that match its CEL condition on the claims of the identity token (GitHub, GitLab). What the service account can do is decided by PBAC policies, written in Cedar, the same way as for users.
It is the same best practice used to run Terraform for AWS or Google Cloud from GitHub Actions.
Can a coding agent manage your Zentral configuration?
Coding agents have become very good at reading and writing code. Terraform is something they know well: they were trained on a lot of Terraform code and documentation, and the language comes with tools that tell them right away when the code is wrong.
Writing the code is only half of it. Boris Cherny, who created Claude Code, calls giving the agent a way to verify its work “probably the most important thing” to get good results. With Terraform, an agent gets two layers of feedback, and neither of them changes anything in Zentral.
The first layer is validation. terraform validate checks the code against the provider schema, without contacting Zentral. A zentral_santa_enrollment without its meta_business_unit_id fails here, with the file, the line and the missing argument. terraform fmt and linters like TFLint add their own checks.
All of them run in seconds, on the agent’s machine. The agent writes, checks, fixes and checks again, until the code is valid. This loop is why agents are so good at writing valid Terraform: a mistake costs them a few seconds, not a failed change in Zentral. The provider documentation lists every resource and data source, with their arguments, so point the agent there too.
The second layer is the plan. It shows what the code would change in Zentral. Asked to add one Santa rule, the agent should find Plan: 1 to add, 0 to change, 0 to destroy. Anything else is a reason to look again. A destroy that was not asked for means stop and ask: removing a resource from the files removes it from Zentral, so this is the line of the plan that matters most.
The plan does not run on the agent’s machine. The agent works like any other contributor, through the GitOps workflow:
- The agent pushes its change to a branch, and opens a pull request.
mainis a protected branch: nothing is merged into it without the reviews you require. - The CI validates the code again, runs
terraform plan, and publishes the plan in the pull request. - The agent follows the CI run, reads the plan, and compares it with what was asked. If they do not match, the agent fixes the code and pushes again, until the plan shows the expected changes, and only those.
- A person, or a review agent, reviews the pull request: the code, and the plan next to it.
- Once the pull request is merged, the CI runs
terraform applyfrommain. Nothing else applies a change.
The agent never gets a Zentral API token, and it never runs terraform apply. The CI does, with the short-lived tokens of the OIDC API token issuers described above. The plan of a pull request can run with a service account whose PBAC policies only grant view actions. Only a workflow running on main, pinned with claims.ref == "refs/heads/main", gets a token for a service account whose policies allow changes.
Publish the text output of the plan in the pull request. It hides the sensitive values. The output of terraform show -json contains them, only marked as sensitive, so keep it out of the pull request.
The security of the whole setup comes from CI/CD best practices your team probably already follows for the rest of its infrastructure: protected branches, required reviews, short-lived tokens, and service accounts that can only do what their policies allow. An agent does not need a way in of its own. The plan it checked is the plan the reviewers see, and the code that is applied is the code they approved.
If you have questions, contact us.