Zitadel configuration as code with Terraform

Teams that run several Zitadel environments need identity configuration in version control, not in the console. This page covers how the Zitadel Terraform provider manages organizations, projects, applications and users, and what it takes to run it safely.

On this page

Zitadel configuration as code with Terraform: what it manages and when to use it

If your organization runs Zitadel in several environments and needs every change to identity settings reviewed, recorded and repeatable, Terraform is the usual starting point to evaluate. The Zitadel Terraform provider manages organizations, projects, applications, users and grants in a Zitadel instance by calling Zitadel's APIs, so the desired state of those objects can live in version-controlled files alongside the admin console. The Terraform configurations that Zitadel publishes are released under the Apache 2.0 license, which means your team can read them, copy them into internal repositories and adapt them to your own review process. The decision in front of you is whether identity configuration changes should pass through the same pull request and pipeline gates as your application infrastructure.

Before you commit, confirm the scope against the Zitadel release you run. The provider exposes a defined set of resources, and their names, arguments and behavior change between provider versions. Pin the provider version in your configuration and check that it matches the Zitadel server version in each environment, because a server-side API change can break a plan that worked last month. Ask for the resource list in writing: which organizations, projects, applications, users and grants can be declared, which attributes are read-only, and which settings still have to be managed in the console or through the API. Treat any setting missing from that list as a manual step that needs its own procedure and its own evidence.

Terraform fits best when several people change identity settings, when auditors ask who changed an application's redirect URIs or a user's grants, and when you need to rebuild a non-production instance from known inputs. It fits less well when the team is small, changes are infrequent and the settings you depend on sit outside the provider's coverage. In that case, a documented console procedure with exported configuration may be enough for now. Make the choice per object type, and decide deliberately which items stay manual. Keep high-churn items such as application registrations and role grants in code where the provider covers them. Record the decision in your change policy so reviewers know what a pull request can and cannot change.

At a high level, the supported method has four parts: a service account in the target instance with permissions limited to the objects you manage, a provider configuration that points at that instance and reads its credentials from your secrets store, a plan that shows the proposed changes for review, and an apply step run by your pipeline, with named people approving the plan. The detailed setup for service accounts, state handling and promotion between environments is covered in the sections that follow, so this section only sets the frame. Confirm with Zitadel's documentation for your release which authentication options the provider supports and how the service account should be scoped, since those details decide how much access your automation holds.

What you need before writing the first Terraform file

Terraform can only manage Zitadel after the platform, database, network path and credentials are in place. Each card below is a check to complete before the first plan runs, and most failures in early provider runs trace back to one of these items.

Self-hosted Zitadel deployment

Zitadel runs self-hosted on Linux, with Docker Compose or Kubernetes as the documented deployment routes. Zitadel recommends a Kubernetes cluster with the official Helm chart for high availability, so a single Compose host is typically a pilot setup, while production identity usually points to the Helm chart. Choose the deployment method before writing code, because the hostname, TLS configuration and upgrade process all follow from that choice.

PostgreSQL version within range

Self-hosted Zitadel stores its data in PostgreSQL, and versions 14 through 18 are supported. Confirm your database server falls in that range before installation, because an unsupported version would force a database change before any Terraform work can start. Terraform never connects to PostgreSQL, so database access stays a deployment concern owned by your platform team.

Running instance and admin account

Have a working Zitadel instance with an admin account that can sign in to the Console and create organizations, projects and machine users. Record the instance URL exactly as the provider will call it, since that domain feeds the provider configuration. Use the admin account for setup, then hand routine Terraform runs to the dedicated service account described in a later card.

Network path to the Zitadel API

The Terraform runner must reach the Zitadel API over HTTPS from wherever plans and applies run, whether that is a CI runner, a bastion host or a workstation. Check firewall egress rules, proxy settings and whether the runner trusts your internal certificate authority. Test the path with a read-only request before the first plan, because a failing connection often surfaces as a confusing provider error.

Service account with minimum permissions

Create a dedicated machine user for Terraform and grant it only the roles needed for the organizations, projects and applications in scope. Reusing the admin account means a leaked CI secret could control the entire instance. Confirm with the provider documentation which grants each resource type requires, and store the service account key in your secrets manager with a scheduled rotation date.

Pinned provider matched to your release

Pin the Zitadel Terraform provider version in your required providers block and commit the dependency lock file, so every run uses the same provider build. Check that pinned version against the Zitadel release you run, since provider and server behavior can diverge across versions. Read the release notes for both before upgrading either one, and schedule that check alongside every Zitadel upgrade.

How to set up Terraform for Zitadel in six steps

This sequence takes a Zitadel instance from manually created configuration to one where organizations, projects, applications and users are managed as code. It ends with a plan that shows Terraform and the live configuration agree. Each step closes with a check you can run before moving on.

  1. Create a dedicated service account with scoped roles

    Your platform or identity team creates a machine user in the Zitadel instance for Terraform to use, separate from any person's account. Grant it only the roles that the managed objects need, scoped to the organizations Terraform will manage. Avoid instance-wide roles unless a managed object requires them, and record which roles were granted and why in your repository documentation. Done when a token request for this user succeeds, a read of the target organization works, and a request outside its scope is refused.

  2. Store the service account key outside Git

    The person who created the machine user generates its key and places it in a secrets manager your organization already operates, such as a vault or the secret store of your CI system. The key file never enters the repository, a local working copy under version control, or pipeline logs. Limit read access to the runners and operators that run Terraform, and name an owner responsible for rotation. Done when a scan of the repository history finds no key material and the pipeline can read the secret at run time.

  3. Pin the provider and Zitadel versions

    The platform engineer declares the Terraform provider version in the committed lock file and records the Zitadel server version your instance runs. Match the provider to the Zitadel release line you operate, and confirm the compatible pair in the provider's documentation before changing either one. Upgrade the provider and the server in separate, reviewed changes. Done when terraform init succeeds with the committed lock file and the recorded versions match the running instance.

  4. Configure the provider for your instance and organization

    The engineer sets the provider's connection details: the instance domain, the service account credentials read from the secret store, and the ID of the organization that Terraform manages. Keep these values in variables so the same code can target staging and production with different settings. The instance is the tenant boundary, and projects and users live inside an organization, so the provider must point at the correct level of both. Done when a read of one organization returns its name and ID without errors.

  5. Import existing objects before managing them

    Objects that already exist, such as projects, applications, roles and users created through the console, are brought under Terraform with import blocks or import commands. Write the matching resource definitions first so the imported state reflects what currently exists, and only then let Terraform manage those objects. Record each import in your change log. Done when every existing object appears in state and no import reports a conflict or duplicate.

  6. Run a plan with no unexpected changes

    Run terraform plan against the imported state and review every proposed change line by line. The expected result is no changes, or only changes that match a reviewed commit. A proposed change to an application's redirect URIs, a role grant or a user's email, with no matching commit, means the definitions or imports are wrong, so fix the code and do not apply it. Done when the plan is clean in the staging organization and the saved output is attached to the pull request.

How Zitadel objects map to Terraform resources

This table shows where each Zitadel object sits in the instance, organization and project hierarchy, which Terraform resource manages it, and who usually owns it. Use the import identifier column when you bring existing objects under management, and check every entry marked for confirmation against the provider documentation for your pinned version. The main takeaway is that ownership follows the hierarchy, so keep platform-owned objects in a separate state from customer-owned ones.

