How to Set GCP Organization Policies with Terraform and gcloud
Key Takeaways
google_org_policy_policyis the current Terraform resource for both boolean and list constraints. The oldergoogle_organization_policyresource targets the V1 API and doesn't support conditions or custom constraints; new work should usegoogle_org_policy_policyunless a codebase has a specific reason to stay on V1.enforce, allow_all,anddeny_allare strings, not native booleans. Write them as"TRUE"or"FALSE".Older provider releases were case-sensitive about the string itself; a later provider fix relaxed that requirement. The uppercase quoted form has worked across every version, and it's also the point where a Terraform config most often drifts from a hand-written gcloud YAML file, since gcloud's enforce field takes a real boolean.gcloud org-policies list --show-unsetis how you audit which constraints apply to a resource, not just which ones have an explicit policy. Without--show-unset,the default output only returns constraints someone has already touched, which undercounts what's governing the resource.- Terraform and the console can both write a policy, and they'll silently fight each other if you use both on the same resource. A policy edited in the console after Terraform applied it gets reverted on the next apply, with no warning beyond a plan diff most people skim past.
Prerequisites
Before writing the resource block, confirm the following:
- Permissions. The identity running Terraform or gcloud needs to be able to get and set
org policies: orgpolicy.policy.get, orgpolicy.policy.set, andorgpolicy.policies.listat minimum, plusorgpolicy.customConstraints.create,.get,.list, and.deletefor the custom constraint work covered below. Google's predefinedroles/orgpolicy.policyAdminbundles all of these, but it's grantable only at the organization node and inherits down from there; it isn't a role you grant directly at a folder or project. To scope write access to a single folder or project without handing out org-wide policy admin, build a custom role with just the permissions this work needs, since IAM custom roles take exact permission names rather than wildcards. - Provider version.
google_org_policy_policyrequires ahashicorp/googleprovider release recent enough to include it; the provider's own documentation marks the oldergoogle_organization_policyresource as superseded by it. A provider pin old enough to predate that change only exposes the V1-basedgoogle_organization_policyresource, without conditions or custom constraint support. Pin the provider version explicitly and check the changelog if a pipeline hasn't been upgraded recently. - The organization, folder, or project ID. The resource's
nameandparentfields take the numeric organization ID or the folder/project ID directly, not a display name. Pull it withgcloud organizations listif it isn't already in a Terraform data source. - The exact constraint name. Managed constraints follow the pattern
service.constraintName(for example,iam.disableServiceAccountKeyCreation). Get the exact string from the organization policy constraints reference or fromgcloud org-policies list --show-unset, covered below.
For the underlying mechanics, constraint types, and inheritance rules this resource controls, see GCP Organization Policy Explained. This page assumes that context and focuses on the implementation.
Terraform Resource for GCP Organization Policy
Boolean constraint
Enforcing a boolean constraint takes a single enforce value inside the rules block:
resource "google_org_policy_policy" "disable_sa_key_creation" {
name = "organizations/${var.org_id}/policies/iam.disableServiceAccountKeyCreation"
parent = "organizations/${var.org_id}"
spec {
rules {
enforce = "TRUE"
}
}
}List constraint
A list constraint carries an allowed_values set, a denied_values set, or both, inside a values block:
resource "google_org_policy_policy" "resource_locations" {
name = "organizations/${var.org_id}/policies/gcp.resourceLocations"
parent = "organizations/${var.org_id}"
spec {
rules {
values {
allowed_values = ["in:us-locations", "in:eu-locations"]
}
}
}
}List constraint scoped by condition
A list constraint can carry a CEL condition so one rule applies to matching resources, and a second, unconditional rule covers everything else. Terraform requires exactly one rule in the set with no condition, which acts as the fallback:
resource "google_org_policy_policy" "vm_external_ip" {
name = "projects/${var.project_id}/policies/compute.vmExternalIpAccess"
parent = "projects/${var.project_id}"
spec {
rules {
condition {
expression = "resource.matchTag('${var.org_id}/environment', 'production')"
title = "block-external-ip-in-production"
description = "Deny external IPs on resources tagged environment: production"
}
deny_all = "TRUE"
}
rules {
allow_all = "TRUE"
}
}
}resource.matchTag() always takes the organization ID in the first argument, in the form ORGANIZATION_ID/tag_key_short_name, even when the policy itself is scoped to a project. It's a namespaced reference to the tag key rather than a plain string match on the key's short name.
Scoping to a project or folder instead of the organization
Swap organizations/${var.org_id} for folders/${var.folder_id} or projects/${var.project_id} in both name and parent. Nothing else about the resource changes; the API infers the hierarchy level from the path.
Overriding or resetting an inherited policy
The companion pillar page covers inheritFromParent and resetToDefault as API concepts. In Terraform, they're inherit_from_parent and reset fields on spec, and mixing them up is the same mistake in HCL form.
Setting inherit_from_parent = false on a list constraint replaces the inherited policy at this node instead of merging with it:
resource "google_org_policy_policy" "resource_locations_override" {
name = "projects/${var.project_id}/policies/gcp.resourceLocations"
parent = "projects/${var.project_id}"
spec {
inherit_from_parent = false
rules {
values {
allowed_values = ["in:us-locations"]
}
}
}
}Setting reset = true clears this node back to the constraint's original default and requires the rule set to be empty:
resource "google_org_policy_policy" "resource_locations_reset" {
name = "projects/${var.project_id}/policies/gcp.resourceLocations"
parent = "projects/${var.project_id}"
spec {
inherit_from_parent = false
reset = true
}
}Reaching for inherit_from_parent = false with new rule values when the actual intent was reset = true is the Terraform-side version of the same authoring mistake described on the pillar page: it leaves the node with an empty policy of your own making, not the constraint's default state.
Custom constraints
google_org_policy_policy sets a policy against a constraint; it doesn't create one. A custom constraint needs its own resource applied first, and the policy resource references it by name:
resource "google_org_policy_custom_constraint" "restrict_machine_types" {
name = "custom.restrictMachineTypes"
parent = "organizations/${var.org_id}"
display_name = "Restrict VM creation to E2 machine types"
description = "Only allow Compute Engine instances using E2 machine types"
action_type = "ALLOW"
condition = "resource.machineType.matches('e2-')"
method_types = ["CREATE"]
resource_types = ["compute.googleapis.com/Instance"]
}
resource "google_org_policy_policy" "restrict_machine_types_enforce" {
name = "organizations/${var.org_id}/policies/${google_org_policy_custom_constraint.restrict_machine_types.name}"
parent = "organizations/${var.org_id}"
spec {
rules {
enforce = "TRUE"
}
}
}Apply the custom constraint before the policy that enforces it; Terraform's implicit dependency on google_org_policy_custom_constraint.restrict_machine_types.name handles the ordering as long as the policy resource references the constraint resource directly, rather than hardcoding the constraint name as a string.
Deletion behavior
deletion_policy on both google_org_policy_policy and google_org_policy_custom_constraint defaults to DELETE, which means a plain terraform destroy deletes the live policy or constraint, not just Terraform's record of managing it. Set it to PREVENT to make destroy fail outright on that resource, or ABANDON to remove it from state without touching the API, which is useful when handing a policy off to be managed outside Terraform:
resource "google_org_policy_policy" "disable_sa_key_creation" {
name = "organizations/${var.org_id}/policies/iam.disableServiceAccountKeyCreation"
parent = "organizations/${var.org_id}"
deletion_policy = "PREVENT"
spec {
rules {
enforce = "TRUE"
}
}
}Importing an Existing Policy into Terraform State
Most organizations have org policies already set through the console or ad hoc gcloud calls before Terraform arrives. Bringing one under management doesn't require deleting and recreating it. The import ID is the resource's name field:
terraform import google_org_policy_policy.disable_sa_key_creation \
"organizations/123456789012/policies/iam.disableServiceAccountKeyCreation"Custom constraints import the same way, against google_org_policy_custom_constraint:
terraform import google_org_policy_custom_constraint.restrict_machine_types \
"organizations/123456789012/customConstraints/custom.restrictMachineTypes"Importing only populates state; it doesn't generate the HCL. Write the resource block to match what's live before the next apply, particularly the enforce/allow_all/deny_all string values, or the first terraform plan after an import will show a diff against a policy that hasn't changed.
List GCP Organization Policy Constraints with gcloud
Before writing a policy, confirm the constraint exists and see its current state on the target resource:
# List every constraint that applies to this org, including ones with no policy set yet
gcloud org-policies list --organization=$ORG_ID --show-unset
# List only constraints with an explicit policy already set
gcloud org-policies list --organization=$ORG_ID--show-unset is the flag that matters here. Without it, list only returns constraints someone has already written a policy for, which makes an organization look far less governed than it is, when inherited defaults are doing real work. The same two forms work with --folder=$FOLDER_ID or --project=$PROJECT_ID in place of --organization.
To see what's in effect on a resource, accounting for inheritance, rather than just what's set directly on it:
gcloud org-policies describe iam.disableServiceAccountKeyCreation \
--organization=$ORG_ID \
--effectiveDrop --effective to see only the policy set at that exact node, with no inherited values folded in. The difference between the two outputs is usually where a "why isn't this enforced" investigation ends.
To confirm a custom constraint exists before writing a policy against it, rather than finding out at apply time:
gcloud org-policies list-custom-constraints --organization=$ORG_IDgcloud Equivalent for Applying a Policy
Terraform and gcloud both write to the same Organization Policy API, so a policy set by one is visible and editable by the other. The gcloud path takes a YAML file instead of an HCL block:
name: organizations/123456789012/policies/iam.disableServiceAccountKeyCreation
spec:
rules:
- enforce: true
gcloud org-policies set-policy sa-key-policy.yamlgcloud's YAML uses a native boolean (enforce: true), while Terraform's HCL uses the string "TRUE". That inconsistency comes from the Terraform provider schema rather than the API.
gcloud org-policies set-policy is idempotent: it creates the resource in the file's name field if it doesn't exist and updates it if it does, the same behavior Terraform's apply gives you. What gcloud doesn't give you is state tracking, drift detection, or a plan step before the change lands, which is the practical reason to standardize on Terraform for anything beyond a one-off fix.
Console Path
The console exposes the same functionality under IAM & Admin > Organization Policies, with a per-constraint edit page that lets you toggle enforcement or edit allowed and denied values directly. It's useful for a quick look at effective policy on a specific resource, or for a one-time emergency change when Terraform tooling isn't reachable. It shouldn't be the primary path for anything managed in code: a console edit doesn't show up in version control, and Terraform will revert it on the next apply without asking.
Common Errors
An invalid-argument error on enforce, allow_all, or deny_all. These fields are strings in the Terraform schema, not native booleans. Write enforce = "TRUE", not enforce = true, regardless of provider version; that's the one form confirmed to work across provider releases.
A constraint-not-found error at apply, not at plan. Terraform doesn't validate the constraint name against Google's catalog before sending the request. A typo in iam.disableServiceAccountKeyCreation, or a constraint that requires a newer API version than the provider supports, both surface at apply instead of during terraform plan. Confirm the exact string with gcloud org-policies list --show-unset first.
A custom constraint policy fails because the constraint doesn't exist yet. The google_org_policy_custom_constraint resource has to apply before the policy that enforces it; without the constraint in place, the policy resource has nothing to attach to.
A policy applies in terraform plan but doesn't appear to take effect on the resource. Check whether a lower node in the hierarchy has its own conflicting policy. For a boolean constraint, the nearest node to the resource wins; a project-level override can silently defeat an organization-level Terraform change. gcloud org-policies describe --effective at the project will show which node's value is winning.
Validating a Policy Change
Run these checks before and after an apply, not just after something breaks:
| Check | Command | What it confirms |
|---|---|---|
| Constraint exists and is unset or set | gcloud org-policies list --organization=$ORG_ID --show-unset | The constraint name is correct and its current state |
| Direct policy at this node | gcloud org-policies describe CONSTRAINT --organization=$ORG_ID | What's set at this exact node, ignoring inheritance |
| Effective policy after inheritance | gcloud org-policies describe CONSTRAINT --organization=$ORG_ID --effective | What's enforced, accounting for parent and child overrides |
| Terraform state matches live policy | terraform plan | No drift between the last apply and the resource's current state |
| Dry run before enforcing broadly | dry_run_spec block in google_org_policy_policy, or the console's Dry run tab on the policy's detail page | What the policy would block, evaluated against existing resources, before you flip it to enforced |
dry_run_spec belongs in the resource permanently for any constraint that could affect a large share of an organization's footprint, such as gcp.resourceLocations. It mirrors the spec block's structure and lets you compare what would be blocked against what's currently deployed before the enforced policy goes live.
Why Native
Native enforces every Organization Policy constraint across an environment against the compliance frameworks a team is accountable to, and closes the gap the moment a Terraform-managed policy drifts from what a console edit or a separate gcloud call left in place. See how Native handles compliance enforcement across Google Cloud.
FAQ
What's the difference between google_org_policy_policy and google_organization_policy in Terraform?
google_org_policy_policy targets the Organization Policy V2 API and supports conditions and custom constraints. google_organization_policy targets the older V1 API and doesn't. New Terraform work should use google_org_policy_policy unless there's a specific reason to stay on V1.
Why do I get an invalid argument error on enforce?
enforce, allow_all, and deny_all are strings in the provider schema, not native booleans. Write them as the quoted string "TRUE" or "FALSE". Older provider releases were also case-sensitive about that string, so the uppercase quoted form is the safest choice regardless of which provider version a pipeline is pinned to.
How do I list every GCP Organization Policy constraint, not just the ones already set?
Run gcloud org-policies list --organization=ORG_ID --show-unset. Without --show-unset, the command only returns constraints with an explicit policy already attached to that resource.
Can I manage the same policy with both Terraform and gcloud?
Technically yes, since both write to the same API, but doing so routinely causes drift. A change made through gcloud or the console gets silently reverted the next time Terraform applies, unless you update the Terraform resource to match first.
Does Terraform validate that a constraint name is correct before applying?
No. An incorrect or unsupported constraint name fails at apply time, rather than during terraform plan, with an error that references the missing constraint. Confirm the constraint name against gcloud org-policies list --show-unset or the constraints reference before writing the resource.
How do I bring an org policy that already exists into Terraform state?
Run terraform import against the resource, using the policy's name field as the import ID: terraform import google_org_policy_policy.example "organizations/ORG_ID/policies/CONSTRAINT_NAME". Import only populates state; write the matching HCL yourself before the next apply.
Does destroying a Terraform-managed org policy resource delete the policy?
By default, yes. deletion_policy defaults to DELETE, so terraform destroy removes the live policy, not just Terraform's record of it. Set deletion_policy = "ABANDON" to stop managing it without touching the API, or "PREVENT" to block destruction outright.