Photo by Mohammad Rahmani on Unsplash
To migrate a Terraform stack to CDK TypeScript, write CDK constructs that match each live resource exactly, set RemovalPolicy.RETAIN, and run cdk import so CloudFormation adopts the existing resources. Then release them from Terraform with a removed block and destroy = false, run drift detection, and confirm cdk diff shows no changes. Nothing is deleted or recreated.
This guide is for platform and DevOps engineers who have already decided to move a Terraform-managed AWS stack to the AWS CDK in TypeScript and now need the procedure. It doesn’t reargue the choice; if you’re still weighing it, read our comparison of AWS CDK vs Terraform for existing AWS stacks first. The same freeze, import and release order applies if you migrate a Terraform stack to plain CloudFormation templates or move a Terraform stack to Pulumi TypeScript.
By the end you’ll have a CDK stack that owns the same S3 bucket and DynamoDB table Terraform used to own, the same physical names and data, and a Terraform state that no longer lists them. The example is small on purpose, but every step scales to a real stack.
What you need before you start
- Terraform 1.7 or later. The
removedblock that releases resources without destroying them arrived in 1.7. Check withterraform version. - The AWS CDK CLI and a TypeScript CDK app. Create one with
cdk init app --language typescriptif you don’t have one yet. - A current CDK bootstrap stack.
cdk importuses the bootstrap deploy role, which needs bootstrap template version 12 or later. - A change freeze on the Terraform stack. Nobody runs
terraform applyon it until the migration is done. Two tools changing one resource is the main way this goes wrong. - A list of what exists.
terraform state listandterraform state show <address>give you the exact settings you’ll reproduce in CDK.
terraform version
terraform state list
terraform state show aws_s3_bucket.uploads
# CDK bootstrap version in the target account and region (needs 12 or later)
aws ssm get-parameter --name /cdk-bootstrap/hnb659fds/version --query Parameter.Value --output text
cdk bootstrap aws://123456789012/us-east-1 # upgrades the bootstrap stack if it's older
How to migrate a Terraform stack to CDK TypeScript: the procedure
- Inventory the Terraform resourcesMap every resource address to the physical ID CloudFormation will need (bucket name, table name, role name).
- Write matching CDK constructsOne construct per CloudFormation resource, with every property set to its current value and a fixed physical name.
- Retain everythingSet
RemovalPolicy.RETAINon each construct so the resources getDeletionPolicy: Retain. CloudFormation import requires aDeletionPolicyon every imported resource, and setting it in code keeps it there after later deploys. - Synthesize and read the templateRun
cdk synthand compare each resource’s properties withterraform state show. - Import into CloudFormationRun
cdk import. It creates the stack (or adds to it) with an IMPORT change set; nothing is created or modified. - Check for driftRun CloudFormation drift detection, because the import doesn’t verify your properties.
- Release from TerraformReplace the resource blocks with
removedblocks (destroy = false), then plan and apply. - Confirm a clean diffRun
cdk deployonce to add CDK’s metadata, thencdk diffshould report no differences.
Importing into CDK before releasing from Terraform is deliberate. If the import fails, Terraform still owns everything and you’ve lost nothing. The window where both tools track a resource is safe as long as the change freeze holds.
Map Terraform resources to CDK constructs
Terraform’s AWS provider splits many settings into separate resources, while CloudFormation keeps them as properties of one resource. That changes what you write. Here’s the Terraform stack we’ll migrate:
resource "aws_s3_bucket" "uploads" {
bucket = "acme-orders-uploads-123456789012"
}
resource "aws_s3_bucket_versioning" "uploads" {
bucket = aws_s3_bucket.uploads.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "uploads" {
bucket = aws_s3_bucket.uploads.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "uploads" {
bucket = aws_s3_bucket.uploads.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
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
}
}
| Terraform resource | Becomes in CDK | CloudFormation resource |
|---|---|---|
aws_s3_bucket |
new s3.Bucket(...) with bucketName |
AWS::S3::Bucket |
aws_s3_bucket_versioning |
versioned: true |
Property of the bucket |
aws_s3_bucket_public_access_block |
blockPublicAccess: BLOCK_ALL |
Property of the bucket |
aws_s3_bucket_server_side_encryption_configuration |
encryption: S3_MANAGED |
Property of the bucket |
aws_dynamodb_table |
new dynamodb.Table(...) with tableName |
AWS::DynamoDB::Table |
Five Terraform resources become two CloudFormation resources. You can draft the TypeScript with the free Terraform to CDK TypeScript converter, but treat the output as a first draft: converters tend to produce new resources with generated names, and for an import you need fixed physical names and retained resources. If your team works in Python instead, the Terraform to CDK Python converter gives the same starting point. If the team switches to Python after the migration, you can translate the AWS CDK TypeScript app to Python without changing any logical IDs.
import * as cdk from "aws-cdk-lib";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as s3 from "aws-cdk-lib/aws-s3";
import { Construct } from "constructs";
export class OrdersStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Was: aws_s3_bucket + _versioning + _public_access_block + _server_side_encryption_configuration
new s3.Bucket(this, "UploadsBucket", {
bucketName: "acme-orders-uploads-123456789012",
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// Was: aws_dynamodb_table.orders
new dynamodb.Table(this, "OrdersTable", {
tableName: "orders",
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
}
}
const app = new cdk.App();
new OrdersStack(app, "OrdersStack", {
env: { account: "123456789012", region: "us-east-1" },
});
Run cdk synth OrdersStack and read cdk.out/OrdersStack.template.json. Each resource should carry "DeletionPolicy": "Retain" and "UpdateReplacePolicy": "Retain", and the logical IDs will look like UploadsBucket5E5E9B64 and OrdersTable315BB997. Don’t rename construct IDs after this point: a new ID means a new logical ID, and CloudFormation treats that as a different resource.
Import the resources with cdk import
Run cdk diff first. For a new stack, every resource shows as an addition, which is what an import expects. The only change allowed in an import is adding the resources being imported. Then import, interactively the first time:
cdk diff OrdersStack
# Interactive: prompts for the bucket name and table name
cdk import OrdersStack
# Or for CI: record the answers once, then replay them without prompts
cdk import OrdersStack --record-resource-mapping mapping.json
cdk import OrdersStack --resource-mapping mapping.json
With --record-resource-mapping the CLI writes the mapping file and performs no import, so you can review it in a pull request before the real run. The file maps each logical ID to its identifier, such as BucketName for the bucket and TableName for the table. The AWS CDK reference for the cdk import command lists every option and the current limitations.
Three limitations shape bigger migrations. Resources can’t be imported into nested stacks. Resources that reference each other must be imported together or in dependency order. And the import doesn’t check that your properties match reality, which is why the next step exists.
Verify the import with drift detection
CloudFormation’s import validates that the resource exists and that your template is valid, not that it matches the live configuration. Drift detection does:
aws cloudformation detect-stack-drift --stack-name OrdersStack
# wait a few seconds, then list anything that differs
aws cloudformation describe-stack-resource-drifts \
--stack-name OrdersStack \
--stack-resource-drift-status-filters MODIFIED DELETED
An empty list means your constructs describe the live resources. If a property shows as modified, fix the construct, not the resource: the next cdk deploy would otherwise “correct” production to match your typo. A quick way to double-check settings like versioning is to find S3 buckets without versioning enabled and confirm the imported bucket isn’t on the list.
Release the resources from Terraform without destroying them
Now remove the resources from Terraform state. Replace every resource block with a removed block, including the helper resources like versioning and the public access block, and delete any outputs or references that pointed at them:
removed {
from = aws_s3_bucket.uploads
lifecycle {
destroy = false
}
}
removed {
from = aws_s3_bucket_versioning.uploads
lifecycle {
destroy = false
}
}
removed {
from = aws_s3_bucket_public_access_block.uploads
lifecycle {
destroy = false
}
}
removed {
from = aws_s3_bucket_server_side_encryption_configuration.uploads
lifecycle {
destroy = false
}
}
removed {
from = aws_dynamodb_table.orders
lifecycle {
destroy = false
}
}
terraform plan # expect 0 to add, 0 to change, 0 to destroy; 5 resources leave state
terraform apply
terraform state list # the five addresses are gone
Stop if the plan shows a destroy. A destroy means a resource block was deleted without a matching removed block, or destroy isn’t false. HashiCorp’s page on removing a resource from Terraform state recommends the removed block over terraform state rm because you can preview it; use terraform state rm only on versions older than 1.7.
Finally, run cdk deploy OrdersStack once. The import left out CDK’s own metadata resource, so this deploy adds it and touches nothing else. After that, cdk diff OrdersStack should report no differences. That clean diff is your sign-off. From then on, the script to detect CloudFormation drift across all stacks catches console changes before they surprise the next deploy.
Permissions needed for the migration
- CDK: your identity must be able to assume the bootstrap roles.
cdk importuses the deploy role andcdk diffreads the deployed template. If you bootstrapped with a custom--cloudformation-execution-policies, that policy must allow the resource types you import. - Drift detection:
cloudformation:DetectStackDrift,cloudformation:DescribeStackResourceDriftsand read access to the resources being checked. - Terraform: read and write access to the state backend, and read access to the resources for the refresh during
plan.
If any step fails with an authorization error, our walkthrough to troubleshoot AWS IAM access denied errors step by step shows how to read the message and find the missing action. When you tighten these roles afterward, the checklist to review a generated IAM policy for least privilege applies to CI roles too.
Troubleshooting and common mistakes
- The first deploy after the import changes a
DeletionPolicy.cdk importaddsRetainto imported resources whose construct doesn’t set one, but your code wins on the next deploy. Stateless L2 constructs such as IAM roles don’t retain by default, so callrole.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN)on them. cdk importrefuses to run because of updates or deletions. The stack has pending changes besides the new resources. Deploy those separately first; don’t reach for--forceduring a migration.- The import fails because the resource belongs to another stack. A resource can belong to only one CloudFormation stack in a Region. Someone may have imported it earlier, or another CDK app defines it.
- The resource type can’t be imported. Not every CloudFormation type supports import. For those, decide whether recreating is acceptable (often fine for IAM policies or event rules) or keep that piece in Terraform for now.
- Terraform plan still references a removed resource. An output, data source or another resource uses its attributes. Replace the reference with a literal or an SSM parameter.
- A later
cdk deploywants to replace something. A construct ID or an immutable property changed. Revert it; read everycdk difffor[-]and replacement markers before deploying.
When the stack behaves differently after the move and you need to compare live state with what you expected, the approach in troubleshooting AWS infrastructure with an AI CLI helps you ask the account directly.
What this procedure and our tools can’t do for you
No tool does the migration end to end. cdk migrate reads CloudFormation stacks, templates and scanned resources, not Terraform state, so HCL always goes through a translation step. The free AI code converters for Terraform, CDK and SDK code translate code only: they don’t read your account, your state file or your live settings, and their output flags with comments the spots where there’s no direct equivalent. Before pasting a large module, check the ChatWithCloud converter limits and file size caps and read whether it’s safe to paste infrastructure code into an AI converter.
For the inventory step, the ChatWithCloud CLI can answer questions like “which DynamoDB tables in us-east-1 have point-in-time recovery on?” with your own AWS profile, as shown in the guide to list AWS resources with natural language from your terminal. Generated code runs without a confirmation step, so connect ChatWithCloud with a read-only AWS profile while you explore.
Frequently asked questions
Can I migrate a Terraform stack to CDK without downtime?
Yes. cdk import and the Terraform removed block only change which tool tracks a resource. The bucket, table and their data are never touched, as long as your constructs match the live settings.
Does cdk migrate work with Terraform?
No. It works from deployed CloudFormation stacks, local templates or resources it scans in your account. For Terraform, write or convert the constructs yourself and use cdk import.
Should I remove resources from Terraform first or import into CDK first?
Import first. If the import fails, Terraform still owns everything. Release from Terraform only after drift detection is clean, and keep the change freeze in place until both steps are done.
How do I migrate a large Terraform stack?
In slices. Import a few related resources at a time, in dependency order, into one or more CDK stacks, and release the same addresses from Terraform after each slice. Keep each CloudFormation stack under its resource quota.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud
