CloudFormation YAML !Sub, !Ref and !GetAtt to Terraform

Abstract network of glowing blue lines and nodes on a dark background

Photo by Growtika on Unsplash

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

reports.yaml (CloudFormation)

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
main.tf (Terraform)

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:

Same 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.yaml (CloudFormation)

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
conditions.tf (Terraform)

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

lookups.tf

# 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 .id everywhere. 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.arn fails once count is set.
  • Converting !Cidr numbers 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