Skip to main content

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.

Critical Migration — Affects All Accounts Using tfstate Backend

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 = true to 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.

State Migration Always Required

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

Featurev3.xv4.x
S3 Bucket addressingUnkeyed: aws_s3_bucket.ntc_tfstateRegion-keyed: aws_s3_bucket.ntc_tfstate["eu-central-1"]
KMS Key addressingIndex-based: aws_kms_key.ntc_state_encryption[0]Region-keyed: aws_kms_key.ntc_state_encryption["eu-central-1"]
DynamoDB addressingIndex-based: aws_dynamodb_table.ntc_tfstate[0]Region-keyed: aws_dynamodb_table.ntc_tfstate["eu-central-1"]
Default behaviorSingle bucket in main regionSingle bucket in main region (same, but region-keyed)
Regional buckets (new)Not supportedOpt-in via s3_regional_buckets = true (requires s3_regional_namespace = true)
Resource iterationcount or single resourcefor_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_arnSupported — bring your own KMS keyDeprecated — removed. Use a custom baseline template if you need custom encryption
unified_multi_region_baselineRequired: trueDeprecated — unified is now the only mode and the default. The flag is accepted but ignored
Template file namesPrefixed: unified_tfstate_backendUnprefixed: tfstate_backend (the unified_ prefix has been removed from all template files)

Before & After Comparison

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"

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
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
# ...
}
warning

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

Migration Order

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:

Account Factory baseline scope
{
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:

ntc_account_factory_baseline_template.tf
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"
# ...
}
},
]
}

Step 3: Add Baseline State Migration (Moved Blocks)

Critical Step — Read Carefully

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:

Account Factory baseline scope
{
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\"]"
},
]
}
Deprecated: DynamoDB State Locking

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:

  1. Remove state_locking_mechanism from your tfstate_backend_inputs (or keep it — it will be ignored)
  2. Ensure your terraform_version in the baseline scope is >= 1.10.0 (required for S3 native locking)
  3. 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.

Deprecated: existing_kms_key_arn

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.

Adjusting the Region Key

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.
Verify No Destroy Actions

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:

Account Factory baseline 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:

Account Factory baseline scope
{
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_to does not match baseline_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.