Skip to main content

NTC Account Factory Migration (v1 → v2)

This guide covers the migration of terraform-aws-ntc-account-factory from v1.x to v2.x.

Critical Migration — High Blast Radius

Account Factory manages the account baseline for all AWS member accounts in your organization. The baseline is deployed via CodePipeline/CodeBuild and its Terraform state is stored in S3. A failed migration can break the account baseline across multiple accounts simultaneously.

  • Always test in a non-production environment first (e.g., a sandbox baseline scope or a dedicated test organization)
  • Never apply changes to production baseline scopes without prior successful validation
  • Ensure you have backup access to member accounts (e.g., root credentials or break-glass roles) before migrating

Overview of Changes

v2.0.0 introduces a unified multi-region baseline deployment mode leveraging the AWS Provider v6 region attribute, eliminating the need for separate per-region Terraform workspaces.

Key Changes

Featurev1.xv2.x
Provider ArchitectureAWS Provider v5 with providers block on moduleAWS Provider v6 with region attribute
Baseline DeploymentSeparate Terraform workspace per regionSingle unified workspace
Baseline StatePer-region module instances (module.baseline_eu_central_1[0], module.baseline_us_east_1[0])Single module instance (module.baseline_unified[0])
Global Resources (IAM)Custom condition: count = var.is_current_region_main_region ? 1 : 0no condition needed
Regional ResourcesCreated per workspace executionfor_each = toset(var.baseline_regions) with region = each.key
Cross-Region ReferencesVia data sourceDirect references between regions
Baseline TemplatesLegacy baseline templates via ntc-account-baseline-templates v2.xUnified baseline templates via ntc-account-baseline-templates v3.x
Injected Variablesvar.main_region, var.current_region, var.is_current_region_main_regionvar.main_region, var.baseline_regions
Module providers Blockproviders = { aws = aws.euc1, aws.us_east_1 = aws.use1 }Not needed — uses region variable
Terraform Version>= 1.3.1>= 1.6.0
baseline_moved_resourcesNot availableNew option for account baseline state migration

Before & After Comparison

Legacy baseline: separate Terraform workspace per region, per-region module instances:

Account Factory
└── baseline-scope/
└── account-name/
├── baseline-module-eu-central-1/ ← Separate Terraform workspace
└── baseline-module-us-east-1/ ← Separate Terraform workspace
└── terraform.tfstate (module.baseline_eu_central_1[0].*, module.baseline_us_east_1[0].*)

State address examples:

# IAM role (global) — only created in main region via count
module.baseline_eu_central_1[0].aws_iam_role.ntc_iam__cloudwatch_crossaccountsharingrole[0]
module.baseline_eu_central_1[0].aws_iam_role_policy_attachment.ntc_iam__cloudwatch_crossaccountsharingrole[0]

# AWS Config (regional) — created in each region workspace
module.baseline_eu_central_1[0].aws_config_configuration_recorder.ntc_config_recorder
module.baseline_us_east_1[0].aws_config_configuration_recorder.ntc_config_recorder

Baseline Template Changes (ntc-account-baseline-templates)

The unified baseline requires new template versions that use the region attribute instead of per-region count guards:

iam_role.tftpl (legacy)
# Global resource — only create in main region
resource "aws_iam_role" "ntc_iam__example" {
count = var.is_current_region_main_region ? 1 : 0 # ← Count guard
name = "example-role"
# ...
}

resource "aws_iam_role_policy_attachment" "ntc_iam__example" {
count = var.is_current_region_main_region ? 1 : 0 # ← Count guard
role = aws_iam_role.ntc_iam__example[0].name # ← [0] index
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
aws_config.tftpl (legacy)
# IAM — only in main region
resource "aws_iam_role" "ntc_config_role" {
count = var.is_current_region_main_region ? 1 : 0
name = "ntc-config-role"
# ...
}

# Data source fallback for non-main regions
data "aws_iam_role" "ntc_config_role" {
count = var.is_current_region_main_region ? 0 : 1
name = "ntc-config-role"
}

# Config recorder — created once per region workspace
resource "aws_config_configuration_recorder" "ntc_config_recorder" {
name = "ntc-config-recorder"
role_arn = try(aws_iam_role.ntc_config_role[0].arn, data.aws_iam_role.ntc_config_role[0].arn)
}

Migration Steps

Migration Order

Always migrate non-production baseline scopes first (e.g., workload-accounts-non-prod) and validate thoroughly before migrating production scopes. The migration must be performed per baseline scope. DO NOT attempt to migrate all scopes simultaneously.

Step 1: Update Provider Configuration

Update terraform.required_providers to use AWS Provider v6:

main.tf
terraform {
required_version = ">= 1.3.0"

required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}

provider "aws" {
region = "eu-central-1"
}

provider "aws" {
alias = "euc1"
region = "eu-central-1"
}

provider "aws" {
alias = "use1"
region = "us-east-1"
}

Step 2: Update Account Factory Module Source

Update the module source to v2.x and add the region parameter:

ntc_account_factory.tf
module "ntc_account_factory" {
source = "github.com/nuvibit-terraform-collection/terraform-aws-ntc-account-factory?ref=1.11.0"

account_factory_baseline_bucket_name = "my-org-ntc-af-baseline"
account_factory_cloudtrail_bucket_name = "my-org-ntc-af-cloudtrail"

# ... configuration ...

providers = {
aws = aws.euc1 # Main provider
aws.us_east_1 = aws.use1 # Required for CloudTrail
}
}
Breaking Change: providers Block Removed

The providers = { aws = aws.euc1, aws.us_east_1 = aws.use1 } block is removed in v2.x. The module now uses the region parameter and the AWS Provider v6 region attribute internally. Remove the providers block from your module call.

Step 3: Remove Deprecated Variables

Remove references to variables that no longer exist in v2.x:

# REMOVED in v2.x — delete these lines:
account_factory_forward_crossregion_events_via_lambda = true # ← Remove

Step 4: Add Unified Baseline Templates (ntc-account-baseline-templates)

ntc-account-baseline-templates Versioning

The ntc-account-baseline-templates module has its own version lifecycle, independent from ntc-account-factory:

VersionTemplatesAWS ProviderNotes
v2.xLegacy baseline templatesAWS Provider v5Breaking change: new injected inputs require ntc-account-factory >= 1.11.0
v3.xUnified + Legacy baseline templatesAWS Provider v6Backwards compatible — v3.x still supports legacy templates via unified_multi_region_baseline = false (default). Set to true to generate unified templates. Requires AWS Provider v6.

Because v3.x is backwards compatible, you can upgrade to v3.x and continue using legacy templates while incrementally enabling unified templates per baseline scope.

This is the most critical step. The migration to unified templates must be done incrementally to avoid breaking existing baselines.

Do Not Remove Legacy Templates Yet

During migration, you must keep both legacy and unified templates in your configuration. Removing legacy templates before completing the state migration will cause Terraform to destroy all existing baseline resources across all accounts in the scope.

4a. Update ntc-account-baseline-templates Module

ntc_account_factory_baseline_template.tf
module "ntc_account_baseline_templates" {
# Update from v2.x to v3.x (adds unified template support, backwards compatible)
# v3.x requires AWS Provider v6 — ensure provider is upgraded first (Step 1)
source = "github.com/nuvibit-terraform-collection/terraform-aws-ntc-account-baseline-templates?ref=3.0.0"

account_baseline_templates = [
# Keep ALL existing legacy templates (do NOT remove yet)
{
file_name = "iam_monitoring_reader"
template_name = "iam_role"
iam_role_inputs = { /* ... existing configuration ... */ }
},
{
file_name = "aws_config"
template_name = "aws_config"
aws_config_inputs = { /* ... existing configuration ... */ }
},

# ADD unified versions alongside legacy templates (same configuration)
{
unified_multi_region_baseline = true # ← NEW: generates unified template
file_name = "unified_iam_monitoring_reader"
template_name = "iam_role"
iam_role_inputs = { /* ... same configuration as legacy ... */ }
},
{
unified_multi_region_baseline = true # ← NEW: generates unified template
file_name = "unified_aws_config"
template_name = "aws_config"
aws_config_inputs = { /* ... same configuration as legacy ... */ }
},
]
}

4b. Reference Unified Templates in Baseline Scope

Update the baseline scope you want to migrate to reference the unified templates and enable the unified mode:

Baseline Scope (Legacy)
{
scope_name = "workloads"

# Legacy template references
baseline_terraform_files = [
module.ntc_account_baseline_templates.account_baseline_terraform_files["iam_monitoring_reader"],
module.ntc_account_baseline_templates.account_baseline_terraform_files["iam_instance_profile"],
module.ntc_account_baseline_templates.account_baseline_terraform_files["oidc_spacelift"],
module.ntc_account_baseline_templates.account_baseline_terraform_files["aws_config"],
]

baseline_regions = ["us-east-1", "eu-central-1"]
baseline_main_region = "eu-central-1"
}

Step 5: Add Baseline State Migration (Moved Blocks)

Critical Step — Read Carefully

This step migrates the Terraform state of existing baseline resources from legacy per-region module instances to the unified module instance. The baseline_moved_resources are injected into the baseline Terraform code deployed to each account via CodePipeline/CodeBuild.

If the moved blocks are incorrect, Terraform will destroy existing resources and recreate them, potentially breaking all accounts in the baseline scope simultaneously. Validate every moved block address against the actual state.

Add baseline_moved_resources to the baseline scope configuration. The moved blocks translate legacy state addresses to unified state addresses:

Baseline Scope with Moved Resources
{
scope_name = "workloads"
unified_multi_region_baseline = true

baseline_terraform_files = [
module.ntc_account_baseline_templates.account_baseline_terraform_files["unified_iam_monitoring_reader"],
module.ntc_account_baseline_templates.account_baseline_terraform_files["unified_iam_instance_profile"],
module.ntc_account_baseline_templates.account_baseline_terraform_files["unified_aws_config"],
]

# STATE MIGRATION: Move resources from legacy to unified state addresses
baseline_moved_resources = [
# -----------------------------------------------
# IAM Role: Monitoring Reader (global resource)
# Legacy: module.baseline_eu_central_1[0] → Unified: module.baseline_unified[0]
# Note: [0] index removed (no more count guard)
# -----------------------------------------------
{
moved_from = "module.baseline_eu_central_1[0].aws_iam_role.ntc_iam__cloudwatch_crossaccountsharingrole[0]"
moved_to = "module.baseline_unified[0].aws_iam_role.ntc_iam__cloudwatch_crossaccountsharingrole"
},
{
moved_from = "module.baseline_eu_central_1[0].aws_iam_role_policy_attachment.ntc_iam__cloudwatch_crossaccountsharingrole[0]"
moved_to = "module.baseline_unified[0].aws_iam_role_policy_attachment.ntc_iam__cloudwatch_crossaccountsharingrole"
},

# -----------------------------------------------
# IAM Role: Instance Profile (global resource)
# -----------------------------------------------
{
moved_from = "module.baseline_eu_central_1[0].aws_iam_role.ntc_iam__ntc_ssm_instance_profile[0]"
moved_to = "module.baseline_unified[0].aws_iam_role.ntc_iam__ntc_ssm_instance_profile"
},
{
moved_from = "module.baseline_eu_central_1[0].aws_iam_instance_profile.ntc_iam__ntc_ssm_instance_profile[0]"
moved_to = "module.baseline_unified[0].aws_iam_instance_profile.ntc_iam__ntc_ssm_instance_profile"
},

# -----------------------------------------------
# AWS Config: IAM Role (global resource)
# -----------------------------------------------
{
moved_from = "module.baseline_eu_central_1[0].aws_iam_role.ntc_config_role[0]"
moved_to = "module.baseline_unified[0].aws_iam_role.ntc_config_role"
},

# -----------------------------------------------
# AWS Config: Regional Resources
# Legacy: per-region module instance, no for_each key
# Unified: single module, for_each with region key
# -----------------------------------------------
{
moved_from = "module.baseline_eu_central_1[0].aws_config_configuration_recorder.ntc_config_recorder"
moved_to = "module.baseline_unified[0].aws_config_configuration_recorder.ntc_config_recorder[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_us_east_1[0].aws_config_configuration_recorder.ntc_config_recorder"
moved_to = "module.baseline_unified[0].aws_config_configuration_recorder.ntc_config_recorder[\"us-east-1\"]"
},
{
moved_from = "module.baseline_eu_central_1[0].aws_config_delivery_channel.ntc_config_delivery"
moved_to = "module.baseline_unified[0].aws_config_delivery_channel.ntc_config_delivery[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_us_east_1[0].aws_config_delivery_channel.ntc_config_delivery"
moved_to = "module.baseline_unified[0].aws_config_delivery_channel.ntc_config_delivery[\"us-east-1\"]"
},
{
moved_from = "module.baseline_eu_central_1[0].aws_config_configuration_recorder_status.ntc_config_recorder"
moved_to = "module.baseline_unified[0].aws_config_configuration_recorder_status.ntc_config_recorder[\"eu-central-1\"]"
},
{
moved_from = "module.baseline_us_east_1[0].aws_config_configuration_recorder_status.ntc_config_recorder"
moved_to = "module.baseline_unified[0].aws_config_configuration_recorder_status.ntc_config_recorder[\"us-east-1\"]"
},
]

baseline_regions = ["us-east-1", "eu-central-1"]
baseline_main_region = "eu-central-1"

# ... remaining scope configuration ...
}

State Address Translation Reference

Use this table as a reference when building your baseline_moved_resources:

Resource TypeLegacy AddressUnified Address
Global (IAM)module.baseline_<main_region>[0].<resource>[0]module.baseline_unified[0].<resource>
Regionalmodule.baseline_<region>[0].<resource>module.baseline_unified[0].<resource>["<region>"]
Regional (Config)module.baseline_eu_central_1[0].aws_config_configuration_recorder.ntc_config_recordermodule.baseline_unified[0].aws_config_configuration_recorder.ntc_config_recorder["eu-central-1"]

Key differences:

  • Module name: module.baseline_<region>[0]module.baseline_unified[0]
  • Global resources: [0] index removed (no more count guard)
  • Regional resources: No [0] index, but gains ["<region>"] key (from for_each)
Identifying State Addresses

To find the exact resource addresses in your current state, check the CodeBuild/CodePipeline output. At the end of a baseline execution the state is listed.

Optional: Conditional Moved Blocks

If you need to apply moved blocks only to specific accounts (e.g., accounts that had previously imported resources), use moved_condition_account_names:

{
moved_from = "module.baseline_eu_central_1[0].aws_iam_openid_connect_provider.ntc_oidc__example[0]"
moved_to = "module.baseline_unified[0].aws_iam_openid_connect_provider.ntc_oidc__example"
moved_condition_account_names = ["my-org-management"] # Only for this account
}

Step 6: Update Baseline Import Resources (if used)

If you have baseline_import_resources, update the import addresses from legacy to unified format:

baseline_import_resources = [
{
import_to = "module.baseline_eu_central_1[0].aws_iam_openid_connect_provider.ntc_oidc__example[0]"
import_id = "arn:aws:iam::123456789012:oidc-provider/example.com"
import_condition_account_names = ["my-org-management"]
},
]

Step 7: Initialize, Plan, and Validate

# Upgrade providers
terraform init -upgrade

# Review the plan carefully
terraform plan

Expected plan output for the Account Factory module itself:

  • Some in-place updates (configuration drift from module changes)
  • Resource additions for new unified baseline infrastructure
  • NO resource deletions or recreations of critical resources
warning

The Account Factory terraform plan only shows changes to the Account Factory infrastructure (CodePipeline, CodeBuild, S3 objects, etc.) and not the baseline resources deployed to member accounts. The actual baseline migration (moved blocks) executes when the baseline pipeline runs in each member account.

Step 8: Apply and Monitor Baseline Execution

# Apply the Account Factory changes
terraform apply

After applying:

  1. Monitor CodePipeline execution in the AWS Console (CodePipeline service)
  2. The baseline pipeline will deploy the unified baseline to each account
  3. Watch for failed pipeline executions — these indicate problems with moved blocks
  4. Check CodeBuild logs for individual account baseline deployments
Monitor Every Pipeline Run

After applying, the baseline pipeline will execute across all accounts in the migrated scope. Monitor every single execution:

  1. Open the AWS CodePipeline Console
  2. Find the pipeline for the migrated baseline scope
  3. Check each CodeBuild execution for errors
  4. If any execution fails, stop the pipeline immediately and investigate

A failed baseline execution typically means a moved block is incorrect, causing Terraform to attempt resource recreation. Fix the moved blocks before retrying.

Expected CodeBuild output per account:

  • Multiple has moved to messages (state migrations)
  • In-place updates for tag changes or configuration drift
  • NO resource deletions or recreations

Step 9: Clean Up

After all baseline pipelines have executed successfully across all accounts:

  1. Remove legacy templates from account_baseline_templates (the ones without unified_multi_region_baseline = true)
  2. Remove baseline_moved_resources from the baseline scope (moved blocks are only needed for the first run)
  3. Apply again to clean up the Account Factory configuration
Final clean configuration
module "ntc_account_baseline_templates" {
source = "github.com/nuvibit-terraform-collection/terraform-aws-ntc-account-baseline-templates?ref=3.0.0"

account_baseline_templates = [
# Only unified templates remain
{
unified_multi_region_baseline = true
file_name = "unified_iam_monitoring_reader"
template_name = "iam_role"
iam_role_inputs = { /* ... */ }
},
{
unified_multi_region_baseline = true
file_name = "unified_aws_config"
template_name = "aws_config"
aws_config_inputs = { /* ... */ }
},
]
}
Migration Strategy: Scope by Scope

You can migrate baseline scopes independently. A recommended approach:

  1. Create a test scope with a single sandbox account → migrate → validate
  2. Migrate non-production scope (e.g., workload-accounts-non-prod) → validate across all accounts
  3. Migrate production scope (e.g., workload-accounts-prod) → validate
  4. Migrate core-accounts scope (if desired) → validate

You can also keep some scopes in legacy mode while others use unified mode. The unified_multi_region_baseline flag is per-scope.

Complete Migration Checklist

Use this checklist to track your progress per baseline scope:

  • Update AWS Provider to v6 (~> 6.0)
  • Update Terraform version requirement (>= 1.6.0)
  • Update terraform-aws-ntc-account-factory source to v2.x
  • Add region parameter to Account Factory module
  • Remove providers block from Account Factory module
  • Remove account_factory_forward_crossregion_events_via_lambda (if used)
  • Update terraform-aws-ntc-account-baseline-templates source from v2.x to v3.x (requires AWS Provider v6)
  • Add unified templates alongside legacy templates (keep both)
  • Set unified_multi_region_baseline = true on target baseline scope
  • Reference unified templates in baseline_terraform_files
  • Add baseline_moved_resources with correct state address mappings
  • Update baseline_import_resources addresses (if applicable)
  • Run terraform init -upgrade
  • Run terraform plan and validate (no unexpected destructions)
  • Apply Account Factory changes
  • Monitor all baseline pipeline executions
  • Verify baseline state in all accounts
  • Remove legacy templates (after successful migration)
  • Remove baseline_moved_resources (after successful migration)
  • Apply final cleanup

Troubleshooting

Issue: Baseline Pipeline Fails with "Resource Not Found"

Symptom: CodeBuild fails with errors about resources that should exist but are not found in state.

Solution:

  1. Download the legacy state file from S3 and compare resource addresses with your baseline_moved_resources
  2. Ensure the moved_from addresses exactly match the state file entries
  3. Check for [0] indices — legacy global resources always have [0], unified global resources do not
  4. Verify region underscores vs. hyphens: state uses module.baseline_eu_central_1 (underscores in module name, hyphens in region keys)

Issue: Resources Being Recreated Instead of Moved

Symptom: Plan shows destroy and create instead of moved operations.

Solution:

  1. The moved_from address doesn't match the existing state — check exact resource names
  2. For regional resources, ensure the moved_to includes the region key: ["eu-central-1"]
  3. For global resources, ensure you removed the [0] index in the moved_to address
  4. Download the state file and run terraform state list to see exact addresses

Issue: "Provider configuration not present" Errors

Symptom: Terraform fails with provider-related errors during baseline execution.

Solution:

  1. Ensure aws_provider_version in the baseline scope is set to a v6.x version (e.g., 6.25.0)
  2. Verify unified_multi_region_baseline = true is set on the baseline scope
  3. Check that unified templates use region = each.key instead of provider aliases

Issue: IAM Resources Created Twice

Symptom: Plan shows IAM resources being created when they already exist.

Solution:

  1. The IAM moved blocks are missing — IAM resources are global and need explicit state migration
  2. Legacy IAM resources live in module.baseline_<main_region>[0] with [0] count index
  3. Unified IAM resources live in module.baseline_unified[0] without [0] count index
  4. Add the missing IAM moved blocks (role, policy attachment, instance profile, OIDC provider)

Issue: Unified State File Not Created

Symptom: The unified state file path (unified/terraform.tfstate) doesn't appear in S3.

Solution:

  1. Verify unified_multi_region_baseline = true is set on the baseline scope
  2. Check that the Account Factory module source is updated to v2.x
  3. The unified state path is managed by Account Factory — ensure the module changes were applied successfully

Issue: Legacy State Files Still Exist After Migration

Symptom: Old per-region state files remain in S3 after migration.

Solution:

  • This is expected. Legacy state files are not automatically cleaned up
  • After validating the migration is successful, you can manually remove old state files from S3
  • Old state paths: s3://<baseline-bucket>/<scope-name>/<account-name>/<region>/terraform.tfstate

Need Help?