ObjectPosition in hierarchyUsually owned byImport identifier
InstanceTop-level tenant with full data isolation; contains all organizationsPlatform teamUsually not imported. Confirm which instance settings the provider exposes for your version
OrganizationInside an instance; holds projects and usersPlatform team for the provider's own org, customer administrator for customer orgsOrganization ID. Confirm the import format in the provider docs
ProjectInside an organization; groups applications, roles and grantsPlatform team for shared projects, customer administrator for their ownProject ID within its organization. Confirm the format for your version
ApplicationInside a project; holds client settings, redirect URIs and auth methodApplication owner or customer administratorComposite of project ID and application ID. Confirm the separator and order
Project roleInside a project; referenced by grants and token claimsPlatform team defines, project owner addsProject ID and role key. Confirm the key format
User grantLinks a user to a project and its roles within a granting organizationCustomer administrator for their users, platform team for staffGrant ID with user and project IDs. Confirm the composite format
Human userInside an organization; identity for a personCustomer administrator or identity teamUser ID. Confirm whether your workflow imports users or leaves them to login flows
Machine userInside an organization; service identity Terraform uses to call the APIPlatform teamUser ID. Keep the key material out of state and confirm the provider's key handling

How to handle Terraform state for identity configuration

Treat Terraform state for Zitadel configuration with the same care you give the identity system it describes. State records what Terraform last knew about each resource it manages, including organization and project identifiers, application client IDs and the attribute values the provider returns. Depending on the resources in your configuration, some of those values may be sensitive, such as generated client secrets or user contact details. Anyone who can read the state can learn how your tenants, projects and applications are wired together. Before you write the first resource, confirm with the Zitadel Terraform provider documentation which attributes it stores in state and which it marks as sensitive, and plan your storage and access controls around that list.

Keep state in a remote backend that supports encryption and locking, and never leave it on a developer laptop or in a CI workspace that is discarded after each run. Locking stops two applies from writing to the same state at the same time, which matters when a manual change and a pipeline run overlap. Encryption should cover data at rest and in transit. Ask your chosen backend vendor or platform team to confirm how it implements locking, whether it keeps previous state versions and for how long, how it encrypts stored objects, and whether access to state is recorded in an audit log. Do not assume these behaviors from the general Terraform documentation, because they depend on the backend you select.

Use a separate state for each environment. A staging and a production configuration should never share a state file, because a plan run against the wrong workspace can change production applications or grants. Within an environment, decide whether each Zitadel instance, and each organization inside it, gets its own state. Smaller states limit the blast radius of a bad apply and narrow who needs access, but they add pipelines and more places where drift can appear. Give each state its own service account credentials with a Zitadel role limited to the organizations and projects that state manages, and grant read access to state only to the people and pipelines that need it.

Run a scheduled plan against each state, using credentials that can read but not change configuration, and alert the owning team when the plan reports differences. Changes made in the Zitadel console that bypass code are drift, and they will be overwritten on the next apply if nobody notices them first. Decide in advance whether console edits are allowed for emergency fixes and who must reconcile them. When drift appears, freeze applies for that state, save a copy of the current state version, and read the plan carefully. If the live change was intentional, update the code to match and apply. If it was not, apply the code to restore the intended configuration. Use terraform import or terraform state rm only after you have confirmed which side is correct, and rehearse this recovery in staging before you need it in production.

How to promote identity configuration from staging to production

Staging is where a Terraform change to Zitadel is written, planned and exercised before anything touches production. The goal is to prove the change behaves correctly against a realistic copy of the configuration, including the login flows that users actually take. Staging does not need to mirror production data, but it does need the same resource shapes, so that a plan produced there predicts what production will do.

The work in this environment usually includes:

  • The change itself: a branch that edits organizations, projects, applications or identity provider resources, with variables supplied from a staging file rather than hard-coded values.
  • Plan review: a saved plan output that a second person reads for destroyed or replaced resources, changed redirect URIs and altered grants before any apply runs.
  • Apply to staging: a single apply from the reviewed plan, with the state held in the staging backend and never shared with production.
  • Smoke tests of login flows: sign-in through each affected application, a check that the expected identity provider button appears for the right organization, and a test of the passkey or password fallback path.
  • Recording results: noting which flows were tested, by whom, and against which commit, so the release gate has evidence to read.

Before leaving staging, confirm that the plan shows no unexpected deletions, that every redirect URI in the staging file matches the staging application, and that the login smoke tests pass for every application the change touches.

Security controls for Terraform-managed identity

A Terraform run that manages Zitadel organizations, projects and applications can change who signs in to your systems. The controls below are the inputs an assessor would review, and each depends on the others. Confirm how your team will implement them before the first apply runs against production.

Least-privilege service account roles

The Terraform provider authenticates as a service account in Zitadel, so that account's role grants define what a plan or apply can change. Scope it to the instance, organization or project it manages, and avoid instance-wide administrator rights for routine runs. Document which grants each workspace needs, then review them whenever a new resource type is added to the configuration. Ask your Zitadel administrator to confirm the role names available in your version.

Short-lived or rotated credentials

Long-lived service account keys are a common way automation credentials outlive their purpose. Prefer credentials that expire on their own, or set a rotation schedule with a named owner and a record of each rotation. Check how your CI runner receives the credential at run time and whether it is written to logs or to the workspace afterward. Confirm with your Zitadel administrator which key types your deployment supports.

Secrets kept in a secrets manager

The service account key and any client secrets used by the Terraform run belong in a secrets manager such as OpenBao or your cloud provider's service, injected into the runner at execution time. Keep them out of .tf files, variable files, module sources and commit history. Scan repositories for accidental commits, and rotate any key that has ever been pushed. Ask who can read the secret path and whether those reads are logged.

Reviewed changes through pull requests

Every change to organizations, projects, applications or users should arrive as a pull request with the plan attached, so the diff is visible before anything applies. Require approval from someone other than the author, and allow the apply job to run only from the protected branch. Use commit signing where your platform supports it, so each change ties to a named person. Separation of duties depends on this step: the person who proposes a change and the person who approves it should be different people.

Restricted access to state and plan output

Terraform state and saved plan files can contain attribute values for every resource the configuration manages, including sensitive ones. Store state in a backend with encryption at rest, access control per workspace and state locking enabled, and limit who can download it. Treat plan artifacts as sensitive, set a retention period, and check whether your CI system exposes them to forks or to users without write access. Confirm which values the Zitadel provider marks as sensitive in your provider version.

Audit logging of applies

Each apply should leave a record of who triggered it, which commit ran, what changed and when, stored outside the pipeline that produced it. Forward CI job logs and state version history to your central log store, and confirm that Zitadel's own audit events for the service account are retained. Assessors usually ask whether the people whose changes are recorded can alter those records. Define the retention period with your compliance lead.

Why Terraform plans fail or drift against Zitadel

This table lists the failures teams most often hit when running the Zitadel Terraform provider, with the likely cause, the first check to make and the fix. Work through the first check before editing resource code, because it usually separates an access problem from a state problem. The main takeaway is that most failures trace back to credentials, state or per-environment values, so fix those first.

FailureLikely causeFirst checkFix
Authentication error on plan or applyExpired or deleted service account key, or the machine user lacks the role in the target organizationCheck the key's expiry and the role grant in the target organizationCreate a new key, update the secret store, and grant only the roles the configuration needs
Plan tries to recreate console-made objectsProject, application or user was created in the console and never imported into stateCompare the planned create with the object ID shown in the consoleImport the object by its ID, then rerun plan until it shows no changes
Persistent drift after console editsSomeone changed an application or project setting in the console outside TerraformReview the console audit history for that object's last changeReapply the code to restore the declared state, and limit console write access to managed objects
Redirect URI or domain mismatchStaging redirect URIs or domains carried into production, or values hardcoded in resource codeDiff the redirect URI list and domain values per workspace against the target environmentMove URIs and domains into per-environment variables, and validate them before apply
Errors after provider or Zitadel upgradeProvider version does not match the Zitadel release running in your environmentCompare the Zitadel release with the provider version pinned in your lock fileConfirm the pairing in the provider documentation, test in staging, then upgrade both together
Secrets appear in plan output or stateClient secrets or machine key material set as unmarked outputs or printed in CI logsSearch plan logs and state files for key material and check output definitionsMark sensitive outputs, keep state remote and encrypted with restricted access, and mask CI logs
Lost state or conflicting appliesLocal state with no locking, so two runs change the same objectsCheck the state backend for locking support and the serial of the last runUse remote state with locking, and run applies only from a controlled CI pipeline

What the work needs from your team

The first input is a written inventory of what the configuration will cover. List every Zitadel organization and project in scope, the applications in each project, and the roles and grants those applications depend on. Confirm whether staging and production are separate instances or separate organizations inside one instance. Zitadel treats instances as fully isolated tenants, so this decision sets how much code can be shared and how changes move between environments. For each organization, name the identity provider used for sign-in, such as Entra ID, Okta, LDAP or local login with passkeys, because each organization can federate differently. Also document the administrator access model: who holds instance administrator rights, who needs read-only access, and who can act in an emergency.

Before the build starts, grant access to four things. First, the Zitadel instances in each environment, plus a dedicated service account that Terraform will use. Give that account only the roles needed for the scope in your inventory, and confirm the exact role set against the provider documentation for your Zitadel version, since permissions can change between releases. Second, the state backend. Name the storage system, the locking mechanism and the people allowed to read the state file, because state records resource identifiers and may hold sensitive values. Third, the secrets store where the service account key and any client secrets will live. Fourth, the Git repository, with branch protection, the CI runner that executes plans, and permission to create protected branches and pull requests.

Define the change approval process before the first apply. Decide who reviews a plan, who approves a merge to the production branch, and who may run an apply against production. Separate these roles where your change policy requires it. Write down the emergency path as well. A change made directly in the console during an incident will drift from the code unless someone reconciles it within a set window. Name at least two people who will own the repository after handover, and confirm they can read Terraform plans, review provider changes and explain the state layout to the rest of the team. A single owner creates a dependency that is hard to recover from when that person leaves.

Once the pattern is set, your team can handle most routine work without us. Adding an application to an existing project, granting a role, onboarding a customer organization that uses an identity provider already covered by the pattern, and rotating a service account key all happen through a pull request and a reviewed plan. Keep the parts that need design judgment with DeployOpen under a fixed scope or retainer: a new identity provider, a change to instance layout, or an upgrade of the Terraform provider or the Zitadel version. Ask in the written quote which of these are included, how many review cycles are covered, and how your team will be told about provider updates before they reach production.

Questions buyers ask before hiring a partner for Terraform on Zitadel

How much does Terraform configuration as code for Zitadel cost?

DeployOpen does not publish prices. A quote follows from the written scope, so the cost depends on what you ask us to build. The main drivers are the number of environments (for example, staging and production), the number of Zitadel organizations and projects, the number of identity providers to federate, the state and pipeline design, and whether existing configuration must be imported into Terraform. The build work is one-time. Ongoing changes to the code, provider version upgrades and pipeline maintenance recur and can be covered by a retainer. Ask for a quote that lists each environment, organization, identity provider and pipeline separately, so you can see which items change the cost if your scope changes. Hosting and Zitadel licensing are separate costs that this work does not include.

How long does it take to bring Zitadel configuration under Terraform?

The timeline is set in the written scope before work starts. We do not give a fixed duration here because it depends on how much configuration already exists and how it was created. An instance configured mostly through the console takes longer to import than one that was scripted from the start. Other factors include the number of environments, whether you need a promotion path from staging to production, how many approval steps your change process requires, and how quickly your team reviews changes. Ask for a timeline with milestones tied to deliverables, and ask which parts depend on your reviews or access approvals. Those dependencies are the most common source of schedule slip on engagements like this.

What does our team need to provide for a Zitadel Terraform engagement?

You provide access and decisions. Expect to supply a running self-hosted Zitadel instance for each environment, an administrator account or service account with a clear permission scope, and a location for Terraform state in your cloud or data center. You also need access to your CI/CD platform, an inventory of existing organizations, projects, applications, roles and identity providers, and named owners who approve changes to production identity configuration. Your team should decide the naming standard, the review process for pull requests and the change window for production. Agree these inputs in writing during scoping. Missing inputs such as credentials or owner approvals are the most frequent reason a project waits.

Can an in-house platform team manage Zitadel with Terraform alone?

Yes, a capable in-house team can do this without a partner, provided it has working knowledge of Terraform, Zitadel's concepts of instances, organizations and projects, and your CI/CD platform. The harder parts are usually the ones that take time to learn: designing state and workspace boundaries, importing existing objects without recreating them, handling secrets, and building a promotion path from staging to production. Ask yourself whether the team can absorb these tasks alongside its normal work, and who will maintain the code after launch. A partner is worth considering when the deadline is fixed, the existing configuration is large or inconsistent, or no one on the team has done this before. Compare the written scope from a partner against your own estimate of the same tasks.

Does the Zitadel Enterprise plan cover Terraform setup work?

The Zitadel Enterprise plan includes custom support agreements and technical account management, according to Zitadel's pricing page. That support is provided by Zitadel and is a separate contract from this implementation work. Do not assume it covers designing your Terraform state, writing modules, importing existing configuration or building your pipeline. Ask Zitadel exactly what the support agreement and technical account management include, including response expectations and whether they cover configuration questions. Then compare that scope with the written scope from DeployOpen. The two can complement each other, but each should be checked on its own terms so that no task falls between them.

What goes wrong when Terraform and the Zitadel console both change identity configuration?

The usual failure is drift. An administrator changes an application, role or identity provider in the console, and the next Terraform apply either reverts the change or fails because the state no longer matches the live object. To avoid this, decide which objects Terraform owns and make the console read-only for those objects in production, or agree a written procedure for emergency console changes that are then written back into code. Run a plan on a schedule and alert when it shows unexpected changes. If you import existing configuration, check each imported object against the live instance before the first apply. Illustrative example: a renamed role is changed in the console on Friday, and the Monday pipeline removes it from users who depend on it.

How do we keep client secrets out of Terraform state for Zitadel?

Treat Terraform state as sensitive, because values that a provider writes into state can include secrets. Store state in a backend that encrypts data at rest and restricts read access to the pipeline and a small group of administrators. Avoid printing secret outputs in logs or pipeline summaries, and do not commit state files to source control. Where possible, generate client secrets outside Terraform and pass them through a secret manager at runtime, and confirm how the Zitadel provider version you pin handles sensitive attributes in state. Review the provider documentation for that version before you design the pipeline, because behavior can differ between releases. Document who can read state and how access is revoked when someone leaves.

How DeployOpen scopes, delivers and supports Zitadel configuration as code

DeployOpen plans and implements Zitadel configuration as code with Terraform inside your own infrastructure, so organizations, projects, applications and users are defined in version-controlled files. Engagements are fixed-scope, with the scope, deliverables and timeline agreed in writing before work starts, and ongoing support available as a retainer.

How we scope

  • The written scope lists which Zitadel instances, organizations, projects and applications the Terraform code will manage.
  • We record which resources stay manual in the console and which move under code, with the reason for each boundary.
  • The scope names the service account setup, state storage location, and the staging and production environments in use.

How we build

  • We write Terraform modules for organizations, projects, applications, roles, grants and users against your Zitadel version.
  • We configure a dedicated service account with the permissions the provider needs, and document how its credentials are stored.
  • We test plans in staging, review drift output, and promote the same configuration to production through your pipeline.

How we hand over

  • You receive the module code, environment variable layout, state backend configuration and the promotion runbook.
  • Documentation covers how to add an application, rotate service account credentials and recover state after an interrupted apply.
  • We walk your engineers through a plan, apply and rollback exercise so the handover is tested by the people who will run it.

How we support

  • Ongoing support covers provider version upgrades, Zitadel release changes and fixes to modules as your identity model grows.
  • Retainer support can include periodic drift reviews and updates to the configuration as organizations and applications change.
  • Confirm with us during scoping how changes are requested, reviewed and approved after handover, and what the retainer covers.

Sources and further reading

Talk to our team.

Tell us what you're working on, whether it's a deployment, an audit, a security test or a cyber range. You'll speak with an engineer who can help you scope it.

  • 30-minute call: free, with no obligation.
  • NDA on request: we can sign before you share details.
  • Clear next steps: a scope and plan after the call.