Terraform & IaC Automation10 min read

How to Convert CloudFormation to Terraform (Step-by-Step Guide)

Learn how to convert CloudFormation to Terraform using cf2tf, manual migration, or CloudOps AI. Compare methods, avoid migration mistakes, and speed up IaC modernization.

AAbhay Singh· Cloud Architect
#cloudformation to terraform#convert cloudformation to terraform#cf2tf#terraform migration#infrastructure as code migration#aws cloudformation#terraform import#cloudops ai#cloudformation vs terraform#IaC automation

Why Teams Convert CloudFormation to Terraform

It usually starts with a single meeting. Your AWS-first engineering team just acquired a GCP workload. Or a new CTO arrives with a multi-cloud mandate. Or the platform team is tired of maintaining two completely different IaC toolchains — CloudFormation for AWS, something else for everything else — and wants to consolidate.

Whatever the trigger, the decision to migrate from CloudFormation to Terraform is increasingly common in 2025. But it's rarely as simple as running a conversion script and calling it done.

This guide covers every approach to converting CloudFormation to Terraform — the automated tools, the manual patterns, the gotchas that will surprise you, and where automation genuinely helps versus where it falls short.


What You're Actually Converting

Before picking a method, understand what CloudFormation-to-Terraform conversion actually involves. It's not just a syntax translation. You're mapping between two fundamentally different mental models:

Concept CloudFormation Terraform Unit of deployment Stack Workspace / root module State Managed by AWS Managed by you (S3 + DynamoDB) Dependency resolution DependsOn + Ref / !GetAtt Implicit via resource references Parameters Parameters block variables.tf Outputs Outputs block outputs.tf Conditionals Conditions + !If count, for_each, dynamic Rollback Automatic Manual re-apply required Nested stacks AWS::CloudFormation::Stack Terraform modules Custom resources AWS::CloudFormation::CustomResource null_resource, local-exec

A CloudFormation template is a deployment manifest — declare it, and AWS executes it. A Terraform configuration is a desired state description — Terraform reconciles current state to reach it. Same outcome, very different mental model.


The Three Conversion Approaches

There are three practical ways to convert CloudFormation to Terraform, each with a different effort/quality trade-off:

  1. cf2tf CLI tool — Automated conversion of CFN YAML/JSON to HCL

  2. Manual conversion — Hand-translating templates resource by resource

  3. CloudOps AI — Visual, AI-assisted conversion with clean output

We'll cover all three in depth.


Method 1: Using cf2tf (Automated CLI Conversion)

What It Is

cf2tf is an open-source Python CLI tool that parses CloudFormation templates and generates approximate Terraform HCL. It handles common resource types and CloudFormation intrinsic functions.

Installation

pip install cf2tf

Basic Usage

# Convert a single template
cf2tf my-template.yaml

# Convert and write output to a directory
cf2tf my-template.yaml -o ./terraform-output/

# Convert JSON format
cf2tf my-stack.json -o ./terraform-output/

Example: What cf2tf Converts

Input CloudFormation (YAML):

Parameters:
  Environment:
    Type: String
    Default: prod
    AllowedValues: [dev, staging, prod]

  InstanceType:
    Type: String
    Default: t3.medium

Resources:
  WebServerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Web server security group
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0

  WebServer:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref InstanceType
      ImageId: ami-0c55b159cbfafe1f0
      SecurityGroupIds:
        - !Ref WebServerSecurityGroup
      Tags:
        - Key: Environment
          Value: !Ref Environment

Outputs:
  WebServerPublicIP:
    Value: !GetAtt WebServer.PublicIp
    Description: Public IP of the web server

Generated Terraform (cf2tf output):

variable "environment" {
  type    = string
  default = "prod"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Must be dev, staging, or prod."
  }
}

variable "instance_type" {
  type    = string
  default = "t3.medium"
}

