Skip to main content

Migrating from CloudFormation and ARM Templates to Terraform

· 7 min read
Goel Academy
DevOps & Cloud Learning Hub

Your organization decided to standardize on Terraform. Great choice. But you have 200 CloudFormation stacks in AWS and 150 ARM template deployments in Azure that are running production workloads right now. You cannot delete them and start over. You need a migration strategy that moves infrastructure under Terraform management without downtime, without recreating resources, and with a rollback plan if things go sideways.

Why Migrate to Terraform?

Before investing the effort, make sure the reasons are clear:

FactorCloudFormation / ARMTerraform
Multi-cloudAWS-only / Azure-onlyAWS + Azure + GCP + 3,000+ providers
State visibilityOpaque (managed by cloud vendor)Explicit state file, inspectable
ModularityNested stacks / linked templatesFirst-class module system with registry
Plan previewChange sets (limited detail)terraform plan with full diff
EcosystemVendor-locked toolingTerragrunt, Atlantis, Terraform Cloud, etc.
LanguageJSON/YAML (verbose)HCL (concise, expressive)

The migration is worth it when you need multi-cloud, better developer experience, or a unified IaC workflow across teams.

CloudFormation to Terraform

Option 1: Automated with cf2tf

The cf2tf tool by DontShaveTheYak converts CloudFormation templates to Terraform HCL:

# Install cf2tf
pip install cf2tf

# Convert a CloudFormation template
cf2tf my-stack-template.yaml -o terraform/

# Output structure
terraform/
├── main.tf # Resources
├── variables.tf # Parameters → variables
└── outputs.tf # Outputs

The output is a starting point, not production-ready code. You will need to:

  1. Fix resource references (cf2tf handles most !Ref and !GetAtt conversions).
  2. Replace CloudFormation intrinsic functions with Terraform equivalents.
  3. Add backend configuration.
  4. Import existing resources into state.

Option 2: Manual Conversion

For complex stacks, manual conversion gives you cleaner code. Map CloudFormation resources to Terraform resources one at a time:

# CloudFormation
Resources:
MyBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: my-app-data-bucket
VersioningConfiguration:
Status: Enabled
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: aws:kms

Becomes:

# Terraform
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-app-data-bucket"
}

resource "aws_s3_bucket_versioning" "my_bucket" {
bucket = aws_s3_bucket.my_bucket.id
versioning_configuration {
status = "Enabled"
}
}

resource "aws_s3_bucket_server_side_encryption_configuration" "my_bucket" {
bucket = aws_s3_bucket.my_bucket.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}

Notice how a single CloudFormation resource often maps to multiple Terraform resources. AWS provider v4+ split many resources into separate configuration resources.

Importing CloudFormation-Managed Resources

After writing the Terraform code, import the existing resources so Terraform manages them without recreating them:

# Import the S3 bucket
terraform import aws_s3_bucket.my_bucket my-app-data-bucket

# Import versioning config
terraform import aws_s3_bucket_versioning.my_bucket my-app-data-bucket

# Import encryption config
terraform import aws_s3_bucket_server_side_encryption_configuration.my_bucket my-app-data-bucket

After importing, run terraform plan to verify zero changes. If Terraform shows differences, adjust your HCL to match the actual resource configuration exactly.

# Verify — this should show "No changes"
terraform plan

ARM Templates to Terraform

Option 1: Automated with aztfexport

Microsoft's aztfexport tool exports existing Azure resources directly into Terraform:

# Install aztfexport
go install github.com/Azure/aztfexport@latest

# Export an entire resource group
aztfexport resource-group my-resource-group -o terraform/

# Export a single resource by ID
aztfexport resource \
"/subscriptions/xxx/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm" \
-o terraform/

aztfexport is more powerful than cf2tf because it reads from the live Azure API, not just templates. It generates HCL and imports resources into state simultaneously:

# aztfexport output
terraform/
├── main.tf # All resources
├── provider.tf # AzureRM provider config
├── terraform.tf # Backend config
└── terraform.tfstate # State with imported resources

Option 2: Manual Conversion

Map ARM resources to Terraform one at a time:

// ARM Template
{
"type": "Microsoft.Network/virtualNetworks",
"apiVersion": "2023-05-01",
"name": "my-vnet",
"location": "[resourceGroup().location]",
"properties": {
"addressSpace": {
"addressPrefixes": ["10.0.0.0/16"]
},
"subnets": [
{
"name": "web-subnet",
"properties": {
"addressPrefix": "10.0.1.0/24"
}
}
]
}
}

