pabis.eu

From CloudFormation to Terraform with Terraformer

26 June 2025

Someone decided to use CloudFormation as the IaC tool of choice, once in the past. Why? Nobody knows why 🤷. It's cool that it managed the state for me and I don't need to worry about someone stealing it or managing permissions or doing backups. But we want to be true professionals now and use Terraform (because everyone uses it, so it must be good, right?). Let's say you are stuck with CloudFormation and you have so many things that are dependent on each other that it becomes increasingly frustrating to maintain. There's light at the end of the tunnel - you can indeed migrate to Terraform but this journey will be long and painful. Just as going to the gym, you first tear your muscles to make them grow anew. Today, we will go through the process of moving from CloudFormation to Terraform and to make it easier (totally 🤣) we will use a tool called Terraformer - a Terraform code generator that queries AWS API and writes .tf files. In the examples, I will use OpenTofu so assume that the two are interchangeable.

An important thing to note is that this tool is pretty much abandoned. There was a recent release on March 27th but the previous version was released in 2023! The states generated by the tool are pretty much unusable in modern day (as you need to upgrade from 0.12 version of Terraform - a feature removed from OpenTofu). We will just use the tool to get some initial code that we will patch with plan's and validate's suggestions.

The repository with the exercise you can clone from GitHub.

Example architecture

First we will create an example architecture that is problematic to migrate from CloudFormation to Terraform. Namely, we assume that there's an underlying stack that has some exported outputs and these are imported by another stack. As the target we want to migrate the first stack to Terraform and this makes it trickier. In my example, I will assume that the first stack is VPC with a subnet, internet gateway, etc. and the seconds stack is an EC2 instance that is in the subnet and security group tied to the VPC.

Diagram of our architecture

Let's define our stacks as CloudFormation templates. I will apply first the VPC one deploy it to my account (using AWS CLI but you can use Console).

AWSTemplateFormatVersion: '2010-09-09'
Description: 'VPC with public subnet, internet gateway, and routes'

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.100.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true

  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.100.1.0/24
      AvailabilityZone: !Select [0, !GetAZs '']
      MapPublicIpOnLaunch: true

  InternetGateway:
    Type: AWS::EC2::InternetGateway

  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC

  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: AttachGateway
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  SubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref PublicRouteTable


Outputs:
  VpcId:
    Value: !Ref VPC
    Export:
      Name: !Sub ${AWS::StackName}-VpcId

  PublicSubnetId:
    Value: !Ref PublicSubnet
    Export:
      Name: !Sub ${AWS::StackName}-PublicSubnetId

As you see above, I have defined some outputs that are exported. These will be imported by the second stack below. The second stack is just an EC2 instance. I don't need to connect to it or use it anyhow - I just want some resource that is blocking the deletion or modification of imported resources.

AWSTemplateFormatVersion: '2010-09-09'
Description: 'EC2 instance with security group in VPC'

Parameters:
  VpcStackName:
    Type: String
    Description: Name of the VPC stack to import values from

Resources:
  SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for EC2 instance
      VpcId: !ImportValue 
        Fn::Sub: ${VpcStackName}-VpcId
      SecurityGroupEgress:
        - IpProtocol: -1
          FromPort: -1
          ToPort: -1
          CidrIp: 0.0.0.0/0

  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: ami-083b72f9e766cbb7c  # eu-central-1 Amazon Linux 2023 ARM64 AMI
      InstanceType: t4g.nano
      SubnetId: !ImportValue 
        Fn::Sub: ${VpcStackName}-PublicSubnetId
      SecurityGroupIds:
        - !Ref SecurityGroup

Now deploy the stacks using AWS CLI or Console. First, you need the VPC stack and the next one is EC2 stack where you provide the name of the VPC stack as a parameter. Below I do it using AWS CLI:

aws cloudformation deploy \
 --template-file vpc.yaml \
 --stack-name example-vpc \
 --region eu-central-1

aws cloudformation deploy \
 --template-file ec2.yaml \
 --stack-name example-ec2 \
 --parameter-overrides VpcStackName=example-vpc \
 --region eu-central-1

As you now try to for example remove the outputs from the first stack, or do some modification that for example will replace the subnet or VPC, it will fail.

Preparing for migration

As the first step, I suggest getting list of all the resources and their "physical" addresses (such as ARNs, IDs) and storing it in a file so that you don't lose track of them. Next thing is to also dump all the outputs to a file. I will use AWS CLI for that, but if you prefer copy-pasting from the Console - your choice 🙄 (you can always use CloudShell!)

aws cloudformation describe-stack-resources \
 --stack-name example-vpc \
 --region eu-central-1 \
 --query 'StackResources[*].[LogicalResourceId,PhysicalResourceId]' \
 --output table > example-vpc-resources.txt

aws cloudformation describe-stacks \
 --stack-name example-vpc \
 --region eu-central-1 \
 --query 'Stacks[0].Outputs' \
 --output table > example-vpc-outputs.txt

After we have everything copied, we will perform some updates to the VPC stack. I assume that your stack is not drifted. If a drift is detected, first make sure that the stack is in sync. In case it might be drifted but it's out of scope of the CloudFormation drift detection, then it's your choice whether you want to take the risk of ⛓️‍💥 breaking something.

In the VPC stack, we need to mark all the resources with DeletionPolicy: Retain and UpdateReplacePolicy: Retain. That way, when we remove them in the future, they will be just disassociated from CloudFormation and not really deleted. Afterwards, apply the changes to the stack, nothing should fail if you don't have drifts. The following example is cut for brevity.

...
Resources:
  VPC:
    Type: AWS::EC2::VPC
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    ...
  PublicSubnet:
    Type: AWS::EC2::Subnet
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    ...
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    ...
  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    ...
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    ...
  PublicRoute:
    Type: AWS::EC2::Route
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    ...
  SubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    ...

Deploy the VPC stack again using AWS CLI or Console. Next step is to replace all the outputs with constants from the dump we did earlier. Fortunately, CloudFormation does only string comparison when checking for changes in outputs, so it won't complain. After you do this, apply the stack again.

...
Outputs:
  VpcId:
    Value: vpc-06789abcdef123456 # Replace with the actual VPC ID
    Export:
      Name: !Sub ${AWS::StackName}-VpcId

  PublicSubnetId:
    Value: subnet-01234567abc123cdf # Replace with the actual Subnet ID
    Export:
      Name: !Sub ${AWS::StackName}-PublicSubnetId

Now, as you have everything in a safe state, where there are no references in outputs to the resources in the stack and that nothing is marked for deletion, you can proceed to the next step - removal of all the resources from the stack. However 🥸! You can't do this because CloudFormation requires at least one resource in the stack! My suggestion is to use a placeholder resource of type AWS::IoT::Thing (someone at AY used it as a placeholder 😅).

AWSTemplateFormatVersion: '2010-09-09'
Description: 'VPC with public subnet, internet gateway, and routes'

Resources:
  Placeholder:
    Type: AWS::IoT::Thing
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      ThingName: !Sub ${AWS::StackName}-Placeholder

Outputs:
  VpcId:
    Value: vpc-06789abcdef123456 # Replace with the actual VPC ID
    Export:
      Name: !Sub ${AWS::StackName}-VpcId

  PublicSubnetId:
    Value: subnet-01234567abc123cdf # Replace with the actual Subnet ID
    Export:
      Name: !Sub ${AWS::StackName}-PublicSubnetId

Yes, this is now a complete vpc.yaml stack. Apply it again to CloudFormation and it should succeed.

Proceeding import with Terraformer

