Migrate a Terraform Stack to CloudFormation Without Downtime

Long aisle between server racks in a data centre with cool white lighting

Photo by panumas nikhomkhai on Pexels

To migrate a Terraform stack to CloudFormation without downtime, freeze Terraform applies, write a template that matches every live resource, and give each one DeletionPolicy: Retain. Import them with an IMPORT change set, run drift detection until the stack is in sync, then release the resources from Terraform with removed blocks set to destroy = false. Nothing is recreated.

This walkthrough is for teams moving an AWS-only workload from Terraform to CloudFormation, usually to standardise on one tool or to hand a service to a team that runs CloudFormation or CDK. It’s the reverse of our guide to import CloudFormation resources into Terraform safely, and the order of operations is just as important: CloudFormation adopts the resources first, and Terraform lets go second.

By the end you’ll have a CloudFormation stack that owns the resources, a drift check that says IN_SYNC, and a Terraform state that no longer lists them. If you haven’t decided whether to switch at all, read AWS CDK vs Terraform for existing AWS stacks first; it covers when a migration isn’t worth it. The same retain-and-release handover works if you migrate a Terraform stack to AWS CDK TypeScript or migrate a Terraform stack to Pulumi TypeScript instead.

What you need before you migrate a Terraform stack to CloudFormation

  • Terraform 1.7 or later for removed blocks. On older versions you’ll use terraform state rm instead.
  • AWS CLI v2 with a profile that can create and execute change sets and read every resource you’re moving.
  • A clean plan. terraform plan must report no changes before you start, so the template describes reality, not a pending edit.
  • An apply freeze. Disable the pipeline that runs terraform apply for this configuration until the handover is done.
  • Resource types that support import. Check each type against CloudFormation’s resource type support list. Common ones such as S3 buckets, DynamoDB tables, Lambda functions and IAM roles do.

The example moves the same data layer used in the CloudFormation-to-Terraform guide, an S3 bucket and a DynamoDB table, now managed by Terraform:

main.tf (current Terraform)

resource "aws_s3_bucket" "orders_archive" {
  bucket = "acme-orders-archive-123456789012"
}

resource "aws_s3_bucket_versioning" "orders_archive" {
  bucket = aws_s3_bucket.orders_archive.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "orders_archive" {
  bucket                  = aws_s3_bucket.orders_archive.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_dynamodb_table" "orders" {
  name         = "orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "pk"
  range_key    = "sk"

  attribute {
    name = "pk"
    type = "S"
  }

  attribute {
    name = "sk"
    type = "S"
  }

  point_in_time_recovery {
    enabled = true
  }
}

The migration, step by step

  1. Freeze and snapshot TerraformConfirm a clean plan, list the addresses you’re moving and keep a copy of the state.
  2. Write the CloudFormation templateOne logical resource per AWS resource, matching live settings, each with DeletionPolicy: Retain and UpdateReplacePolicy: Retain.
  3. Collect resource identifiersUse get-template-summary to learn which property identifies each type.
  4. Import with an IMPORT change setCreate it, read it, execute it and wait for IMPORT_COMPLETE.
  5. Run drift detectionFix the template until every imported resource is IN_SYNC.
  6. Release the resources from TerraformReplace the resource blocks with removed blocks and apply a plan that destroys nothing.

Step 1: freeze and snapshot Terraform

Terminal

# Exit code 0 means no changes are pending
terraform plan -detailed-exitcode

# Addresses you are about to move
terraform state list

# Keep a copy of the current state outside the backend
terraform state pull > pre-migration.tfstate

Step 2: write the CloudFormation template

Draft it from the HCL with the free Terraform to CloudFormation converter, then fix it by hand. The main manual job is merging: the AWS provider splits one S3 bucket into several resource types, while CloudFormation models versioning and the public access block as properties of AWS::S3::Bucket. Four Terraform resources become two logical resources. Where HCL interpolation has to become !Sub, !Ref or !GetAtt, our CloudFormation !Sub, !Ref and !GetAtt to Terraform reference maps each pair; read it right to left.

orders-data.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: Orders data layer (imported from Terraform)
Resources:
  OrdersArchiveBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: acme-orders-archive-123456789012
      VersioningConfiguration:
        Status: Enabled
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
  OrdersTable:
    Type: AWS::DynamoDB::Table
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      TableName: orders
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: pk
          AttributeType: S
        - AttributeName: sk
          AttributeType: S
      KeySchema:
        - AttributeName: pk
          KeyType: HASH
        - AttributeName: sk
          KeyType: RANGE
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true

Import accepts any DeletionPolicy value, but use Retain. It means a mistaken stack delete later leaves the data behind, and it protects the resources while you’re still testing the new stack. Watch for Terraform-only constructs as you translate:

  • Provider default_tags become explicit Tags on each resource, or drift detection flags them.
  • Data sources become parameters or literal values.
  • count and for_each become separate logical resources (or Fn::ForEach with the AWS::LanguageExtensions transform).
  • random_*, null_resource and terraform_data have no AWS resource behind them. Replace them with the literal values they produced.

Physical names must match exactly, so copy them from state, not from memory. If you prefer a template generated from the live account, CloudFormation’s IaC generator scans existing resources and writes one you can import; it still needs the same review.

Steps 3 and 4: import with an IMPORT change set

resources-to-import.json

[
  {
    "ResourceType": "AWS::S3::Bucket",
    "LogicalResourceId": "OrdersArchiveBucket",
    "ResourceIdentifier": { "BucketName": "acme-orders-archive-123456789012" }
  },
  {
    "ResourceType": "AWS::DynamoDB::Table",
    "LogicalResourceId": "OrdersTable",
    "ResourceIdentifier": { "TableName": "orders" }
  }
]
Terminal

# Which property identifies each resource type in this template
aws cloudformation get-template-summary --template-body file://orders-data.yaml \
  --query "ResourceIdentifierSummaries"

aws cloudformation create-change-set --stack-name orders-data \
  --change-set-name import-from-terraform --change-set-type IMPORT \
  --template-body file://orders-data.yaml \
  --resources-to-import file://resources-to-import.json

aws cloudformation wait change-set-create-complete --stack-name orders-data \
  --change-set-name import-from-terraform

aws cloudformation describe-change-set --stack-name orders-data \
  --change-set-name import-from-terraform \
  --query "Changes[].ResourceChange.[LogicalResourceId,Action]" --output table

aws cloudformation execute-change-set --stack-name orders-data \
  --change-set-name import-from-terraform

aws cloudformation wait stack-import-complete --stack-name orders-data

Every row in the change set should say Import. CloudFormation’s rules for manual resource import are strict: an import operation can’t create, delete or change the properties of other resources, a resource can belong to only one stack in a Region, and CloudFormation doesn’t check that your template matches the live configuration. That last rule is why step 5 exists. If the template names IAM roles, add --capabilities CAPABILITY_NAMED_IAM.

Tip: For simple stacks, CloudFormation can also auto-import. Add --import-existing-resources to a normal CREATE change set and it adopts resources whose template has a static custom name and a DeletionPolicy of Retain or RetainExceptOnCreate. S3 buckets and DynamoDB tables are on the supported list. Review the change set just as carefully.

Step 5: check drift

Terminal

DRIFT_ID=$(aws cloudformation detect-stack-drift --stack-name orders-data \
  --query StackDriftDetectionId --output text)

# Repeat until DetectionStatus is DETECTION_COMPLETE
aws cloudformation describe-stack-drift-detection-status \
  --stack-drift-detection-id "$DRIFT_ID" \
  --query "[DetectionStatus,StackDriftStatus]" --output text

aws cloudformation describe-stack-resource-drifts --stack-name orders-data \
  --query "StackResourceDrifts[].[LogicalResourceId,StackResourceDriftStatus]" \
  --output table

Every resource should report IN_SYNC. A MODIFIED resource means the template disagrees with reality, usually a default Terraform set that you didn’t write down, or tags from default_tags. Correct the template to match the live value; don’t change the resource. Some properties aren’t covered by drift detection, so also compare the settings that matter most by hand. Once the migration is done, the script to detect CloudFormation drift across all stacks keeps checking every stack on a schedule.

Step 6: release the resources from Terraform

Only now does Terraform let go. Replace each moved resource block with a removed block. HashiCorp’s page on removing a resource from Terraform state recommends this over terraform state rm because the change shows in a plan before anything happens.

release.tf

removed {
  from = aws_s3_bucket.orders_archive
  lifecycle {
    destroy = false
  }
}

removed {
  from = aws_s3_bucket_versioning.orders_archive
  lifecycle {
    destroy = false
  }
}

removed {
  from = aws_s3_bucket_public_access_block.orders_archive
  lifecycle {
    destroy = false
  }
}

removed {
  from = aws_dynamodb_table.orders
  lifecycle {
    destroy = false
  }
}
Terminal

terraform plan    # must show zero resources to destroy
terraform apply
terraform state list   # the four addresses are gone

# Terraform older than 1.7: remove the resource blocks, then
# terraform state rm aws_s3_bucket.orders_archive aws_s3_bucket_versioning.orders_archive \
#   aws_s3_bucket_public_access_block.orders_archive aws_dynamodb_table.orders

If the plan shows any destroy, stop: a resource block was deleted without a matching removed block. After the apply, delete the removed blocks in a later commit, and re-enable your pipeline for whatever Terraform still manages.

Permissions needed for the migration

Phase IAM actions
Import cloudformation:GetTemplateSummary, CreateChangeSet, DescribeChangeSet, ExecuteChangeSet, DescribeStacks
Drift check cloudformation:DetectStackDrift, DescribeStackDriftDetectionStatus, DescribeStackResourceDrifts
Resource reads Read access to each imported type, such as s3:GetBucketVersioning, s3:GetBucketPublicAccessBlock, dynamodb:DescribeTable, dynamodb:DescribeContinuousBackups, because CloudFormation reads each resource during import and drift detection

If a step fails with AccessDenied, the error names the missing action; our method to troubleshoot AWS IAM access denied errors finds which policy blocks it. Keep the migration role separate from everyday roles and remove it afterwards; the checklist to review an IAM policy for least privilege applies here too.

Common mistakes and how to fix them

Symptom Cause and fix
Change set fails: resource already exists in another stack The resource belongs to a stack already. Remove it from that stack with Retain first.
Change set fails validation A required property is missing or a value breaks the type schema. Add the property with its live value.
Drift shows MODIFIED tags Terraform default_tags weren’t copied. Add them to the template’s Tags.
terraform plan wants to destroy A resource block was removed without a removed block. Restore it and add the block.
Both tools “fix” the same setting The apply freeze was lifted before step 6. Never let both tools manage one resource.
Import rolled back At IMPORT_ROLLBACK_COMPLETE the stack is back on its previous template and Terraform still owns the resources. Read the stack events, fix the template, try again.

When something behaves differently after the handover, start with what’s deployed: you can list AWS resources in plain English from your terminal and follow the approach in troubleshooting AWS infrastructure with an AI CLI.

Where ChatWithCloud fits, and its limits

The Terraform to CloudFormation converter is one of the free AI code converters for AWS infrastructure. It drafts the template from HCL and adds comments where there’s no direct equivalent; it doesn’t read your state or run the import, so the bucket merge above is still your job. Treat the output as a draft that must be reviewed and tested. Very large configurations may hit the 60,000-character limit per conversion described in ChatWithCloud’s free converter limits, and the note on pasting infrastructure code into an AI converter explains what’s sent. For the opposite direction there’s the CloudFormation YAML to Terraform converter.

The ChatWithCloud CLI can answer inventory questions (“which DynamoDB tables have point-in-time recovery on?”) using your AWS profile. It runs changes without a confirmation step, so use a read-only profile; the ChatWithCloud security model explains what stays on your machine.

Frequently asked questions

Can I migrate a Terraform stack to CloudFormation without recreating resources?

Yes. CloudFormation resource import adopts existing resources, and a removed block with destroy = false takes them out of Terraform state without touching them.

Should I remove resources from Terraform state before importing into CloudFormation?

No, import first. If the import fails, Terraform still owns everything. Keep applies frozen so the two tools never act on the same resource.

Does CloudFormation check that my template matches the resource?

No. Import validates that the resource exists and that the template follows the type schema, not that values match. Run drift detection after every import.

Can I import Terraform modules into nested stacks?

Resource import supports one level of nesting. Flatten deep module trees into a single stack or one level of nested stacks.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud