Migrate a Terraform Stack to AWS CDK TypeScript, Step by Step

Dark code editor on a monitor showing indented source code with syntax highlighting

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 removed block that releases resources without destroying them arrived in 1.7. Check with terraform version.
  • The AWS CDK CLI and a TypeScript CDK app. Create one with cdk init app --language typescript if you don’t have one yet.
  • A current CDK bootstrap stack. cdk import uses the bootstrap deploy role, which needs bootstrap template version 12 or later.
  • A change freeze on the Terraform stack. Nobody runs terraform apply on 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 list and terraform state show <address> give you the exact settings you’ll reproduce in CDK.
Terminal

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

  1. Inventory the Terraform resourcesMap every resource address to the physical ID CloudFormation will need (bucket name, table name, role name).
  2. Write matching CDK constructsOne construct per CloudFormation resource, with every property set to its current value and a fixed physical name.
  3. Retain everythingSet RemovalPolicy.RETAIN on each construct so the resources get DeletionPolicy: Retain. CloudFormation import requires a DeletionPolicy on every imported resource, and setting it in code keeps it there after later deploys.
  4. Synthesize and read the templateRun cdk synth and compare each resource’s properties with terraform state show.
  5. Import into CloudFormationRun cdk import. It creates the stack (or adds to it) with an IMPORT change set; nothing is created or modified.
  6. Check for driftRun CloudFormation drift detection, because the import doesn’t verify your properties.
  7. Release from TerraformReplace the resource blocks with removed blocks (destroy = false), then plan and apply.
  8. Confirm a clean diffRun cdk deploy once to add CDK’s metadata, then cdk diff should 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:

main.tf (before)

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.

lib/orders-stack.ts

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:

Terminal

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:

Terminal

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:

main.tf (after)

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
  }
}
Terminal

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 import uses the deploy role and cdk diff reads 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:DescribeStackResourceDrifts and 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 import adds Retain to 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 call role.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN) on them.
  • cdk import refuses to run because of updates or deletions. The stack has pending changes besides the new resources. Deploy those separately first; don’t reach for --force during 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 deploy wants to replace something. A construct ID or an immutable property changed. Revert it; read every cdk diff for [-] 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