We have a list of resources that were previously associated with the stack and we can theoretically manually create a compatible Terraform code and just import everything. But there's a (Google Cloud's 😉) tool for that - Terraformer. It is not perfect, doesn't support all the resources or relations but is a good first step to get some code ready. Our example stack is pretty small. If you have a stack with 300 resources, then well, you have a different problem 🥲 (and moving to Terraform won't solve your company's tech culture).

Create a new directory for the Terraform code. Create a main.tf file and add the AWS provider to it. Below I provide you an example with local backend. Afterwards, in this directory initialize Terraform (or OpenTofu).

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

provider "aws" {
  region = "eu-central-1"
}
tofu init # or terraform init

In case you use OpenTofu, you need to also run the following command because Terraformer has hardcoded the provider repository to registry.terraform.io. As of OpenTofu 1.9.0 and AWS provider 5.100.0, it is working just fine.

ln -s .terraform/plugins/registry.opentofu.org .terraform/plugins/registry.terraform.io

It's time to call the tool. If you use an AWS profile, you need to specify it as --profile flag - the AWS_PROFILE environment variable doesn't work (despite it was merged here 🤷‍♂️).

terraformer import aws -r vpc,subnet,route_table # --profile optional_profile_name

You can ignore the errors at the end of the output. You should now see a new directory called generated. However, as we didn't specify any filter, the tool generated code for all the VPCs, Subnets and Route Tables we have in the region! Remove the generated directory and let's try setting the filters. For VPC the name of the field is id and for others vpc_id. It all depends on the resource type you are importing.

$ terraformer import aws -r vpc,subnet,route_table \
 --filter="Type=vpc;Name=id;Value=vpc-06789abcdef123456" \
 --filter="Type=subnet;Name=vpc_id;Value=vpc-06789abcdef123456" \
 --filter="Type=route_table;Name=vpc_id;Value=vpc-06789abcdef123456"

You can move the generated code from generated/vpc/vpc.tf to the root directory. For route_table there are also associations with subnets. By default they are not filtered, but if you want to filter them, you can use Type=route_table_association;Name=subnet_id;Value=subnet-01234567abc123cdf. If you want to match multiple values, use a colon, for example Type=subnet;Name=vpc_id;Value=vpc-06789abcdef123456:vpc-1234567890abcdef. However, in my opinion, it's easier to just go through the list of resources and delete what's unnecessary.

mv generated/aws/route_table/route_table.tf .
mv generated/aws/route_table/route_table_association.tf .
mv generated/aws/vpc/vpc.tf .
mv generated/aws/subnet/subnet.tf .
rm -r generated

If you try to plan now, you will see a lot of errors. First of all, Terraformer uses data sources from each state file. We can simply replace it with IDs from the values. I will do it using sed command and explain you the regex. From example ${data.terraform_remote_state.vpc.outputs.aws_vpc_tfer--vpc-0123456abcdef_id} we must match only vpc-0123456abcdef - this will be our \1. We look for the lines that start with ${data.terraform_remote_state. and end with } - we match any characters before two hyphens --. Afterwards, we look for -- and then match any characters that are not _ using [^_]* - this will become our \1. Whenever we find _ with anything that follows, we discard it. 😵‍💫 At least it works on my Mac 😌. For Linux, replace -i '' with just -i.

find . -depth 1 -name '*.tf' -exec sed -i '' 's/\${data\.terraform_remote_state\..*\-\-\([^_]*\)\(_[^}]*\)}/\1/g' {} \;

Now if you run tofu plan you will still see some errors. For example:

"map_customer_owned_ip_on_launch": all of `customer_owned_ipv4_pool,map_customer_owned_ip_on_launch,outpost_arn` must be specified

This seems like the harder part where you have to go through all of the places which need fixing. For the example above, simply delete the line: false is default. Similarly delete ipv6_netmask_length and enable_lni_at_device_index. Try to make the code as such that the plan succeeds.

Importing resources to Terraform state

As you have the working code, you can import all the resources. There are many approaches to this: you can generate a script that will use tofu import or you can use import block. I will go with the latter approach and will make a script using Bash.

