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
removedblocks. On older versions you’ll useterraform state rminstead. - AWS CLI v2 with a profile that can create and execute change sets and read every resource you’re moving.
- A clean plan.
terraform planmust report no changes before you start, so the template describes reality, not a pending edit. - An apply freeze. Disable the pipeline that runs
terraform applyfor 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:
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
- Freeze and snapshot TerraformConfirm a clean plan, list the addresses you’re moving and keep a copy of the state.
- Write the CloudFormation templateOne logical resource per AWS resource, matching live settings, each with
DeletionPolicy: RetainandUpdateReplacePolicy: Retain. - Collect resource identifiersUse
get-template-summaryto learn which property identifies each type. - Import with an
IMPORTchange setCreate it, read it, execute it and wait forIMPORT_COMPLETE. - Run drift detectionFix the template until every imported resource is
IN_SYNC. - Release the resources from TerraformReplace the resource blocks with
removedblocks and apply a plan that destroys nothing.
Step 1: freeze and snapshot Terraform
# 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.
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_tagsbecome explicitTagson each resource, or drift detection flags them. - Data sources become parameters or literal values.
countandfor_eachbecome separate logical resources (orFn::ForEachwith theAWS::LanguageExtensionstransform).random_*,null_resourceandterraform_datahave 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
[
{
"ResourceType": "AWS::S3::Bucket",
"LogicalResourceId": "OrdersArchiveBucket",
"ResourceIdentifier": { "BucketName": "acme-orders-archive-123456789012" }
},
{
"ResourceType": "AWS::DynamoDB::Table",
"LogicalResourceId": "OrdersTable",
"ResourceIdentifier": { "TableName": "orders" }
}
]
# 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
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.
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
}
}
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

