To translate CloudFormation YAML !Sub to Terraform, turn it into an HCL string with ${} interpolation: parameters become var.name, resource references become resource attributes, and pseudo parameters like AWS::AccountId come from data sources. ${!Literal} becomes $${Literal}. The trap is !Ref, which returns a name, ID, URL or ARN depending on the resource type.
This is a lookup reference for engineers converting templates by hand or checking a machine translation. It covers CloudFormation YAML !Sub to Terraform first, because that’s where most templates keep their logic, then !Ref, !GetAtt, pseudo parameters, Conditions, Mappings and the rest of the intrinsic functions. Each entry shows the HCL equivalent and what changes in behaviour.
It pairs with two other pieces. Our walkthrough to import CloudFormation resources into Terraform safely covers moving live resources without recreating them. The free CloudFormation YAML to Terraform converter drafts the HCL. This page is for checking that draft line by line.
How CloudFormation YAML !Sub maps to Terraform strings
!Sub substitutes ${Name} placeholders in a string. Terraform strings do the same with ${expression}, so the translation is mostly about what each placeholder points at:
Inside !Sub |
Terraform | Note |
|---|---|---|
${EnvParam} |
${var.env} |
Parameters become input variables |
${MyBucket} |
${aws_s3_bucket.my.id} |
Same as !Ref: check the resource’s return value |
${MyQueue.Arn} |
${aws_sqs_queue.my.arn} |
Same as !GetAtt |
${AWS::AccountId} |
${data.aws_caller_identity.current.account_id} |
Needs a data block |
${!Literal} |
$${Literal} |
Escape for a literal ${ |
| Two-argument form with a variable map | A locals value or the expression inline |
Terraform has no local map per string |
The escape row matters for shell scripts in user data and for any string that must keep a literal ${. HashiCorp’s reference on Terraform string literals and template escapes documents $${ and %%{.
A worked !Sub example
AWSTemplateFormatVersion: '2010-09-09'
Parameters:
Env:
Type: String
AllowedValues: [dev, prod]
Resources:
ReportsBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub 'acme-reports-${Env}-${AWS::AccountId}'
ReportsQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub '${AWS::StackName}-reports'
ReaderPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: s3:GetObject
Resource: !Sub 'arn:${AWS::Partition}:s3:::${ReportsBucket}/*'
- Effect: Allow
Action: sqs:ReceiveMessage
Resource: !GetAtt ReportsQueue.Arn
Greeting:
Type: AWS::SSM::Parameter
Properties:
Name: !Sub '/${Env}/reports/greeting'
Type: String
Value: !Sub 'echo "Hello from ${Env}, home is ${!HOME}"'
Outputs:
QueueUrl:
Value: !Ref ReportsQueue
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
variable "env" {
type = string
validation {
condition = contains(["dev", "prod"], var.env)
error_message = "env must be dev or prod."
}
}
locals {
# CloudFormation's AWS::StackName has no Terraform equivalent.
stack_name = "reports-${var.env}"
}
resource "aws_s3_bucket" "reports" {
bucket = "acme-reports-${var.env}-${data.aws_caller_identity.current.account_id}"
}
resource "aws_sqs_queue" "reports" {
name = "${local.stack_name}-reports"
}
resource "aws_iam_policy" "reader" {
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "s3:GetObject"
Resource = "arn:${data.aws_partition.current.partition}:s3:::${aws_s3_bucket.reports.id}/*"
},
{
Effect = "Allow"
Action = "sqs:ReceiveMessage"
Resource = aws_sqs_queue.reports.arn
}
]
})
}
resource "aws_ssm_parameter" "greeting" {
name = "/${var.env}/reports/greeting"
type = "String"
value = "echo \"Hello from ${var.env}, home is $${HOME}\""
}
output "queue_url" {
value = aws_sqs_queue.reports.url
}
Three things changed beyond syntax. AllowedValues became a validation block. The inline policy document became jsonencode(), which avoids hand-escaping JSON inside HCL. And the output uses .url, because !Ref on a queue returns its URL, not its name. In the policy you could also write "${aws_s3_bucket.reports.arn}/*", which is shorter and reads the same ARN.
Pseudo parameters and their Terraform equivalents
| CloudFormation | Terraform |
|---|---|
AWS::AccountId |
data.aws_caller_identity.current.account_id |
AWS::Region |
data.aws_region.current.region (AWS provider 6.x; older versions use .name) |
AWS::Partition |
data.aws_partition.current.partition |
AWS::URLSuffix |
data.aws_partition.current.dns_suffix |
AWS::NoValue |
null |
AWS::StackName, AWS::StackId |
No equivalent: use a variable or locals value |
AWS::NotificationARNs |
No equivalent: stack notifications don’t exist in Terraform |
If a name was built from AWS::StackName, keep the exact same string in Terraform. A different name on a bucket, queue or table forces replacement when you later import it.
What does !Ref return, and which Terraform attribute matches?
!Ref on a parameter returns its value. On a resource it returns whatever that resource type defines, which varies. Terraform’s id attribute also varies, so match on meaning, not on the word “ID”:
| Resource type | !Ref returns |
Terraform |
|---|---|---|
AWS::S3::Bucket |
Bucket name | aws_s3_bucket.x.id or .bucket |
AWS::SQS::Queue |
Queue URL | aws_sqs_queue.x.url (also .id) |
AWS::SNS::Topic |
Topic ARN | aws_sns_topic.x.arn |
AWS::IAM::Role |
Role name | aws_iam_role.x.name |
AWS::Lambda::Function |
Function name | aws_lambda_function.x.function_name |
AWS::DynamoDB::Table |
Table name | aws_dynamodb_table.x.name |
AWS::EC2::SecurityGroup |
Security group ID | aws_security_group.x.id |
AWS::KMS::Key |
Key ID | aws_kms_key.x.key_id |
The classic bug: a template passes !Ref MyTopic to a property that wants an ARN, and a converted file uses aws_sns_topic.x.name because “Ref means name”. Topics return ARNs; roles return names. When in doubt, open the resource’s “Return values” section in the CloudFormation reference.
!GetAtt attributes in Terraform
| CloudFormation | Terraform |
|---|---|
!GetAtt Bucket.Arn |
aws_s3_bucket.x.arn |
!GetAtt Bucket.DomainName |
aws_s3_bucket.x.bucket_domain_name |
!GetAtt Bucket.RegionalDomainName |
aws_s3_bucket.x.bucket_regional_domain_name |
!GetAtt Role.Arn / Role.RoleId |
aws_iam_role.x.arn / .unique_id |
!GetAtt Queue.Arn / Queue.QueueName |
aws_sqs_queue.x.arn / .name |
!GetAtt Function.Arn |
aws_lambda_function.x.arn |
!GetAtt Table.StreamArn |
aws_dynamodb_table.x.stream_arn |
The other intrinsic functions
| CloudFormation | Terraform | Gotcha |
|---|---|---|
!Join [",", list] |
join(",", list) |
Same argument order |
!Split [",", str] |
split(",", str) |
Same argument order |
!Select [0, list] |
list[0] or element(list, 0) |
element() wraps around past the end; indexing errors instead |
!GetAZs '' |
data.aws_availability_zones.available.names |
Add state = "available" to filter |
!Base64 |
base64encode() |
aws_instance takes plain user_data; aws_launch_template expects base64 |
!Cidr [block, count, bits] |
cidrsubnets() or cidrsubnet() |
The bit arguments mean different things (below) |
!FindInMap |
Index a map in locals |
Mappings become plain maps |
!ImportValue |
data "aws_cloudformation_export" or a data source |
Still depends on the exporting stack |
!If, !Equals, !And, !Or, !Not |
cond ? a : b, ==, &&, ||, ! |
Conditions on resources become count |
Fn::Length, Fn::ToJsonString, Fn::ForEach |
length(), jsonencode(), for_each |
These need the AWS::LanguageExtensions transform in CloudFormation |
Macros, !Transform |
No equivalent | Process the template first, then convert the output |
!Cidr bits are not Terraform newbits
In Fn::Cidr, the third argument is the number of host bits in each new block: 8 gives a /24 whatever the parent size. In Terraform’s cidrsubnet() and cidrsubnets(), the bit argument is how many bits to add to the parent prefix. They agree only when the parent is a /16. AWS’s own example splits a /24 into six /27 blocks:
# CloudFormation: !Cidr [ "192.168.0.0/24", 6, 5 ] (5 host bits -> /27)
locals {
subnets = cidrsubnets("192.168.0.0/24", 3, 3, 3, 3, 3, 3) # 24 + 3 = /27
}
Conditions, !If and AWS::NoValue
Conditions:
IsProd: !Equals [!Ref Env, prod]
Resources:
AlertsTopic:
Type: AWS::SNS::Topic
Condition: IsProd
JobsQueue:
Type: AWS::SQS::Queue
Properties:
KmsMasterKeyId: !If [IsProd, alias/aws/sqs, !Ref AWS::NoValue]
Outputs:
AlertsTopicArn:
Condition: IsProd
Value: !Ref AlertsTopic
locals {
is_prod = var.env == "prod"
}
resource "aws_sns_topic" "alerts" {
count = local.is_prod ? 1 : 0
name = "orders-alerts"
}
resource "aws_sqs_queue" "jobs" {
name = "orders-jobs"
kms_master_key_id = local.is_prod ? "alias/aws/sqs" : null
}
output "alerts_topic_arn" {
value = one(aws_sns_topic.alerts[*].arn)
}
Once a resource has count, every reference needs an index or a splat, which is why the output uses one(). It returns null when the topic doesn’t exist, the closest match to a conditional output.
Mappings and !ImportValue
# Mappings + !FindInMap [EnvSizes, !Ref Env, InstanceType]
locals {
env_sizes = {
dev = { instance_type = "t4g.small" }
prod = { instance_type = "m7g.large" }
}
instance_type = local.env_sizes[var.env].instance_type
}
# !ImportValue network-VpcId
data "aws_cloudformation_export" "vpc_id" {
name = "network-VpcId"
}
resource "aws_security_group" "app" {
name = "orders-app"
vpc_id = data.aws_cloudformation_export.vpc_id.value
}
The aws_cloudformation_export data source keeps a dependency on the exporting stack, which is fine during a migration. Once that stack moves too, replace the lookup with a direct reference or a data source such as aws_vpc, so deleting the old stack doesn’t break the plan.
Common mistakes when converting intrinsic functions
- Using
.ideverywhere. It’s a name for buckets and roles, a URL for queues and an ARN for topics. Pick the attribute by meaning. - Dropping the
${!}escape. A shell variable in user data that becomes${HOME}in HCL is a Terraform reference error, or worse, an empty string after templating. - Hard-coding account IDs and Regions that the template derived from pseudo parameters. Use the data sources so the code works in every account.
- Forgetting the index after
count.aws_sns_topic.alerts.arnfails oncecountis set. - Converting
!Cidrnumbers as-is. Recalculate the bits, then compare the resulting blocks with the live subnets.
After converting, compare against what’s deployed, not against the repository. You can list AWS resources with natural language from your terminal to confirm names, and the checks in troubleshooting AWS infrastructure with an AI CLI help when a converted stack behaves differently. If the policies in the template came out wide, run them through our guide to review an IAM policy for least privilege.
Where ChatWithCloud fits, and its limits
The CloudFormation YAML to Terraform code converter and the CloudFormation JSON to Terraform converter draft the HCL for a whole template and add comments where there’s no direct equivalent. They don’t read your account, so they can’t know live names; check each conversion against this reference and run terraform plan. Going the other way, the Terraform to CloudFormation converter drafts templates from HCL, and the walkthrough to migrate a Terraform stack to CloudFormation without downtime moves live resources in that direction.
Conversions are capped at 60,000 characters each; the ChatWithCloud free converter limits explain the per-minute and daily caps, which a free ChatWithCloud account for higher converter limits raises. Strip secrets from parameters and defaults before pasting; our note on pasting AWS code into an AI converter safely covers what is sent. If you’re still choosing a target tool, read AWS CDK vs Terraform for existing AWS stacks first.
Frequently asked questions
How do I convert CloudFormation YAML !Sub to Terraform when it uses a variable map?
Put each map entry in locals or write the expression directly inside the HCL string. Terraform strings interpolate any expression, so the map isn’t needed.
What is the Terraform equivalent of AWS::Region?
data.aws_region.current.region on AWS provider 6.x, after declaring data "aws_region" "current" {}. Earlier provider versions expose it as .name.
Is Terraform’s id the same as CloudFormation’s Ref?
Sometimes. Both depend on the resource type. For an SQS queue both are the URL, but for an SNS topic !Ref is the ARN and you should use .arn to be explicit.
Can Terraform read CloudFormation stack outputs?
Yes. Exported outputs are available through the aws_cloudformation_export data source, and the aws_cloudformation_stack data source reads a stack’s outputs.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud

