Terraform

Multi-Region Architecture with Terraform: Balancing DR and Consistency

2026-04-19
NicheeLab Editorial Team

Multi-region is not about standing up duplicate infrastructure — it is about being able to switch over intentionally with consistent operations. Terraform gives you reproducible configuration, but meeting the RTO/RPO requirements of a real DR plan requires careful provider design, deliberate state boundaries, and controlled concurrent execution.

Based on the documented behavior, this article walks through the Active-Active vs Active-Passive trade-offs, provider aliases, backend availability and locking, the proper role of workspaces, and the pitfalls most often tested on the exam.

Foundations: Multi-Region and DR Models

DR requirements are defined by RTO (Recovery Time Objective) and RPO (Recovery Point Objective). Terraform provides reproducible infrastructure definitions, but actually hitting your RTO/RPO depends on how you design state management, dependencies, and apply order. Active-Active prioritizes traffic distribution and data consistency, while Active-Passive prioritizes a clear cutover procedure and lower cost.

Terraform's job is to apply the same definition to multiple regions without contradictions, to keep concurrent applies from clashing, and to never corrupt state. DNS or traffic cutover is handled by cloud-side services (such as global DNS or a traffic manager) and woven into your overall operational plan.

  • DR patterns: Active-Active (low RTO/RPO, complex to implement) vs Active-Passive (moderate RTO, moderate-to-high RPO, simpler operations)
  • Terraform guarantees consistency through declare → plan → apply. Plan and apply must share the same remote state, and locking must be enabled
  • Replication and recovery of the data layer (databases, storage) rely on the cloud provider's features. Terraform focuses on declaring configuration and wiring components together

Conceptual multi-region apply flow

state/lockoutputsDev/CI RunnerPlan/ApplyTerraform CLI/CloudPlan/Apply/LockRemote State BackendS3/Terraform Cloud/etc.Provider Region AProvider Region BResources AResources BWith the S3 backend, pair it with a DynamoDB lock table. With Terraform Cloud or Enterprise, locking and queuing are handled by the service itself.

Provider Aliases and Module Patterns

Iterating across regions is cleanest and safest when you invoke one module per region. Providers cannot be used with for_each, so you define one aliased provider per region and pass it into each module. Inside the module, you simply accept the default aws (or target-cloud) provider, and the same code runs against each region.

Make dependencies explicit through module inputs and outputs instead of relying on implicit data source references. Locking prevents conflicts during concurrent applies, and you should avoid mutual references — enforce a one-way wiring discipline.

  • Define one provider per region using alias, then pass them via the module's providers argument
  • Treat the module as one instance per region. Use for_each at the call site, not inside the module — it stays simpler that way
  • Connect modules through inputs and outputs. Avoid mutual dependencies via data sources

Example of aliased providers and module invocations (using AWS to illustrate the pattern)

terraform {
  required_version  = ">= 1.5.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = ">= 5.0" }
  }
}

variable "regions" {
  # primary/secondaryを固定名で扱う。実務では環境毎の変数ファイルで値を切替
  type = object({
    primary   = object({ region = string })
    secondary = object({ region = string })
  })
}

provider "aws" {
  alias  = "primary"
  region = var.regions.primary.region
}

provider "aws" {
  alias  = "secondary"
  region = var.regions.secondary.region
}

# 1リージョン=1モジュールの原則。呼び出し側で2回呼ぶ
module "app_primary" {
  source    = "./modules/app"
  providers = { aws = aws.primary }
  region    = var.regions.primary.region
}

module "app_secondary" {
  source    = "./modules/app"
  providers = { aws = aws.secondary }
  region    = var.regions.secondary.region
}

# modules/app/main.tf(抜粋)
# module側はデフォルトawsプロバイダを受けるだけ
variable "region" { type = string }

resource "aws_s3_bucket" "logs" {
  bucket = "nicheelab-logs-${var.region}"
  tags   = { managed_by = "terraform", region = var.region }
}

State Splitting Strategy and Backend Availability

Draw state boundaries based on blast radius and the independence of parallel work streams. For multi-region setups, weigh the trade-offs between managing both regions in a single state versus splitting state per region. Generally, prioritize fault isolation during a regional outage and easy rollback — splitting state per region is the safer default.

Make locking mandatory on every remote backend. With the S3 backend, pair it with a DynamoDB lock table, and enable versioning plus encryption on the bucket. Cross-Region Replication is useful as a backup, but remember that the DynamoDB lock table itself is region-scoped. With the Terraform Cloud or Enterprise remote backend, locking, queuing, and high availability are provided by the service.

  • Backend configuration cannot reference variables. Inject per-environment or per-region values with -backend-config
  • S3 + DynamoDB lock: enable versioning, SSE, Block Public Access, and set appropriate IAM policies
  • Terraform Cloud/Enterprise: per-workspace queuing and locking make it easy to avoid concurrent-execution conflicts
State management unitProsRisks / caveatsTypical use cases
Single state managing both regionsThe whole picture fits in one plan/apply; dependencies are highly visibleA failed plan/apply takes down everything; parallel work easily collidesSmall or learning environments; configurations with tight mutual dependencies that must update together
Per-region state (recommended)Isolates blast radius; rollback is easy; handles parallel development wellCross-region dependencies must be explicitly wired through remote stateStaged updates in Active-Passive or Active-Active topologies
Further split by service × regionTiny change units; clear separation of responsibilitiesMore state files means more operational complexity and dependency overheadLarge organizations with clear team boundaries, in either monorepo or multi-repo setups

Partial S3 backend configuration with per-region apply

# backendは変数参照不可。部分定義して起動時に注入
terraform {
  backend "s3" {}
}

# backend-us-east-1.hcl
bucket         = "tfstate-prod"
key            = "app/us-east-1/terraform.tfstate"
region         = "us-east-1"
dynamodb_table = "tf-locks-prod"
encrypt        = true

# backend-ap-northeast-1.hcl
bucket         = "tfstate-prod"
key            = "app/ap-northeast-1/terraform.tfstate"
region         = "ap-northeast-1"
dynamodb_table = "tf-locks-prod"
encrypt        = true

# 初期化と適用(リージョン単位で分けて実行)
terraform init -backend-config=backend-us-east-1.hcl
terraform apply -auto-approve

# 別ディレクトリ/別ワークスペースでap-northeast-1を同様に実行
terraform init -backend-config=backend-ap-northeast-1.hcl
terraform apply -auto-approve

Maintaining Consistency: Locking, Dependencies, and Data Lookup Discipline

Locking is non-negotiable. Use DynamoDB locking with the S3 backend, or workspace locking and queuing with Terraform Cloud/Enterprise, to prevent simultaneous runs. Extend the wait time with -lock-timeout when needed.

Connect dependencies primarily through module inputs and outputs, and avoid overusing data sources. Keep the remote state data source strictly one-way — never create cycles. For plan stability, an explicit depends_on in a module is a useful tool.

Detect drift with terraform plan or refresh-only applies. When destructive changes are required, use lifecycle's create_before_destroy or replace_triggered_by to make the swap effectively atomic.

  • Avoid conflicts with terraform plan -lock=true (the default) and -lock-timeout=5m
  • Wire modules in one direction: outputs → inputs. Treat remote state as read-only
  • Where destructive changes are expected, use create_before_destroy to provision first, then switch over
  • Avoid cycles and avoid leaning on -target — both lead to inconsistent plans

Example of one-way dependency wiring via remote state

# us-east-1側のネットワーク出力を、ap-northeast-1側で参照(片方向)
# ap側のコード(抜粋)
data "terraform_remote_state" "primary_net" {
  backend = "s3"
  config = {
    bucket = "tfstate-prod"
    key    = "network/us-east-1/terraform.tfstate"
    region = "us-east-1"
  }
}

module "app_secondary" {
  source    = "./modules/app"
  providers = { aws = aws.secondary }
  region    = var.regions.secondary.region
  # 片方向に必要最小限の情報だけを受け取る
  vpc_id    = data.terraform_remote_state.primary_net.outputs.vpc_id
}

CI/CD and Operations: Apply Order, Failover, and Rollback

Build your pipeline around one stage per region and stop downstream stages on failure. For Active-Passive, the safe sequence is: update the standby first, verify health, switch traffic, then update the primary.

Run plan and apply against the same remote state and the same code revision. Rather than carrying a local plan around, standardize on a fresh plan in CI followed immediately by apply. With Terraform Cloud/Enterprise, per-workspace queuing controls concurrent execution for you.

  • Split jobs by region and skip the next region on failure
  • Make DNS / traffic cutover an explicit, separate job to avoid half-applied states
  • Treat the plan as an artifact and apply it in the same environment soon after, to prevent drift from sneaking in
  • Rotate backend credentials using OIDC or short-lived credentials

GitHub Actions skeleton for sequential per-region apply (conceptual)

jobs:
  apply-us:
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -backend-config=backend-us-east-1.hcl
      - run: terraform plan -out=plan.out -lock-timeout=5m
      - run: terraform apply -auto-approve plan.out
  apply-ap:
    needs: apply-us
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -backend-config=backend-ap-northeast-1.hcl
      - run: terraform plan -out=plan.out -lock-timeout=5m
      - run: terraform apply -auto-approve plan.out

Common Exam Traps and Pitfalls

Backend configuration cannot be parameterized with variables. Inject values via -backend-config or use separate environment files. Workspaces are intended for logical separation such as dev/stg/prod environments, not for splitting by region.

When renaming resources or moving them between regions, declare the state migration with a moved block to avoid unnecessary recreation. To bring existing resources under management, use either the import block or terraform import.

Targeted apply (-target) is a temporary workaround. Relying on it in steady-state operations will break your dependencies. Solve dependencies through module design instead.

  • The backend block cannot reference variables — handle this with -backend-config
  • Workspaces are not regions. Handle regions through state splitting or by invoking the module multiple times
  • Migrate state safely with the moved block, and bring existing assets in with import
  • Overusing -target breeds inconsistency. Prioritize plan completeness

Example of a moved block for region migration or renames

terraform {
  required_version = ">= 1.5.0"
}

# 以前: aws_s3_bucket.primary
# 以後:  aws_s3_bucket.this
moved {
  from = aws_s3_bucket.primary
  to   = aws_s3_bucket.this
}

resource "aws_s3_bucket" "this" {
  bucket = "nicheelab-logs-us-east-1"
}

Check Your Understanding

Pro

問題 1

You run a production workload with us-east-1 as primary and ap-northeast-1 as standby. Which Terraform design best balances DR readiness with consistency?

  1. Split state per region (S3 + DynamoDB locking) and apply the same module twice using provider aliases. Reference remote state outputs in one direction for any cross-region dependency, and run plan/apply sequentially per region in CI.
  2. Use a single state to apply both regions together; on failure, manually remove unwanted diffs and rerun. Switch regions via workspaces.
  3. Apply both regions in parallel with locking disabled, and rely on S3 Cross-Region Replication for DR.
  4. Parameterize the backend with variables to switch regions, cross-reference via data sources, and adjust dependency order on the fly with -target.

正解: A

Per-region state isolates blast radius and makes rollback controllable, while S3 + DynamoDB locking enforces consistency. Invoking the same module twice maximizes reuse, and one-way remote state references make cross-region dependencies explicit. Running plan/apply sequentially per region in CI limits the impact of any failure. The other options bake in anti-patterns: large-scale failures on a single state, disabled locking, workspace misuse, and -target dependence.

Frequently Asked Questions

If I enable Cross-Region Replication (CRR) on the S3 backend, does that also make the DynamoDB lock highly available?

No. CRR replicates S3 objects (state files), but the DynamoDB lock table is a separate, region-scoped service. Locks are taken against a table in a specific region, so you need to decide which table holds the lock and document the failover procedure separately from your replication strategy.

Is it acceptable to use Terraform workspaces to switch between regions?

Not recommended. Workspaces are best used for logical separation such as environments (dev/stg/prod). Region differences are typically handled with split state files or by invoking the same module twice. Using workspaces for regions blurs dependency and permission boundaries and raises the risk of misapplied changes.

Can I safely run apply against both regions in parallel?

Sequential execution is the safe default. If you must parallelize, give each region its own state, enable locking on every state, and verify there is no mutual dependency via remote state. Cross-references between states can cause cycles or partial failures.

Check what you learned with practice questions

Practice with certification-focused question sets

無料で問題を解いてみる
Author

NicheeLab Editorial Team

NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.


Related articles
Terraform

HCL Syntax: Terraform's Configuration Language (2026)

HCL2 fundamentals for Terraform — blocks, attributes, expres...

Terraform

Terraform Authoring & Operations Pro: Complete Guide (2026)

Tactics for the Terraform Pro exam — module authoring, works...

Terraform

Terraform Providers: Plugin Management Fundamentals (2026)

Provider mechanics — required_providers, versions, mirrors, ...

Terraform

Terraform Resource Blocks: Declarative Infra Units (2026)

Resource block fundamentals — addresses, references, common ...

Terraform

Terraform Data Sources: Read-Only External Data (2026)

Data source basics — declaration, refresh behavior, dependen...

Browse all Terraform articles (102)
© 2026 NicheeLab All rights reserved.