resource "aws_security_group" "web_server_security_group" {
  description = "Web server security group"
  vpc_id      = var.vpc_id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "web_server" {
  instance_type          = var.instance_type
  ami                    = "ami-0c55b159cbfafe1f0"
  vpc_security_group_ids = [aws_security_group.web_server_security_group.id]

  tags = {
    Environment = var.environment
  }
}

output "web_server_public_ip" {
  value       = aws_instance.web_server.public_ip
  description = "Public IP of the web server"
}

Not bad for an automated tool. Parameters become variables, outputs map cleanly, and !Ref / !GetAtt are resolved to Terraform resource references.

What cf2tf Handles Well

  • Basic resource type mapping (EC2, S3, RDS, VPC, IAM)

  • Parametersvariables.tf

  • Outputsoutputs.tf

  • Common intrinsic functions: !Ref, !GetAtt, !Sub, !Join

  • DependsOndepends_on

  • AllowedValuesvalidation blocks

What cf2tf Struggles With

1. CloudFormation-specific intrinsic functions with no clean Terraform equivalent:

# CloudFormation — selects value based on condition
!If [IsProd, t3.large, t3.micro]

cf2tf may generate incorrect or incomplete HCL for complex !If, !Select, !FindInMap, and !Transform expressions. These need manual review and often a full rewrite using Terraform's locals and for_each.

2. CloudFormation Conditions:

Conditions:
  IsProd: !Equals [!Ref Environment, prod]
  HasMultiAZ: !And
    - !Condition IsProd
    - !Equals [!Ref EnableHA, true]

Terraform has no direct Conditions equivalent. These translate to locals and then count = local.is_prod ? 1 : 0 patterns, which cf2tf handles inconsistently.

3. AWS::CloudFormation::Init and cfn-hup:

EC2 user data that bootstraps instances using cfn-init has no Terraform equivalent at all. You'll need to rewrite these as user_data blocks, potentially referencing S3-hosted scripts.

4. Nested stacks:

Resources:
  NetworkStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.amazonaws.com/mybucket/network.yaml

cf2tf cannot follow nested stack references. Each nested template needs to be converted and refactored into a Terraform module separately.

5. CloudFormation Macros and Transforms:

Transform: AWS::Serverless-2016-10-31

SAM transforms, CloudFormation macros, and AWS::LanguageExtensions have no Terraform equivalent. Serverless Application Model (SAM) resources need to be manually expanded into their underlying CloudFormation/Terraform equivalents.

6. Import state — cf2tf doesn't handle it:

cf2tf generates .tf files but does not import the current state of existing AWS resources. After conversion, you must run terraform import for every resource that already exists in AWS, or use terraform plan and accept that Terraform will want to recreate everything.

cf2tf Verdict

Good for: Simple stacks with standard resource types, generating a rough scaffold that you'll refine. Not good for: Complex stacks with Conditions, Macros, nested stacks, cfn-init, or SAM resources. Expect 30–60% of the output to need manual cleanup for any real-world template.


Method 2: Manual Conversion (Pattern by Pattern)

For complex stacks, or for teams that want clean, idiomatic output from day one, manual conversion is the more reliable path. Here are the most important translation patterns.

Pattern 1: Parameters → Variables

# CloudFormation
Parameters:
  DBInstanceClass:
    Type: String
    Default: db.t3.medium
    Description: RDS instance class
    AllowedValues:
      - db.t3.medium
      - db.r5.large
# Terraform
variable "db_instance_class" {
  type        = string
  default     = "db.t3.medium"
  description = "RDS instance class"

  validation {
    condition     = contains(["db.t3.medium", "db.r5.large"], var.db_instance_class)
    error_message = "Must be db.t3.medium or db.r5.large."
  }
}

Pattern 2: Conditions → locals + count

# CloudFormation
Conditions:
  IsProduction: !Equals [!Ref Environment, production]

Resources:
  ReadReplica:
    Type: AWS::RDS::DBInstance
    Condition: IsProduction
    Properties:
      DBInstanceClass: db.t3.medium
# Terraform
locals {
  is_production = var.environment == "production"
}

resource "aws_db_instance" "read_replica" {
  count = local.is_production ? 1 : 0

  instance_class = "db.t3.medium"
  # ...
}

Pattern 3: !FindInMap → locals map lookup

# CloudFormation
Mappings:
  InstanceTypeMap:
    prod:
      InstanceType: t3.large
    dev:
      InstanceType: t3.micro

Resources:
  Server:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !FindInMap [InstanceTypeMap, !Ref Environment, InstanceType]
# Terraform
locals {
  instance_type_map = {
    prod = "t3.large"
    dev  = "t3.micro"
  }
}

resource "aws_instance" "server" {
  instance_type = local.instance_type_map[var.environment]
  # ...
}

Pattern 4: !Sub with multiple references → templatestring / string interpolation

# CloudFormation
BucketName: !Sub "${AWS::AccountId}-${Environment}-app-data"
# Terraform
locals {
  bucket_name = "${data.aws_caller_identity.current.account_id}-${var.environment}-app-data"
}

data "aws_caller_identity" "current" {}

Pattern 5: Nested Stacks → Modules

# CloudFormation nested stack
Resources:
  VPCStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.amazonaws.com/bucket/vpc-template.yaml
      Parameters:
        CIDRBlock: 10.0.0.0/16
# Terraform module (convert vpc-template.yaml separately into modules/vpc/)
module "vpc" {
  source     = "./modules/vpc"
  cidr_block = "10.0.0.0/16"
}

Each nested template becomes a separate module. Convert each independently, then wire them together with module calls in your root main.tf.

Pattern 6: Custom Resources → null_resource or external data sources

# CloudFormation Custom Resource
Resources:
  DNSRecord:
    Type: Custom::Route53Record
    Properties:
      ServiceToken: !GetAtt LambdaFunction.Arn
      HostedZoneId: !Ref HostedZoneId
      RecordName: api.example.com
# Terraform — use the native resource if it exists
resource "aws_route53_record" "api" {
  zone_id = var.hosted_zone_id
  name    = "api.example.com"
  type    = "A"
  # ...
}

# Or for truly custom logic, use null_resource + local-exec
resource "null_resource" "custom_action" {
  triggers = { always_run = timestamp() }

  provisioner "local-exec" {
    command = "python3 scripts/custom_action.py ${var.hosted_zone_id}"
  }
}

The key insight: most CloudFormation Custom Resources exist to work around CloudFormation's limitations. In Terraform, check whether a native provider resource exists before reaching for null_resource. Often it does.


The Step You Can't Skip: Importing Existing State

Whether you use cf2tf or manual conversion, converting a CloudFormation stack that manages live infrastructure requires importing that infrastructure into Terraform's state before you can manage it.

Without this step, terraform plan will show every resource as "to be created" — and terraform apply will try to create duplicates or fail on name conflicts.

Step 1: Generate the .tf files (via cf2tf or manual)

Step 2: Initialize Terraform

terraform init

Step 3: Import each resource

# Import S3 bucket
terraform import aws_s3_bucket.app_data my-bucket-name

# Import EC2 instance
terraform import aws_instance.web_server i-1234567890abcdef0

# Import RDS instance
terraform import aws_db_instance.primary mydb-identifier

# Import VPC
terraform import aws_vpc.main vpc-12345678

# Import security group
terraform import aws_security_group.web sg-12345678

For large stacks with 50+ resources, this is where most of the migration time goes. Each resource requires finding the correct import ID format (which varies by resource type — some use ARNs, some use IDs, some use names), running the import command, then verifying the state matches your .tf definition.

Step 4: Run terraform plan and reconcile drift

After importing, run:

terraform plan

The goal is a clean No changes. Infrastructure is up-to-date. output. Any ~ (update) or -/+ (destroy/recreate) lines indicate your .tf definition doesn't match the imported state. Fix the .tf to match reality before proceeding.

This reconciliation step is typically the most time-consuming part of any CloudFormation-to-Terraform migration. A single complex stack can take days to fully reconcile.


Step 5: Set Up Remote State Before Cutting Over

Before you decommission the CloudFormation stack, ensure Terraform's state is properly set up for your team:

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "production/main/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }
}
# Migrate local state to remote backend
terraform init -migrate-state