First, I wll search through all the .tf files and look for Terraform resource lines. Then I will parse each line and extract the resource type, name and ID from the name. Lastly, I will add the import block of each resource to imports.tf file.

#!/bin/bash
echo "" > imports.tf

RESOURCES=$(find . -depth 1 -name '*.tf' -exec grep -E '^resource .* {' {} \;)

IFS=$'\n'
for resource in $RESOURCES; do
  TYPE=$(echo "$resource" | sed 's/resource "\(.*\)" "tfer--.*".*/\1/g')
  NAME=$(echo "$resource" | sed 's/resource ".*" "\(tfer--.*\)".*/\1/g')
  ID=$(echo "$NAME" | sed 's/^tfer--//g')
  echo -e "import {\n  to = ${TYPE}.${NAME}\n  id = \"${ID}\"\n}\n" >> imports.tf
done

Now if you tofu plan you should get a list of resources to import rather than create. There might be some more errors (for example route table associations), so you need to fix them accordingly. After you see only Plan: 5 to import, 0 to add, 0 to change, 0 to destroy., you can proceed to apply. After you have all the resource imported, try again planning and adapting until you have no changes: No changes. Your infrastructure matches the configuration.

Renaming resources

To change resource addresses in Terraform you have to manipulate the state. You can do it in the command line or use a new moved block, just like with imports. I suggest using the latter solution as it is more readable to review. Before we do that, we need to also delete imports.tf (or comment it out) as in the next step, the resources references will disappear. I will use Bash to rename each resource automatically and generate moved.tf file. First, I will parse the resources.txt file which we have created before. I will match all two-column lines with grep and then remove all trailing whitespace and pipe characters. I will first save the ID and then the logical name. Then I will find all resources in the .tf files that have tfer-- in their name and extract their type and ID. For each resource, I will then check if the ID can be looked up in the previously parsed CloudFormation resource dump. If this check succeeds, we can then proceed to rename the resource in the file and move it to the new name using moved block. The script will also add a comment at the top of resource block with the old ID so we can reference it later.

#!/bin/bash
NAMES_IDS=$(grep -E '^\|(?:\s+.*\s+\|){2}' example-vpc-resources.txt)
NAMES_IDS=$(echo "$NAMES_IDS" | sed -E 's/\|[[:blank:]]+([^[:blank:]]+)[[:blank:]]+\|[[:blank:]]+([^[:blank:]]+)[[:blank:]]+\|/\2 \1/g')

echo "" > moved.tf

