Intrinsic functions & pseudo-parameters
Ref, GetAtt, Sub, and friends.
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.
Resources:AppBucket:Type: AWS::S3::BucketWeb:Type: AWS::EC2::InstanceProperties:SubnetId: !Ref PublicSubnet # Ref a PARAMETER -> its valueImageId: !Ref LatestAmi # AMI = Amazon Machine ImageTags:- Key: BucketValue: !Ref AppBucket # Ref a RESOURCE -> its default id (the NAME)Db:Type: AWS::RDS::DBInstance # RDS = Relational Database ServiceProperties:Engine: postgresDBInstanceClass: db.t3.microAllocatedStorage: "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.
Outputs:BucketName:Value: !Ref AppBucket # default id -> the nameBucketArn:Value: !GetAtt AppBucket.Arn # a specific attributeInstanceId:Value: !Ref Web # default id -> the instance idInstanceIp:Value: !GetAtt Web.PublicIp # Ref would NOT give you thisDbEndpoint:Value: !GetAtt Db.Endpoint.Address # nested attribute, dottedPolicyArn:Value: !Sub "arn:${AWS::Partition}:s3:::${AppBucket}/*"ApiUrl: # assumes a load balancer named AlbValue: !Sub- "https://${Host}/${Stage}" # map form: template + local vars- Host: !GetAtt Alb.DNSNameStage: prod
Web: # the same Web instance, now with a boot scriptType: AWS::EC2::InstanceProperties:UserData:Fn::Base64: !Sub | # outer full form, inner short: one tag each#!/bin/bashecho "region is ${AWS::Region}" # CloudFormation resolves thisecho "bucket is ${AppBucket}" # resolves to the bucket nameecho "home is ${!HOME}" # stays literal: ${HOME}
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.
aws cloudformation validate-template --template-body file://app.yaml
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.
aws cloudformation deploy \--template-file app.yaml --stack-name app \--capabilities CAPABILITY_NAMED_IAMaws cloudformation describe-stacks --stack-name app \--query 'Stacks[0].Outputs[].[OutputKey,OutputValue]' --output text
Waiting for changeset to be created..Waiting for stack create/update to completeSuccessfully created/updated stack - appBucketName app-appbucket-1a2b3c4d5e6fBucketArn arn:aws:s3:::app-appbucket-1a2b3c4d5e6fInstanceId i-0abcd1234ef567890InstanceIp 54.226.18.203DbEndpoint app-db.cxy1234abcd.us-east-1.rds.amazonaws.comPolicyArn arn:aws:s3:::app-appbucket-1a2b3c4d5e6f/*ApiUrl https://app-alb-1234567890.us-east-1.elb.amazonaws.com/prod
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.
Resources:SubnetA:Type: AWS::EC2::SubnetProperties:VpcId: !Ref VpcCidrBlock: 10.0.0.0/24AvailabilityZone: !Select [0, !GetAZs ""] # "" = the Availability Zones of THIS regionAppBucket:Type: AWS::S3::BucketProperties:BucketEncryption: !If # NoValue drops the whole property when UseKms is false- UseKms- ServerSideEncryptionConfiguration:- ServerSideEncryptionByDefault:SSEAlgorithm: aws:kms # SSE = server-side encryption, KMS = Key Management ServiceKMSMasterKeyID: !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.
# INVALID: two short (!) tags on ONE node -> YAML parse errorAvailabilityZone: !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 listFirstCidr: !Select [0, !Split [",", !Ref AllowedCidrs]]# The same rule bites cross-stack imports:# VpcId: !ImportValue !Sub "${NetworkStack}-VpcId" <- INVALIDVpcId: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.
cfn-lint app.yaml network.yamlecho "exit $?"
exit 0
Resource field to !Ref AppBucket. The stack rolls back on deploy. What went wrong?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.