Becomes:

# Terraform
resource "azurerm_virtual_network" "main" {
name = "my-vnet"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
address_space = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "web" {
name = "web-subnet"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.1.0/24"]
}

Import with:

terraform import azurerm_virtual_network.main \
"/subscriptions/xxx/resourceGroups/my-rg/providers/Microsoft.Network/virtualNetworks/my-vnet"

terraform import azurerm_subnet.web \
"/subscriptions/xxx/resourceGroups/my-rg/providers/Microsoft.Network/virtualNetworks/my-vnet/subnets/web-subnet"

Maintaining Parallel Stacks During Migration

Never delete the original CloudFormation stack or ARM deployment until Terraform fully manages the resources. Run parallel stacks during the transition:

Phase 1: Write Terraform code, import resources
CloudFormation/ARM: ACTIVE (do not touch)
Terraform: Manages same resources via import

Phase 2: Verify terraform plan shows no changes
Run for 1-2 weeks to confirm stability

Phase 3: Remove resources from CloudFormation/ARM stack
Use DeletionPolicy: Retain (CF) or "complete" mode (ARM)
This deletes the stack but keeps the actual resources

Phase 4: Terraform is the sole owner
Delete old templates from version control

For CloudFormation, set DeletionPolicy: Retain on all resources before deleting the stack:

# Update CloudFormation stack — add Retain to all resources
Resources:
MyBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
Properties:
BucketName: my-app-data-bucket

Then delete the stack safely — the resources remain, now managed only by Terraform.

Testing Migrated Code

After migration, validate thoroughly:

# Step 1: Plan should show zero changes
terraform plan

# Step 2: Run terraform validate
terraform validate

# Step 3: Use tflint for best practices
tflint --init
tflint

# Step 4: Use checkov for security scanning
checkov -d .

# Step 5: Optionally apply to a test environment first
terraform workspace select staging
terraform apply

Rollback Strategy

Always have a way to go back:

  1. Keep original templates in version control (separate branch or archived folder).
  2. Tag the Terraform state before major changes: copy the state file to a backup location.
  3. Use terraform state rm to remove resources from Terraform without destroying them, then re-deploy the original CloudFormation/ARM stack.
# Emergency rollback: remove resource from Terraform state (does NOT delete it)
terraform state rm aws_s3_bucket.my_bucket

# Now re-import into CloudFormation stack if needed
aws cloudformation create-stack \
--stack-name my-stack-restored \
--template-body file://original-template.yaml

Migration Checklist

Use this checklist for each stack you migrate:

  • Inventory all resources in the CloudFormation/ARM stack
  • Write equivalent Terraform HCL
  • Configure remote backend (S3 + DynamoDB or Azure Storage)
  • Import every resource into Terraform state
  • Run terraform plan — confirm zero changes
  • Add CI/CD pipeline for the new Terraform code
  • Run parallel for 1-2 weeks
  • Set DeletionPolicy: Retain (CF) or use complete mode (ARM)
  • Delete the old stack
  • Remove old templates from active use
  • Document the migration in a runbook

Common Pitfalls

PitfallProblemSolution
Resource namingTerraform resource names do not match CF logical IDsUse meaningful names; refactor later with moved blocks
State conflictsTwo tools managing the same resourceNever apply from both tools simultaneously
Missing importsForgot to import a sub-resourceRun terraform plan to catch; import anything Terraform wants to create
Provider versionOld provider version lacks import supportUse latest provider version for migration
Drift during migrationSomeone updates CF stack while migratingFreeze changes to old stacks during migration window

The Hybrid Approach

You do not have to migrate everything at once. A gradual migration works well:

  1. New infrastructure — always use Terraform.
  2. High-change resources — migrate first (compute, deployments).
  3. Stable infrastructure — migrate last (networking, DNS, IAM).
  4. Legacy stacks — leave in CloudFormation/ARM if they never change.

This approach spreads the effort over months and lets your team build Terraform skills incrementally.

Closing Note

Migration is not a weekend project. It is a systematic process of writing code, importing state, verifying plans, and carefully decommissioning old stacks. The tools — cf2tf, aztfexport, terraform import — handle the mechanical parts. Your job is to plan the sequence, test thoroughly, and resist the urge to rush. Every stack you migrate is one less vendor-locked template and one step closer to a unified infrastructure workflow.