CoursesAWS CloudFormationIntrinsic functions & pseudo-parameters

Intrinsic functions & pseudo-parameters

Ref, GetAtt, Sub, and friends.

Intermediate12 min · lesson 6 of 12

You write a CloudFormation template before a single one of its resources exists. That leaves you naming things you cannot possibly know yet: the ID (identifier) AWS will hand your VPC (Virtual Private Cloud, your own walled-off network inside AWS), the address of a load balancer that has not booted, the account number you happen to be deploying into. Intrinsic functions are how you write those blanks. A template behaves like a form letter with fill-in slots: you type Dear ${name} now, and the real name gets merged in when the letter goes out. CloudFormation does the merge at deploy time, once every resource has a real identifier. Learn a handful of these functions and your templates stop hard-coding values and start composing them.

Ref and GetAtt: Pointing at Things That Do Not Exist Yet

Once a resource exists, it carries a small fact sheet. Ref reads the single headline field off that sheet. Fn::GetAtt reads any specific line you point to. What Ref returns depends on the type: Ref on an S3 (Simple Storage Service) bucket gives the bucket name, on an EC2 (Elastic Compute Cloud) instance the instance ID, on a VPC the VPC ID. Point Ref at a parameter instead of a resource and it returns that parameter's value. GetAtt covers everything the headline leaves out: an ARN (Amazon Resource Name, the id AWS uses in policies), a DNS (Domain Name System) name, an endpoint address, a private IP. You choose the attribute with dot syntax, and it can reach nested fields like Db.Endpoint.Address.

app.yaml
Resources:
AppBucket:
Type: AWS::S3::Bucket
Web:
Type: AWS::EC2::Instance
Properties:
SubnetId: !Ref PublicSubnet # Ref a PARAMETER -> its value
ImageId: !Ref LatestAmi # AMI = Amazon Machine Image
Tags:
- Key: Bucket
Value: !Ref AppBucket # Ref a RESOURCE -> its default id (the NAME)
Db:
Type: AWS::RDS::DBInstance # RDS = Relational Database Service
Properties:
Engine: postgres
DBInstanceClass: db.t3.micro
AllocatedStorage: "20"

GetAtt does one more thing for free. When you write !GetAtt Db.Endpoint.Address, you have also told CloudFormation that this resource needs the database first. It reads the reference, sees the dependency, and builds the database before whatever points at it, with no explicit DependsOn. Ref sets up the same ordering. This is also where a common security bug lives. Say you scope an IAM (Identity and Access Management, the service that decides who may do what) policy to one bucket and write !Ref AppBucket in the policy's Resource field. Ref on a bucket returns its name. An IAM Resource field holds an ARN or * and nothing else, so IAM refuses the bare name: the policy fails to create with MalformedPolicyDocument: ... must be in ARN format, and the stack rolls back. That is the lucky ending. The dangerous one is the engineer who, staring at a red deploy, widens the Resource to * to force it green and ships a policy that now reaches every bucket in the account. The fix is !GetAtt AppBucket.Arn. Before you assume which one you need, read the resource type's documented Return values.

Fn::Sub: String Interpolation You Can Read

Fn::Sub is mail merge for strings. Before it existed, people built ARNs and web addresses out of deeply nested Fn::Join arrays that were painful to read and easy to break. Sub swaps all that for shell-style ${...} slots. Inside a ${...} you can drop a resource's logical name, a parameter, a pseudo-parameter, or a dotted GetAtt such as ${Web.PublicIp}. It takes an optional second argument too, a map of local variables you define inline, which is how you slot in a value that is not already a resource or parameter. Reach for Sub on any ARN, URL (web address), user-data script, or policy document a person has to read.

app.yaml
Outputs:
BucketName:
Value: !Ref AppBucket # default id -> the name
BucketArn:
Value: !GetAtt AppBucket.Arn # a specific attribute
InstanceId:
Value: !Ref Web # default id -> the instance id
InstanceIp:
Value: !GetAtt Web.PublicIp # Ref would NOT give you this
DbEndpoint:
Value: !GetAtt Db.Endpoint.Address # nested attribute, dotted
PolicyArn:
Value: !Sub "arn:${AWS::Partition}:s3:::${AppBucket}/*"
ApiUrl: # assumes a load balancer named Alb
Value: !Sub
- "https://${Host}/${Stage}" # map form: template + local vars
- Host: !GetAtt Alb.DNSName
Stage: prod
app.yaml
Web: # the same Web instance, now with a boot script
Type: AWS::EC2::Instance
Properties:
UserData:
Fn::Base64: !Sub | # outer full form, inner short: one tag each
#!/bin/bash
echo "region is ${AWS::Region}" # CloudFormation resolves this
echo "bucket is ${AppBucket}" # resolves to the bucket name
echo "home is ${!HOME}" # stays literal: ${HOME}
Escape The ${...} You Do Not Want Resolved
Inside Fn::Sub, every ${x} is read as a reference to resolve. Put a shell variable like ${HOME} in a UserData script without escaping it, and CloudFormation goes hunting for a resource or parameter named HOME. It finds none, and rejects the template up front with Unresolved resource dependencies [HOME], before it builds a single resource, so there is nothing to roll back. The same trap catches IAM policy variables such as ${aws:username}, which IAM, not CloudFormation, is meant to expand at request time. Write ${!HOME} and ${!aws:username} with the leading !, and the literal text passes straight through.

Prove It At Deploy Time

Every one of these functions is inert text until a stack operation runs, so the operator's habit is: validate, deploy, then read the resolved values back. Leave the ! off ${HOME} above and the AWS CLI (command-line interface) stops you before anything is built.

terminal
aws cloudformation validate-template --template-body file://app.yaml
output
An error occurred (ValidationError) when calling the ValidateTemplate operation: Template format error: Unresolved resource dependencies [HOME] in the Resources block of the template

Add the escape, deploy, and pull the stack's Outputs. Here the difference between Ref, GetAtt, and Sub stops being theory. The bucket comes back as a name, its ARN comes back as an ARN, and the composed policy string arrives fully assembled with the partition already filled in.

terminal
aws cloudformation deploy \
--template-file app.yaml --stack-name app \
--capabilities CAPABILITY_NAMED_IAM
aws cloudformation describe-stacks --stack-name app \
--query 'Stacks[0].Outputs[].[OutputKey,OutputValue]' --output text
output
Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - app
BucketName app-appbucket-1a2b3c4d5e6f
BucketArn arn:aws:s3:::app-appbucket-1a2b3c4d5e6f
InstanceId i-0abcd1234ef567890
InstanceIp 54.226.18.203
DbEndpoint app-db.cxy1234abcd.us-east-1.rds.amazonaws.com
PolicyArn arn:aws:s3:::app-appbucket-1a2b3c4d5e6f/*
ApiUrl https://app-alb-1234567890.us-east-1.elb.amazonaws.com/prod
How A Reference Resolves At Deploy Time
1You write !GetAtt Alb.DNSName
inert text sitting in the template
2A stack operation starts
deploy, create, or update
3Build the dependency graph
every Ref, GetAtt, and Sub is an edge
4Create resources in order
each returns real ids and attributes
5Substitute the real values
the property gets the load balancer's DNS name
Nothing resolves until a stack operation runs. CloudFormation orders creation from the reference graph, then fills in the real values it gets back.

Pseudo-Parameters Keep Templates Portable

Pseudo-parameters are the fields the system fills without being asked, like the date stamp and return address a mailroom adds on the way out. They exist in every template with no declaration. AWS::Region and AWS::AccountId tell a resource where and whose account it landed in. AWS::StackName and AWS::StackId name the deployment for tags and logs. AWS::Partition is the security-relevant one: it returns aws in the commercial regions, aws-us-gov in GovCloud (the isolated United States government partition), and aws-cn in China. Hardcode arn:aws:... and your template is correct in Virginia and quietly wrong the day it lands in GovCloud, where every ARN begins arn:aws-us-gov:. Write arn:${AWS::Partition}:... and the one file is right in all three. AWS::URLSuffix does the same job for hostnames (amazonaws.com versus amazonaws.com.cn). And AWS::NoValue, returned from an Fn::If, removes a property outright instead of setting it empty.

network.yaml
Resources:
SubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.0.0.0/24
AvailabilityZone: !Select [0, !GetAZs ""] # "" = the Availability Zones of THIS region
AppBucket:
Type: AWS::S3::Bucket
Properties:
BucketEncryption: !If # NoValue drops the whole property when UseKms is false
- UseKms
- ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: aws:kms # SSE = server-side encryption, KMS = Key Management Service
KMSMasterKeyID: !Ref KmsKey
- !Ref "AWS::NoValue"

Fn::GetAZs returns the Availability Zones (isolated groups of data centers inside a region) for that region, Fn::Select plucks one out by zero-based index, and Fn::Split and Fn::Join convert between strings and lists. Together they let one template fan its subnets across whatever zones the target region actually has, instead of naming zones you would have to edit for every region. The same Split then Select pattern pulls one entry out of a comma-separated parameter, say the first CIDR (Classless Inter-Domain Routing, the a.b.c.d/n way of writing a block of IP addresses) in an allow-list.

Two Functions, One Node

A YAML tag works like the shipping label on a box: one box carries one label. A short-form function such as !Sub is that label, and a YAML (the indented text format the template is written in) node is the box, so a single value can take only one short form. !Base64 !Sub "..." is a parse error, and so is !GetAZs !Ref "AWS::Region", because in each case two tags land on one node. And because it breaks the YAML parser itself, it fails before CloudFormation validation even runs: the error points at the parser, not at your template logic, which is baffling the first time you hit it. The fix is to write the outer function in its full Fn:: form and keep the inner one short. Nesting through a list is fine, because each function then tags a separate node in the sequence.

snippets.yaml
# INVALID: two short (!) tags on ONE node -> YAML parse error
AvailabilityZone: !Select [0, !GetAZs !Ref "AWS::Region"]
# VALID: outer in full Fn:: form, inner stays short (one tag per node)
AvailabilityZone: !Select
- 0
- Fn::GetAZs: !Ref "AWS::Region"
# VALID: each function tags a SEPARATE node inside the list
FirstCidr: !Select [0, !Split [",", !Ref AllowedCidrs]]
# The same rule bites cross-stack imports:
# VpcId: !ImportValue !Sub "${NetworkStack}-VpcId" <- INVALID
VpcId:
Fn::ImportValue: !Sub "${NetworkStack}-VpcId"

Fn::ImportValue, which reads a value another stack has exported, obeys every rule here; its cross-stack wiring gets its own lesson. Before any of this reaches AWS, catch it locally. cfn-lint parses the template the way CloudFormation will, flags a bad GetAtt attribute or a Ref to a logical name that does not exist, and exits nonzero so a CI (Continuous Integration, the automated checks that run on every push) pipeline blocks the merge. A clean run prints nothing and exits zero.

terminal
cfn-lint app.yaml network.yaml
echo "exit $?"
output
exit 0
Quick check
01Your template's IAM policy must grant access to one specific S3 bucket, so you set the policy's Resource field to !Ref AppBucket. The stack rolls back on deploy. What went wrong?
Correct — Ref hands back a resource's default id, which for S3 is the bucket name, not the arn:aws:s3::: value IAM validates and matches on, so the policy fails to create and the stack rolls back.
Incorrect — Ref works anywhere in the template, including Resources; it returned the real bucket name, only the wrong shape for an IAM ARN.
Incorrect — There is no dependency cycle here; the rollback comes from the malformed ARN, and a genuine cycle raises a circular-dependency error instead of blanking a field.
Incorrect — Ref on a bucket returns the name, not an ARN at all, so there is no /* to strip; that is a separate gotcha.
02You are moving a template that already works in a commercial region into AWS GovCloud (the isolated United States government partition), where every Amazon Resource Name (ARN) must begin arn:aws-us-gov:. The template hard-codes arn:aws:... everywhere. Which pseudo-parameter makes the one file correct in every partition?
Incorrect — the region is a separate part of the ARN; the partition prefix aws versus aws-us-gov is the piece that breaks, and AWS::Region does not supply it.
Incorrect — the account id has nothing to do with the ARN's partition segment, so swapping it never fixes the arn:aws: prefix.
Correct — AWS::Partition resolves to the partition of the deploying region, so one template produces the right ARN prefix in each one.
Incorrect — AWS::URLSuffix swaps hostnames such as amazonaws.com versus amazonaws.com.cn, not the ARN partition segment.
03You write AvailabilityZone: !Select [0, !GetAZs !Ref "AWS::Region"] and the deploy fails right away with a YAML (the text format the template is written in) parse error, before CloudFormation even validates the template. What is wrong and how do you fix it?
Incorrect — GetAZs accepts a region (an empty string just means the current one); the real fault is two short-form tags on a single node.
Correct — a YAML node can carry only one tag, so !GetAZs !Ref collides; moving the outer function to Fn:: form gives each function its own node.
Incorrect — Select is zero-based, and an out-of-range index would fail at deploy time, not as a YAML parse error before validation.
Incorrect — the pseudo-parameter is already quoted correctly; the collision of two ! tags on one node is what breaks the parser.

Try this

Run aws cloudformation validate-template --template-body file://app.yaml on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: escape The ${...} You Do Not Want Resolved. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related