Do not decommission the CloudFormation stack until:

  • Terraform state is remote and locked

  • At least one successful terraform plan with no changes

  • At least one successful terraform apply (even a no-op or minor change)

  • The team has verified they can roll back to CloudFormation if needed


  • Step 6: Decommission the CloudFormation Stack (Carefully)

    Once you're confident Terraform owns the state:

    1. Remove the resources from CloudFormation stack management using aws cloudformation update-stack with DeletionPolicy: Retain on all resources — this prevents CloudFormation from deleting resources when you delete the stack.

    2. Delete the CloudFormation stack (resources are retained, not deleted).

    3. Verify — all resources still exist in AWS, and terraform plan still shows no changes.

    Never delete the CloudFormation stack without DeletionPolicy: Retain on every resource. This is how migrations go catastrophically wrong.


    Method 3: CloudOps AI (Automated + Production-Ready)

    The gap between cf2tf's rough output and production-ready Terraform is where most migration projects stall. Manual cleanup of a 500-line cfn-to-tf conversion is a multi-day project for a senior engineer.

    CloudOps AI closes that gap with a different approach: instead of translating CloudFormation syntax to Terraform syntax line-by-line, it reads your live AWS infrastructure (or your CloudFormation template) and generates clean, idiomatic Terraform from the resource state — skipping the translation layer entirely.

    How it works:

    1. Connect your AWS account via read-only IAM role.

    2. CloudOps AI scans your CloudFormation stacks — reading both the template and the live resource state.

    3. Select the stack or resources to migrate using the visual interface.

    4. CloudOps AI generates:

    • Clean main.tf, variables.tf, outputs.tf per module

    • Pre-written backend.tf with S3 + DynamoDB configuration

    • import commands for every resource

    • A migration checklist specific to your stack

    5. Review, adjust, and export — or push directly to a Git branch.

    The key difference: CloudOps AI generates Terraform that matches your actual resource configuration, not the CloudFormation template (which may have drifted from reality). The output is already variable-parameterized, module-organized, and tagged — not a rough scaffold that needs days of cleanup.

    Where CloudOps AI saves the most time:

    Migration task Manual cf2tf CloudOps AI Basic resource translation 1–2 hours Minutes Condition → count/for_each conversion 2–4 hours Automatic State import commands generation 1 day+ Automatic Plan reconciliation 1–3 days Significantly reduced Module organization Manual refactor Automatic Variable extraction Manual Automatic backend.tf setup Manual Generated Total (medium stack, ~50 resources) 3–7 days 2–4 hours


    Common Migration Mistakes to Avoid

    Deleting the CloudFormation stack before Terraform state is reconciled. Always add DeletionPolicy: Retain to every resource before deleting any CloudFormation stack. Resources can be re-imported into Terraform if something goes wrong, but they can't be un-deleted.

    Migrating everything at once. For large, complex stacks, migrate one logical component at a time — networking first, then compute, then databases. Smaller blast radius per migration step.

    Ignoring CloudFormation drift before migrating. Run CloudFormation drift detection before starting. If your live resources have drifted from the CloudFormation template, your cf2tf output will be wrong from the start.

    aws cloudformation detect-stack-drift --stack-name my-stack
    aws cloudformation describe-stack-drift-detection-status \
      --stack-drift-detection-id <detection-id>
    

    Skipping the terraform plan reconciliation step. This step cannot be rushed. Every ~ or -/+ in the plan represents a potential change to live infrastructure. Investigate every one before proceeding.

    Not pinning provider versions in the migrated code. The generated Terraform will inherit whatever provider version you initialized with. Pin it explicitly before committing:

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

    Realistic Timeline by Stack Complexity

    Stack size Resources Manual migration With cf2tf + cleanup With CloudOps AI Small < 20 resources 1–2 days 4–8 hours 1–2 hours Medium 20–75 resources 3–7 days 2–4 days 4–8 hours Large 75–200 resources 2–4 weeks 1–2 weeks 1–3 days Complex (nested stacks, Macros) 200+ resources 4–8 weeks 3–6 weeks 1–2 weeks

    These are estimates for a single experienced engineer. Add buffer for team coordination, testing, and production validation.

    Ready to optimise your cloud operations?

    CloudOps AI gives your team AI-powered architecture, FinOps, and DevSecOps in one platform.

    Start for free →

    Frequently Asked Questions

    Can I convert CloudFormation to Terraform automatically?

    Partially. Tools like cf2tf automate the syntax translation for common resource types, but the output requires significant manual cleanup — especially for Conditions, nested stacks, CloudFormation Macros, and state import. No tool produces ready-to-use Terraform without human review.

    Does cf2tf import existing AWS resources into Terraform state?

    No. cf2tf only generates .tf configuration files. You must separately run terraform import for each existing resource. CloudOps AI generates both the configuration and the import commands.

    What happens to my CloudFormation stack when I switch to Terraform?

    Nothing happens automatically — CloudFormation and Terraform are independent. You must explicitly delete the CloudFormation stack (with DeletionPolicy: Retain set) after Terraform is fully managing the resources. Until then, both tools can coexist without conflict.

    How do I handle CloudFormation Secrets Manager references in Terraform?

    Replace {{resolve:secretsmanager:...}} dynamic references with Terraform data "aws_secretsmanager_secret_version" data sources: data "aws_secretsmanager_secret_version" "db_password" { secret_id = "prod/myapp/db-password" } resource "aws_db_instance" "main" { password = data.aws_secretsmanager_secret_version.db_password.secret_string }

    Can I migrate AWS SAM (Serverless Application Model) stacks to Terraform?

    Yes, but it requires more work. SAM resources (AWS::Serverless::Function, AWS::Serverless::Api) are syntactic sugar that CloudFormation expands into multiple underlying resources. You need to identify the underlying resources (Lambda functions, API Gateway APIs, IAM roles) and write Terraform for each. The aws_lambda_function, aws_api_gateway_rest_api, and related resources cover the same ground.

    Is it worth migrating from CloudFormation to Terraform?

    For AWS-only teams with stable, simple stacks — possibly not. The migration cost is real, and CloudFormation works well for pure AWS workloads. The strongest cases for migration are: expanding to multiple cloud providers, needing richer module ecosystems, wanting better developer experience, or standardizing on Terraform across a heterogeneous platform.

    A

    Written by

    Abhay Singh

    Cloud Architect

    Cloud Architect and DevOps specialist with 10+ years of experience in AWS and Azure.

    More articles by Abhay Singh