Allowed regions
For platform engineers deciding whether to apply variable_allowed_regions: what it changes, how to enable it, and how to check it worked.
Adds a validation block to the module's region variable, so a region outside your organization's allowlist is rejected during terraform validate or plan.
Use this when: Your organization is limited to specific regions by contract, residency, or policy, and you want that limit enforced where Terraform is run rather than only at the account boundary.
Do not use this when: The module takes no region variable — it inherits the provider's region — and you have not set allow_unset. The rule cannot constrain a value the module never accepts.
What this rule does
Adds a validation block to the module's region variable. This one is enforced on your side: the assertion ships inside the module source, so a value outside what the rule allows is rejected when you run Terraform, before anything is created.
Terraform checks a variable validation when it evaluates that variable, and where that happens depends on how the module is used. Consumed as a child module — the normal case for a module you pull by source — the check runs at terraform validate, whether the value comes from the parent's argument or from the module's own default. Run as the root module, terraform validate does not evaluate input variables at all: neither terraform.tfvars, nor -var, nor the default is checked there, and every one of them surfaces at terraform plan instead. A parent argument Terraform cannot resolve yet - one derived from another resource - is unknown at validate time and is checked when it becomes known. Either way the value never reaches apply, but a clean terraform validate on a root module is not proof that anything was checked. That split was measured on Terraform 1.15.2; Terraform has moved variable-validation evaluation before, so confirm it against the version you run before building a gate on it.
Existing HCL is never rewritten — the block is only added. Applying this to a module that already carries the identical validation changes nothing.
Affected resources
| Resource | Service | Why |
|---|---|---|
aws_* | Any AWS resource | the rule constrains the module's region variable, not one resource type |
Before and after
Before (a module without the change):
variable "region" {
type = string
default = null
}After (with Allowed regions applied):
variable "region" {
type = string
default = null
validation {
condition = var.region == null ? false : contains(["eu-west-1", "eu-central-1"], var.region)
error_message = <<EOF
Variable "region" must be one of the regions allowed by your organization: ["eu-west-1", "eu-central-1"]. An empty or unknown value is rejected.
EOF
}
}That is the real output, indentation included — the message is a heredoc, not a quoted string, and the module's closing brace ends up indented. The region list is whatever your organization configured; the two above are an example.
The module's arguments and outputs are unchanged. What changes is which values it accepts: the added validation rejects a value the rule does not allow, including a null default that used to be usable. That is the point of the rule, but it does mean a module can stop accepting an input it accepted before.
Why this rule exists
Where infrastructure runs is a contractual question before it is a technical one. A customer contract, a data residency clause, or a regulator's ruling can limit an estate to named regions, and the usual enforcement point is a Service Control Policy — which lives in the AWS account, far from the person writing the Terraform. That distance is the problem: a developer discovers the constraint when an apply fails against a policy they cannot read, after the plan looked fine.
This rule moves the constraint into the module itself. The allowlist your organization configured travels with the module source, so a disallowed region is rejected on the developer's own machine with a message naming the regions they may use. The SCP remains the backstop it should be; the rule is what stops a wrong region reaching it.
Default configuration
These are the values the rule ships with. Parameters never travel in a module source URL, so a rule named there runs with exactly these defaults. Your organization sets them per rule, in its Baseline or in a ruleset.
| Parameter | Type | Default | Description |
|---|---|---|---|
regions | list(string) | [] | Allowed AWS regions (enumerated, no globs). Empty = unconfigured: the rule injects nothing and enforces nothing |
allow_unset | bool | false | Accept a null region (provider-inherited). Unenforced by consent - it does not permit a wrong region |
variable_name | string | "region" | Name of the module variable carrying the region |
resource_types | list(string) | ["aws_*"] | Resource types this rule applies to. A module with none is not applicable |
The HCL this rule is written in, with its parameters and the transformers it runs, is published at variable_allowed_regions.
How to enable
On your organization's host, an org admin adds the rule to the Baseline, which reaches every module served from that host, or to a named ruleset that a module selects with ?ruleset=:
module "s3_bucket" {
source = "https://bobthecorp.compliance.tf/terraform-aws-modules/s3-bucket/aws?version=x.y.z&ruleset=<ruleset-name>"
}Replace x.y.z with the release you use and bobthecorp with your organization's alias. Which parameters each host accepts, and which it refuses with a 400, is on Rulesets in the console.
How to check it worked
Read compliancetf-manifest.json in the module you received. Its entry for this rule carries an outcome: enforced means the validation was injected, not_configured means the allowlist was empty so nothing was injected, not_applicable means nothing matched - either no resource of a targeted type, or the module already carried the identical validation, and waived_by_consent means allow_unset accepted an absent target. Do not infer enforcement from a successful terraform init - a rule that enforces nothing also inits cleanly.
Known limits
- Only the module's region variable. A resource that hardcodes a region inline, or a nested provider block naming one, is not reached.
- A CI stage that runs only
terraform validateis not a gate for this rule when the module under test is the root module. Root input variables are not evaluated at validate time, so a disallowed region passes that stage and fails later atplan. If you rely on this rule to stop a deployment, putterraform planin the pipeline. terraform validateis not a complete gate. Consumed as a child module it does check the region, but run as the root module it does not evaluate input variables at all, and the failure moves toterraform plan.- An empty allowlist enforces nothing. With no regions configured the rule injects nothing and is recorded as
not_configuredrather than enforced, because a build cannot tell an organization that wants nothing enforced from one that has not configured the rule yet. Rejecting an empty allowlist is config admission's job, not the build's. allow_unsetchanges two things, and the second is easy to miss. If the target variable is absent, nothing is injected at all - no region is checked, and the rule is recorded as waived. If the variable is present, the validation is injected, but the emitted condition then accepts anullregion alongside the allowed ones. Only in that second case is a wrong region still rejected.- Only
.tfand.tofufiles are read. Modules written in JSON syntax (.tf.json,.tofu.json) are left untouched. - Only the module's own directory and its first-level
modules/*submodules are processed. Modules that this module calls by source URL are separate downloads and are handled on their own request. - Works with Terraform 1.x and OpenTofu 1.6 and later. The rule reads ordinary HCL, and anything it changes stays ordinary HCL.
Compliance framework support
This rule is not a compliance control. It supports these framework objectives as an operational safeguard:
| Framework | Controls | Role |
|---|---|---|
| NIST SP 800-53 Rev 5 | SA-9(5) | Moves the approved-location constraint into the module source, so a disallowed location fails before provisioning rather than at the account boundary |
Failure modes
| Scenario | Result |
|---|---|
| A caller passes a region outside the allowlist | Consumed as a child module - the normal case - terraform validate fails with a message naming the regions your organization allows. If the module is your root module, validate does not evaluate input variables at all and the failure moves to terraform plan. Nothing is created either way. |
| The module's own default is a disallowed region | As a child module this is caught at terraform validate like any other value. As the root module it surfaces at terraform plan, because validate does not evaluate root input variables. The apply never runs. |
The module has no region variable and allow_unset is false | The rule errors rather than shipping a module it cannot constrain. |
The module has no region variable and allow_unset is true | No validation is injected, and the run is recorded as waived_by_consent rather than silently skipped. Your organization has consented to a provider-inherited region. |
| The module already carries an identical validation | No second block is added and the rule is recorded as not_applicable, because nothing matched that it still had to change. Applying the rule twice produces the same module. |
| The module already validates the same variable, but with a different condition | Both validations are kept - only an identical condition is treated as already applied - and Terraform requires a value to satisfy every one of them. If the upstream condition and your organization's allowlist have no region in common, the module accepts nothing and cannot be used until one of them changes. |
| A configured region value contains a Terraform interpolation | The rule errors and the build fails. Nothing is injected and the module is left unchanged; the guard runs before any HCL is rendered, so nothing is ever partially emitted. |
| The module contains no resource of a targeted type - a pure wrapper of other modules, say | The rule never reaches the module and is recorded as not_applicable. Nothing is injected and nothing is reported as wrong, so this is the case most easily mistaken for enforcement. |
Related
- Rulesets in the console - where an org admin adds this rule to the Baseline or a ruleset
- Rule catalog - every rule, with what each one changes