for f in *.tf; do
  # Get all resource IDs found in this Terraform file, extract the ID and type
  RESOURCES=$(grep -oE '^resource ".*" ".*" {' $f)
  RESOURCE_IDS=($(echo "$RESOURCES" | grep -oE '"tfer--.*"' | tr -d \" | cut -d- -f3-))
  RESOURCE_TYPES=($(echo "$RESOURCES" | cut -d' ' -f2 | tr -d \"))
  # Iterate over all resources using integer index
  i=0
  while [ $i -lt ${#RESOURCE_IDS[@]} ]; do
    res_id=${RESOURCE_IDS[$i]}
    res_type=${RESOURCE_TYPES[$i]}
    # If the specified resource ID is in the dump
    if echo "$NAMES_IDS" | grep -qE "^$res_id "; then
      RES_NAME=$(echo "$NAMES_IDS" | grep -E "^$res_id " | cut -d' ' -f2)
      echo "Found $res_id -> $RES_NAME"
      sed -i '' -E "s#^(resource \".*\" )\"tfer\-\-${res_id}\"#// ${res_id}\n\1\"${RES_NAME}\"#g" $f
      echo -e "moved {\n  from = ${res_type}.tfer--${res_id}\n  to = ${res_type}.${RES_NAME}\n}\n" >> moved.tf
    fi
    i=$((i + 1))
  done
done

Afterwards you run this script, try planning again and see if the only changes are the renames. Then apply and you can delete the moved.tf file.

Matching properties

Currently, the resources have hardcoded properties. For example, the subnet has VPC's ID instead of referencing it in Terraform. We will create yet another script that will do just that! I want to note that it might not be perfect and some things might not be replaced correctly (if anything here works well at all!).

First, I need to collect all the resource lines from the *.tf files. I will also print the line above the resource line, assuming it contains the comment. Then we will use sed to extract the ID from the comment line, the resource type and name into format such as vpc-0123456789::aws_vpc.VPC. As the last step we will filter again with grep so that only lines with such format are considered. This will be done for all the *.tf files at once.

As now we have the list of resources with their IDs, we will go for each resource-id-address line and for each Terraform file. sed will replace all the occurrences of lines in the following format: argument = "string value". If the string value matches the ID, we will replace it with the address without quotes and the property to reference (hardcoded id in this scenario).

#!/bin/bash
RESOURCES_WITH_COMMENTS=$(grep -B1 -ohE '^resource ".*" ".*" {' *.tf)
RES_ID_TO_ADDR=$(echo "$RESOURCES_WITH_COMMENTS" | sed -E '/^\/\/ .*/N;s/^\/\/ (.*)\nresource "(.*)" "(.*)" {/\1::\2.\3\n/g')
RES_ID_TO_ADDR=($(echo "$RES_ID_TO_ADDR" | grep -oE '^.*::.*\..*$'))

for res_id_addr in ${RES_ID_TO_ADDR[@]}; do
  ID=$(echo "$res_id_addr" | cut -d':' -f1)
  ADDR=$(echo "$res_id_addr" | cut -d':' -f3-)
  find . -depth 1 -name '*.tf' -exec sed -i '' -E "s/([[:blank:]]*.+[[:blank:]]*=[[:blank:]]*)\"$ID\"/\1$ADDR.id/g" {} \;
done

Save it as match_properties.sh, chmod +x and run. This is not a final version as we have hardcoded the id property. This isn't always the case for all resources, just a random guess in this example.

As the last step, we can make a pipeline out of all the scripts we just used. You can create a migrate.sh script to do just that. I will start from the point where you have already generated code with Terraformer and moved all the files that you want to the root directory. I will use the files generated by .sh scripts to mark which stages were done and leave them empty if the apply succeeds. You need to rerun this script multiple times until you have no errors and the plan shows no changes (also check if there were no changes in the meantime when you were applying 😨).

#!/bin/bash
set -e
find . -depth 1 -name '*.tf' -exec sed -i '' 's/\${data\.terraform_remote_state\..*\-\-\([^_]*\)\(_[^}]*\)}/\1/g' {} \;
tofu validate
[ ! -f imports.tf ] && bash import.sh
tofu apply
echo "" > imports.tf
[ ! -f moved.tf ] && bash rename.sh
tofu apply
echo "" > moved.tf
bash match_properties.sh
tofu plan

Summary

If you follow this tutorial exactly, there are still some things to do. First of all we didn't import the Internet Gateway - I leave it to you as an exercise 😉. Also there's the default route table that can be deleted from Terraform code and state. Also not all relations between resources. Don't even get me started on resources that are identified with ARN.

And there you have it 🥳!

Now you know that you should choose your IaC tool wisely before you start doing anything as migrating from one to another will make your blood boil 😡. Another option is to go from the CloudFormation YAML side using tools like cf2tf or CloudFormation_To_Terraform but in either case, you have to: free the resources from the CloudFormation stack, freeze the outputs and import to Terraform - this last step is the hardest one. Or if you like doing YOLO on production, just hook up your favorite LLM with an MCP server and let it do anything but don't blame anyone when it buys u-24tb1.224xlarge for three years reservation all upfront because someone used transparent font on their blog prompting it to do it (not me 🙄).