NTC Account Baseline Templates Migration (v3 → v4)
This guide covers the migration of the tfstate backend template in terraform-aws-ntc-account-baseline-templates from v3.x to v4.x.
The tfstate backend template manages Terraform state storage (S3 buckets, KMS keys) in every member account. A failed migration can break state access across multiple accounts simultaneously.
- Always test in a non-production baseline scope first
- Use
baseline_maintenance_plan_only = trueto validate the plan before applying
Overview of Changes
v4.0.0 changes the tfstate backend template to use for_each with region keys on all resources. Previously, resources were created only in the main region using index-based or unkeyed addresses. Now, all resources are keyed by region — even when deploying to a single region (default behavior).
Optionally, you can enable s3_regional_buckets = true to create a separate S3 state bucket (with dedicated KMS key) in each baseline region.
The resource addressing change from unkeyed/index-based to region-keyed is a breaking change that requires baseline_moved_resources for every baseline scope using the tfstate backend template — regardless of whether you enable regional buckets or not.
Key Changes
| Feature | v3.x | v4.x |
|---|---|---|
| S3 Bucket addressing | Unkeyed: aws_s3_bucket.ntc_tfstate | Region-keyed: aws_s3_bucket.ntc_tfstate["eu-central-1"] |
| KMS Key addressing | Index-based: aws_kms_key.ntc_state_encryption[0] | Region-keyed: aws_kms_key.ntc_state_encryption["eu-central-1"] |
| DynamoDB addressing | Index-based: aws_dynamodb_table.ntc_tfstate[0] | Region-keyed: aws_dynamodb_table.ntc_tfstate["eu-central-1"] |
| Default behavior | Single bucket in main region | Single bucket in main region (same, but region-keyed) |
| Regional buckets (new) | Not supported | Opt-in via s3_regional_buckets = true (requires s3_regional_namespace = true) |
| Resource iteration | count or single resource | for_each = toset(regions) |
state_locking_mechanism | "s3" or "dynamodb" | Deprecated — only S3 native locking is supported. DynamoDB table resources will be destroyed |
existing_kms_key_arn | Supported — bring your own KMS key | Deprecated — removed. Use a custom baseline template if you need custom encryption |
unified_multi_region_baseline | Required: true | Deprecated — unified is now the only mode and the default. The flag is accepted but ignored |
| Template file names | Prefixed: unified_tfstate_backend | Unprefixed: tfstate_backend (the unified_ prefix has been removed from all template files) |
Before & After Comparison
- v3.x (Index/Unkeyed)
- v4.x Default (Region-Keyed, Main Region Only)
- v4.x with Regional Buckets (Optional)
Terraform State Addresses:
├── aws_s3_bucket.ntc_tfstate ← unkeyed
├── aws_s3_bucket_acl.ntc_tfstate ← unkeyed
├── aws_s3_bucket_ownership_controls.ntc_tfstate ← unkeyed
├── aws_s3_bucket_policy.ntc_tfstate ← unkeyed
├── aws_s3_bucket_public_access_block.ntc_tfstate ← unkeyed
├── aws_s3_bucket_server_side_encryption_configuration.ntc_tfstate ← unkeyed
├── aws_s3_bucket_versioning.ntc_tfstate ← unkeyed
├── aws_kms_key.ntc_state_encryption[0] ← index [0]
├── aws_kms_key_policy.ntc_state_bucket_encryption[0] ← index [0]
├── aws_kms_alias.ntc_state_encryption[0] ← index [0]
├── aws_dynamodb_table.ntc_tfstate[0] ← only if state_locking_mechanism = "dynamodb"
└── aws_dynamodb_resource_policy.ntc_tfstate[0] ← only if state_locking_mechanism = "dynamodb"
Terraform State Addresses (main_region = eu-central-1):
├── aws_s3_bucket.ntc_tfstate["eu-central-1"] ← region key
├── aws_s3_bucket_acl.ntc_tfstate["eu-central-1"] ← region key
├── aws_s3_bucket_ownership_controls.ntc_tfstate["eu-central-1"] ← region key
├── aws_s3_bucket_policy.ntc_tfstate["eu-central-1"] ← region key
├── aws_s3_bucket_public_access_block.ntc_tfstate["eu-central-1"]← region key
├── aws_s3_bucket_server_side_encryption_configuration.ntc_tfstate["eu-central-1"] ← region key
├── aws_s3_bucket_versioning.ntc_tfstate["eu-central-1"] ← region key
├── aws_kms_key.ntc_state_encryption["eu-central-1"] ← region key
├── aws_kms_key_policy.ntc_state_bucket_encryption["eu-central-1"] ← region key
└── aws_kms_alias.ntc_state_encryption["eu-central-1"] ← region key
Terraform State Addresses (baseline_regions = ["eu-central-1", "us-east-1"]):
├── aws_s3_bucket.ntc_tfstate["eu-central-1"] ← main region (migrated)
├── aws_s3_bucket.ntc_tfstate["us-east-1"] ← additional region (new)
├── aws_s3_bucket_acl.ntc_tfstate["eu-central-1"]
├── aws_s3_bucket_acl.ntc_tfstate["us-east-1"]
├── ... (all S3 resources duplicated per region)
├── aws_kms_key.ntc_state_encryption["eu-central-1"]
├── aws_kms_key.ntc_state_encryption["us-east-1"]
└── ... (all KMS resources duplicated per region)
Optional: Enable Regional TFState Buckets
After completing the state migration, you can optionally enable regional buckets to create a separate S3 state bucket (with dedicated KMS key) in each baseline region. This requires two input flags:
tfstate_backend_inputs = {
s3_regional_namespace = true # Required — enables unique bucket naming: <name>-<account_id>-<region>-an
s3_regional_buckets = true # Creates a state bucket in each baseline region
# ...
}
s3_regional_buckets requires s3_regional_namespace = true. Changing s3_regional_namespace or s3_regional_buckets on an existing deployment will recreate buckets and can lead to state loss. Only enable these on new deployments or after careful planning.
When s3_regional_buckets = false (default), resources are created only in baseline_main_region — the same behavior as v3.x, but with region-keyed addressing.
Migration Steps
Always migrate non-production baseline scopes first and validate thoroughly before migrating production scopes. Perform the migration per baseline scope.
Step 1: Enable Maintenance Mode
Before making any changes, enable baseline_maintenance_plan_only on the baseline scope to prevent automatic applies:
{
scope_name = "core-accounts"
# ...
baseline_maintenance_plan_only = true
}
Apply this change to Account Factory so the baseline pipeline switches to plan-only mode.
Step 2: Update ntc-account-baseline-templates Module Source
Update the module source to v4.x:
- Before (v3.x)
- After (v4.x)
module "ntc_account_baseline_templates" {
source = "github.com/nuvibit-terraform-collection/terraform-aws-ntc-account-baseline-templates?ref=3.1.0"
account_baseline_templates = [
{
unified_multi_region_baseline = true
file_name = "unified_tfstate_backend"
template_name = "tfstate_backend"
tfstate_backend_inputs = {
s3_bucket_name = "my-org-tfstate"
# ...
}
},
]
}
module "ntc_account_baseline_templates" {
source = "github.com/nuvibit-terraform-collection/terraform-aws-ntc-account-baseline-templates?ref=4.0.0"
account_baseline_templates = [
{
file_name = "tfstate_backend"
template_name = "tfstate_backend"
tfstate_backend_inputs = {
s3_bucket_name = "my-org-tfstate"
# ...
}
},
]
}
Step 3: Add Baseline State Migration (Moved Blocks)
The baseline_moved_resources are injected into the baseline Terraform code deployed to each account via the baseline pipeline. If the moved blocks are incorrect, Terraform will destroy existing resources and recreate them, potentially breaking state access across all accounts in the scope.
Add baseline_moved_resources to each Account Factory baseline scope that uses the tfstate backend template. Replace eu-central-1 with your actual baseline_main_region value:
{
scope_name = "core-accounts"
# ...
baseline_moved_resources = [
# ...existing moved resources (if any)...
# -------------------------------------------------
# TFState Backend: KMS resources
# v3.x: index [0] → v4.x: region key
# -------------------------------------------------
{
moved_from = "module.baseline_unified[0].aws_kms_alias.ntc_state_encryption[0]"
moved_to = "module.baseline_unified[0].aws_kms_alias.ntc_state_encryption[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_unified[0].aws_kms_key.ntc_state_encryption[0]"
moved_to = "module.baseline_unified[0].aws_kms_key.ntc_state_encryption[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_unified[0].aws_kms_key_policy.ntc_state_bucket_encryption[0]"
moved_to = "module.baseline_unified[0].aws_kms_key_policy.ntc_state_bucket_encryption[\"eu-central-1\"]"
},
# -------------------------------------------------
# TFState Backend: S3 bucket resources
# v3.x: unkeyed → v4.x: region key
# -------------------------------------------------
{
moved_from = "module.baseline_unified[0].aws_s3_bucket.ntc_tfstate"
moved_to = "module.baseline_unified[0].aws_s3_bucket.ntc_tfstate[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_unified[0].aws_s3_bucket_acl.ntc_tfstate"
moved_to = "module.baseline_unified[0].aws_s3_bucket_acl.ntc_tfstate[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_unified[0].aws_s3_bucket_ownership_controls.ntc_tfstate"
moved_to = "module.baseline_unified[0].aws_s3_bucket_ownership_controls.ntc_tfstate[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_unified[0].aws_s3_bucket_policy.ntc_tfstate"
moved_to = "module.baseline_unified[0].aws_s3_bucket_policy.ntc_tfstate[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_unified[0].aws_s3_bucket_public_access_block.ntc_tfstate"
moved_to = "module.baseline_unified[0].aws_s3_bucket_public_access_block.ntc_tfstate[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_unified[0].aws_s3_bucket_server_side_encryption_configuration.ntc_tfstate"
moved_to = "module.baseline_unified[0].aws_s3_bucket_server_side_encryption_configuration.ntc_tfstate[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_unified[0].aws_s3_bucket_versioning.ntc_tfstate"
moved_to = "module.baseline_unified[0].aws_s3_bucket_versioning.ntc_tfstate[\"eu-central-1\"]"
},
]
}
The state_locking_mechanism input has been deprecated in v4.x — only S3 native locking (use_lockfile = true) is supported. If you were using state_locking_mechanism = "dynamodb", the DynamoDB table and its resource policy will be destroyed during the migration.
Before upgrading:
- Remove
state_locking_mechanismfrom yourtfstate_backend_inputs(or keep it — it will be ignored) - Ensure your
terraform_versionin the baseline scope is>= 1.10.0(required for S3 native locking) - The plan output will show the DynamoDB table as destroyed — this is expected
No moved blocks are needed for DynamoDB resources since they are being removed, not migrated.
The existing_kms_key_arn input has been removed in v4.x. The template always creates a dedicated KMS CMK per region for state encryption. If you need custom encryption (e.g., bring your own key), create a custom baseline template instead of using the tfstate backend template.
Replace eu-central-1 in all moved blocks with the value of your baseline_main_region. For example, if your main region is us-east-1:
{
moved_from = "module.baseline_unified[0].aws_s3_bucket.ntc_tfstate"
moved_to = "module.baseline_unified[0].aws_s3_bucket.ntc_tfstate[\"us-east-1\"]"
},
Step 4: Apply Account Factory and Validate Plan Output
Apply the Account Factory changes (module source update + moved blocks):
terraform init -upgrade
terraform plan
terraform apply
This will update the baseline pipeline configuration. Because baseline_maintenance_plan_only = true, the baseline pipeline will only run terraform plan in each account.
Review the baseline pipeline output for each account. The plan should show:
# module.baseline_unified[0].aws_s3_bucket.ntc_tfstate["eu-central-1"] will be moved
# from module.baseline_unified[0].aws_s3_bucket.ntc_tfstate
# module.baseline_unified[0].aws_kms_key.ntc_state_encryption["eu-central-1"] will be moved
# from module.baseline_unified[0].aws_kms_key.ntc_state_encryption[0]
# ...
Plan: 0 to add, X to change, 0 to destroy.
If the plan shows any destroy actions for S3 buckets, KMS keys, or DynamoDB tables, do not proceed. Review the moved blocks for incorrect addresses.
Step 5: Disable Maintenance Mode and Apply
After confirming the plan output is correct across all accounts in the scope:
{
scope_name = "core-accounts"
# ...
baseline_maintenance_plan_only = false
}
Apply Account Factory again to switch the baseline pipeline back to terraform apply mode. The pipeline will execute the state migration in each account.
Step 6: Clean Up Moved Blocks
After the migration has been successfully applied to all accounts in the baseline scope, remove the tfstate backend moved blocks from baseline_moved_resources:
{
scope_name = "core-accounts"
# ...
baseline_moved_resources = [
# ...keep other moved resources if any...
# Remove the tfstate backend moved blocks added in Step 3
]
}
Step 7: Repeat for Remaining Baseline Scopes
Repeat Steps 1–6 for each additional baseline scope (e.g., workload-accounts-prod, workload-accounts-non-prod).
Troubleshooting
Plan Shows Destroy for S3 Bucket
Symptom:
# module.baseline_unified[0].aws_s3_bucket.ntc_tfstate will be destroyed
Cause: The moved_from address does not match the actual state address. This can happen if:
- The resource was previously migrated from a legacy (non-unified) baseline
- The region key in
moved_todoes not matchbaseline_main_region
Solution: Check the actual state address using the baseline pipeline logs or by running terraform state list in the account. Adjust the moved_from address accordingly.
Plan Shows Create for New Regional Bucket
Symptom:
# module.baseline_unified[0].aws_s3_bucket.ntc_tfstate["us-east-1"] will be created
Cause: This is expected if you enabled s3_regional_buckets = true and have multiple baseline regions. The moved blocks only migrate the existing main-region resources. Additional region buckets are created as new resources.
Solution: No action needed — this is the expected behavior when enabling regional buckets.
Error: s3_regional_buckets Requires s3_regional_namespace
Symptom:
Bucket naming conflict or validation error when enabling s3_regional_buckets = true.
Cause: Regional buckets require unique bucket names per region. The s3_regional_namespace flag adds account ID and region to the bucket name to ensure uniqueness.
Solution: Set s3_regional_namespace = true alongside s3_regional_buckets = true. Note that enabling s3_regional_namespace on an existing bucket will recreate it — only enable both flags together on new deployments or after migrating existing state to a new bucket.
Error: Moved Block Does Not Match
Symptom:
Error: Moved object no longer exists
The source of the moved block does not match any resource in the state.
Cause: The resource at the moved_from address does not exist in the state. This can happen if:
- The moved block was already applied in a previous run
- The resource was never created (e.g., DynamoDB table when using S3 native locking)
Solution: Remove the moved block that references the non-existent resource.
Plan Shows Destroy for DynamoDB Table
Symptom:
# module.baseline_unified[0].aws_dynamodb_table.ntc_tfstate[0] will be destroyed
# module.baseline_unified[0].aws_dynamodb_resource_policy.ntc_tfstate[0] will be destroyed
Cause: The state_locking_mechanism input has been deprecated in v4.x. DynamoDB-based state locking is no longer supported — only S3 native locking is used. The DynamoDB table and its resource policy are removed from the template.
Solution: This is expected behavior. The DynamoDB table is safe to destroy because S3 native locking (use_lockfile = true) replaces it. Ensure your terraform_version in the baseline scope is >= 1.10.0 before proceeding.