Import CloudFormation Resources Into Terraform Safely

Dark code editor on a monitor showing lines of infrastructure configuration

Photo by Pankaj Patel on Unsplash

To import CloudFormation resources into Terraform without recreating them, first set DeletionPolicy: Retain and UpdateReplacePolicy: Retain on each resource and update the stack. Then remove the resources from the stack (or delete the stack), write Terraform import blocks with each physical ID, generate or write the matching HCL, and apply only when terraform plan shows imports and no other changes.

This walkthrough is for platform engineers who want to import CloudFormation resources into Terraform while the resources stay in service: an S3 bucket full of data, a DynamoDB table taking writes. The order of operations is what keeps you safe. If CloudFormation still owns a resource when Terraform starts managing it, two tools fight over one bucket. If you release it without a retain policy, CloudFormation deletes it.

By the end you’ll have both resources in Terraform state, a clean configuration, and a terraform plan that reports nothing to do. If you’re still deciding which tool should own the stack in the first place, read our comparison of AWS CDK vs Terraform for existing AWS stacks before you start.

What you need before a CloudFormation to Terraform migration

  • Terraform 1.5 or later. Import blocks and -generate-config-out arrived in 1.5. Older versions only have the one-at-a-time terraform import command.
  • AWS CLI v2 with a profile that can read and update the stack, plus read access to the resources (see the permissions section).
  • The current stack template, exported from the deployed stack rather than a copy in Git that may have drifted.
  • A Terraform backend already configured (S3 or Terraform Cloud), so the imported state isn’t stranded on a laptop.
  • A change window with no one else deploying the stack. Nothing goes offline, but you don’t want a parallel stack update in the middle.

The example stack is called orders-data. It holds an S3 bucket and a DynamoDB table:

orders-data.yaml (after step 2)

AWSTemplateFormatVersion: '2010-09-09'
Description: Orders data layer
Resources:
  OrdersArchiveBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub 'acme-orders-archive-${AWS::AccountId}'
      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
Outputs:
  OrdersTableName:
    Value: !Ref OrdersTable

How to import CloudFormation resources into Terraform, step by step

  1. Inventory the stackList every resource with its logical ID, type and physical ID. The physical ID becomes the Terraform import ID.
  2. Retain everything you are movingAdd DeletionPolicy: Retain and UpdateReplacePolicy: Retain to each resource, change nothing else, and update the stack.
  3. Release the resources from CloudFormationRemove them from the template and update the stack, or delete the stack if you’re moving every resource. Retained resources stay in your account.
  4. Write import blocksOne block per Terraform resource, with the to address and the physical id.
  5. Generate and clean the configurationRun terraform plan -generate-config-out=generated.tf, then trim the output into code you’d want to maintain.
  6. Plan, apply, plan againApply only when the plan lists imports and zero adds, changes or destroys. A second plan must say there’s nothing to do.

Step 1: inventory the stack and save the deployed template

Terminal

aws cloudformation describe-stack-resources --stack-name orders-data \
  --query "StackResources[].[LogicalResourceId,ResourceType,PhysicalResourceId]" \
  --output table

aws cloudformation get-template --stack-name orders-data \
  --query TemplateBody --output text > orders-data.yaml

For a template stored as JSON, drop --output text and save the JSON object instead. Keep this file; you’ll edit it in steps 2 and 3. If you would rather ask than type the query, you can list AWS resources with natural language from your terminal and get the same inventory in plain English.

Step 2: retain CloudFormation resources before Terraform import

This is the step that protects your data. DeletionPolicy: Retain applies when a stack is deleted and when a resource is removed from the template in an update: CloudFormation drops it from the stack but leaves the physical resource alone. UpdateReplacePolicy: Retain covers the other path, where a property change forces a replacement and the old resource would otherwise be deleted.

Terminal

aws cloudformation deploy --stack-name orders-data \
  --template-file orders-data.yaml

# Confirm the deployed template now carries both policies
aws cloudformation get-template --stack-name orders-data \
  --query TemplateBody --output text | grep -E "DeletionPolicy|UpdateReplacePolicy"

Warning: Deploy the retain policies on their own. If the same update also changes a property that requires replacement, CloudFormation replaces the resource before the policy protects anything. Stacks with IAM resources also need --capabilities CAPABILITY_IAM or CAPABILITY_NAMED_IAM.

Step 3: remove the resources from the stack

If you are moving only some resources, delete them from the template, remove every !Ref, !GetAtt and output that points at them, and preview the update as a change set:

Terminal

aws cloudformation create-change-set --stack-name orders-data \
  --change-set-name release-to-terraform \
  --template-body file://orders-data-trimmed.yaml

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

aws cloudformation execute-change-set --stack-name orders-data \
  --change-set-name release-to-terraform

Every resource you’re moving should show Remove with a PolicyAction of Retain. If any row says Delete, stop and fix the template. A template needs at least one resource, so when you’re moving all of them, delete the stack instead:

Terminal

aws cloudformation delete-stack --stack-name orders-data
aws cloudformation wait stack-delete-complete --stack-name orders-data
aws s3api head-bucket --bucket acme-orders-archive-123456789012
aws dynamodb describe-table --table-name orders --query "Table.TableStatus"

Step 4: write Terraform import blocks

An import block tells Terraform to adopt an existing object during the next apply instead of creating one. The id format depends on the resource type: a bucket name for S3, a table name for DynamoDB. Notice that one CloudFormation bucket becomes three Terraform resources, because the AWS provider splits versioning and the public access block into their own resource types. Each needs its own import, or the plan will try to create them.

imports.tf

terraform {
  required_version = ">= 1.5.0"

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

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

import {
  to = aws_s3_bucket.orders_archive
  id = "acme-orders-archive-123456789012"
}

import {
  to = aws_s3_bucket_versioning.orders_archive
  id = "acme-orders-archive-123456789012"
}

import {
  to = aws_s3_bucket_public_access_block.orders_archive
  id = "acme-orders-archive-123456789012"
}

import {
  to = aws_dynamodb_table.orders
  id = "orders"
}

Step 5: generate the configuration and clean it up

Terminal

terraform init
terraform plan -generate-config-out=generated.tf

Terraform writes a resource block for every import block that has no matching resource yet. The file must not already exist. HashiCorp’s guide to generating configuration for imported resources describes the feature as experimental and warns that the output can contain conflicting arguments you have to remove by hand. Treat generated.tf as a draft: it lists every attribute, including defaults you don’t care about.

A second route to readable HCL is to paste the saved template into the free CloudFormation YAML to Terraform converter (or the CloudFormation JSON to Terraform converter for JSON stacks). It keeps your original structure and marks properties with no direct equivalent in comments. Before pasting anything, strip account-specific secrets; our explainer on whether it’s safe to paste AWS code into an AI converter covers what is sent and what isn’t. Either way, the cleaned result looks like this:

main.tf

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

  lifecycle {
    prevent_destroy = true
  }
}

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
  }

  lifecycle {
    prevent_destroy = true
  }
}

Delete generated.tf once main.tf covers every imported address, otherwise Terraform sees duplicate resources.

What should terraform plan show after a CloudFormation migration?

Run terraform plan and read the summary line. The only acceptable result is imports and nothing else:

Terminal

$ terraform plan
...
Plan: 4 to import, 0 to add, 0 to change, 0 to destroy.

$ terraform apply
...
Apply complete! Resources: 4 imported, 0 added, 0 changed, 0 destroyed.

$ terraform plan
No changes. Your infrastructure matches the configuration.

If the plan shows to add, an import block is missing or its to address doesn’t match a resource. If it shows to change, your HCL disagrees with the live resource: edit the HCL until the change disappears, don’t apply it. A to destroy or a replacement on an import plan means stop. HashiCorp says you can delete the import blocks after the apply or keep them as a record of where each resource came from; the Terraform import overview covers both options.

How to avoid recreating AWS resources during Terraform import

  • Never skip the retain update. Removing a resource without DeletionPolicy: Retain deletes it, data included.
  • Match names exactly. A different bucket or name argument forces replacement. Copy physical IDs from step 1.
  • Add prevent_destroy to stateful resources so a later refactor can’t plan a destroy without an error.
  • Import child resources too. Bucket policies, encryption settings and lifecycle rules each map to their own Terraform resource type.
  • Watch for leftover tags. Resources created by CloudFormation may keep system tags with the aws: prefix. You can’t edit those, so don’t copy them into HCL.

Permissions needed for the migration

Phase IAM actions
Inventory and export cloudformation:DescribeStackResources, cloudformation:GetTemplate
Retain and release cloudformation:CreateChangeSet, DescribeChangeSet, ExecuteChangeSet, UpdateStack or DeleteStack, plus whatever the stack’s own resources need to update
Terraform import and plan Read access to each resource, such as s3:GetBucketVersioning, s3:GetBucketPublicAccessBlock, s3:ListBucket, dynamodb:DescribeTable, dynamodb:DescribeContinuousBackups, dynamodb:ListTagsOfResource

The AWS provider reads many more attributes than you’d expect during refresh, so a narrow policy often fails with a 403 on some Get* call. If that happens, follow the method in troubleshooting AWS IAM access denied errors step by step rather than granting s3:*. When you later tighten the Terraform role, our checklist to review a generated IAM policy for least privilege applies.

Common mistakes and how to fix them

Symptom Cause and fix
Stack update fails: export in use Another stack uses an output through Fn::ImportValue. Replace that reference with a literal or a data source first.
Cannot import non-existent remote object Wrong import ID, region or account. Compare with the physical ID from step 1.
generated.tf already exists Terraform refuses to overwrite it. Delete or rename the file and plan again.
Plan shows an in-place change after import A default in HCL differs from the live value. Match the live value, then plan again.
Stack delete leaves resources you didn’t expect Those had Retain. Import them too or delete them deliberately.

For wider debugging after the move, see how to troubleshoot AWS infrastructure with an AI CLI.

Where ChatWithCloud fits, and its limits

The free AI code converters for AWS infrastructure produce a first draft of HCL from a template; they don’t read your account, write import blocks or run Terraform. Output must be reviewed and tested, and the free converter limits and file size caps (60,000 characters per conversion) mean very large templates need splitting. If you ever need to go back, the Terraform to CloudFormation converter drafts the reverse direction.

The ChatWithCloud CLI can answer inventory questions such as “which resources are in the orders-data stack?” by running read calls with your profile, as explained on how ChatWithCloud runs AWS SDK calls locally. It runs changes without a confirmation step, so use a read-only profile for this work; the ChatWithCloud security model explains what leaves your machine.

Frequently asked questions

Can I import CloudFormation resources into Terraform without downtime?

Yes. The resources never stop running. Retaining and releasing them only changes which tool tracks them. The risk is a replacement or delete, which the retain policies and a clean plan prevent.

Do I need to delete the CloudFormation stack?

Only if you’re moving every resource. Otherwise remove the moved resources from the template and update the stack. Never leave a resource managed by both tools.

What are the CloudFormation to Terraform migration steps for nested stacks?

Work from the innermost stack outward: retain and release resources in each nested stack, then update the parent. Import the resources in Terraform the same way.

Can I use the terraform import command instead of import blocks?

Yes, but it imports one resource per run and can’t be planned first. Import blocks show every import in a plan before anything touches state, which is why they suit a migration.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud