AWS Certified CloudOps Engineer - Associate

CloudFormation Fundamentals

How a CloudFormation template describes infrastructure: the template sections, parameters with real guardrails, intrinsic functions, the dependency graph CloudFormation builds for you, and the resource attributes that decide what survives a delete.

Intermediate 30 minutes 7 Learning Objectives
  1. Distinguish a template, a stack, a logical ID, and a physical ID
  2. Identify the template sections and explain which one is required
  3. Choose the right parameter type and constraints to catch bad input before any resource is created
  4. Select the correct intrinsic function for a given reference, and explain why Ref and Fn::GetAtt are not interchangeable
  5. Predict the order in which CloudFormation creates resources, and know when DependsOn is required
  6. Compare Fn::ImportValue, Fn::GetStackOutput, and nested stack outputs for sharing values between stacks
  7. Keep credentials out of a template using dynamic references, and state what NoEcho does not protect

A team built staging by hand: a VPC, two subnets, a security group, a load balancer, an Auto Scaling group, and an RDS instance. It took an afternoon of clicking. Now production needs the same thing, in a second Region, and nobody can reconstruct the exact security group rules from memory. Somebody opens the console side by side with staging and starts copying.

That is the problem CloudFormation removes. You describe the infrastructure once, in a file, and AWS builds it. Build it again next week in another account and you get the same result, because the description is the source of truth instead of somebody's memory.

Template, stack, and the two kinds of ID

Three words carry most of the meaning, and mixing them up makes every later concept harder.

A template is the file. JSON or YAML, stored in Git, reviewed like code, doing nothing on its own.

A stack is what you get when CloudFormation processes that template: a live collection of AWS resources managed as one unit. Update the stack and CloudFormation works out which resources need to change. Delete the stack and, by default, everything in it goes.

Inside the template, every resource has a logical ID, the name you choose:

Resources:
  WebSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Allow HTTPS from the load balancer
      VpcId: !Ref AppVpc

WebSecurityGroup is the logical ID. It exists only inside this template and this stack. When CloudFormation creates the group, AWS assigns it a physical ID such as sg-0a1b2c3d4e5f. The logical ID is how you refer to the resource while authoring; the physical ID is what actually exists in the account.

That distinction becomes load-bearing later. Rename a logical ID and CloudFormation does not see a rename, it sees one resource deleted and a different resource created. The physical ID is also what changes when a resource is replaced during an update, which is the difference between a harmless update and an outage.

One more framing that pays off throughout this topic: a template is declarative. You state the end state, not the steps. You never write "create the VPC, wait, then create the subnet." You state that the subnet belongs to the VPC, and CloudFormation figures out the order.

The template sections

A template has ten possible top-level sections. Exactly one is required.

SectionPurpose
ResourcesRequired. The AWS resources to create, each with a logical ID, a Type, and Properties
ParametersValues supplied at create or update time, so one template serves many environments
MappingsA static lookup table read with Fn::FindInMap, typically keyed by Region or environment
ConditionsNamed boolean expressions that decide whether a resource or property is included
OutputsValues returned after the stack is built, optionally exported for other stacks
TransformMacros to run over the template, including AWS::Serverless (SAM) and AWS::LanguageExtensions
MetadataArbitrary extra data about the template, including console UI hints
RulesValidates a parameter or a combination of parameters before provisioning
AWSTemplateFormatVersionThe template format version, 2010-09-09
DescriptionA text description of the template

A template that only declares Resources is valid. Everything else earns its place by making the template reusable, safer, or easier to read.

Section order in the file does not matter to CloudFormation, and neither does the order of resources inside Resources. That surprises people who expect a script.

Parameters: input with guardrails

A hardcoded template is a template you can only use once. Parameters are how one file serves dev, staging, and production.

Parameters:
  EnvironmentName:
    Type: String
    AllowedValues: [dev, staging, prod]
    Description: Which environment this stack represents

  AppVpcId:
    Type: AWS::EC2::VPC::Id
    Description: The VPC to deploy into

  LatestAmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2

Three different parameter types are doing three different jobs there, and the difference matters more than the syntax suggests.

EnvironmentName is a plain String locked down by AllowedValues. Constraints (AllowedValues, AllowedPattern, MinLength, MaxLength, MinValue, MaxValue) are checked before any resource is touched, so a typo fails in seconds instead of failing halfway through a 12-minute deployment. Add ConstraintDescription and the error message says "must be dev, staging, or prod" instead of dumping a regular expression at the user.

AppVpcId uses an AWS-specific parameter type. CloudFormation validates that the VPC actually exists in this account and Region, and the console shows a dropdown instead of a text box. There are types for availability zones, AMI IDs, instance IDs, key pairs, security groups, subnets, volumes, VPCs, and Route 53 hosted zones, plus a List<> form of each.

LatestAmiId uses an SSM parameter type. You supply a Parameter Store key, and CloudFormation fetches the current value at create or update time. That one line replaces the entire Region-to-AMI mapping table that older templates carried, and it is the reason the AWS public parameter /aws/service/ami-amazon-latest/... exists. Note what "current" means here: the value is resolved when the stack operation runs, so a stack created last month still holds last month's AMI ID until you update it.

The basic types are String, Number, List<Number>, and CommaDelimitedList. A template can declare up to 200 parameters.

The misconception worth killing now: parameters are not a secrets mechanism. NoEcho: true masks a parameter with asterisks in describe-stacks and describe-stack-events, which is useful, but it is a display filter and nothing more. It does not mask the value in the Outputs section, in the Metadata section, or in a resource's Metadata attribute, and the value still travels through your API call and your CI logs. Secrets belong in a dynamic reference, covered later in this lesson.

Intrinsic functions

A template has to refer to things that do not exist yet. The instance needs the security group's ID, but the security group has no ID until CloudFormation creates it. Intrinsic functions are how you write "whatever that turns out to be."

Ref returns a resource's primary identifier, or a parameter's value.

SubnetId: !Ref PrivateSubnetA     # returns subnet-0abc123
InstanceType: !Ref InstanceType   # returns the parameter value

What Ref returns is resource-specific and documented per type. For AWS::EC2::Instance it is the instance ID. For AWS::S3::Bucket it is the bucket name. For AWS::IAM::Role it is the role name, not the ARN, which is a very common one-line failure.

Fn::GetAtt returns a named attribute instead of the identifier.

RoleArn: !GetAtt AppRole.Arn
DnsName: !GetAtt AppLoadBalancer.DNSName

Read the boundary between them once and you stop guessing: Ref gives you the one identifier the resource type nominates; GetAtt gives you any other published attribute, by name. When a stem asks for an ARN and the resource's Ref returns a name, the answer is GetAtt.

Fn::Sub does string substitution, and it is the readable replacement for nested Fn::Join calls.

BucketName: !Sub "${EnvironmentName}-app-logs-${AWS::AccountId}"
Arn: !Sub "arn:${AWS::Partition}:s3:::${LogBucket}/*"

Those ${AWS::...} values are pseudo parameters: values CloudFormation supplies without you declaring them. AWS::Region, AWS::AccountId, AWS::StackName, AWS::StackId, AWS::Partition, AWS::URLSuffix, and AWS::NoValue. Using them instead of hardcoded values is what makes a template portable across accounts, Regions, and partitions such as GovCloud.

The rest of the working set, briefly:

FunctionUse it for
Fn::FindInMapRead a value out of the Mappings section
Fn::ImportValueRead a value exported by another stack in the same account and Region
Fn::GetStackOutputRead any stack output, including cross-Region and cross-account
Fn::Join / Fn::SplitBuild a string from parts, or break one apart
Fn::SelectPick an item from a list by index
Fn::GetAZsGet the availability zones of a Region as a list
Fn::Base64Encode user data
Fn::CidrCarve subnet CIDR blocks out of a VPC CIDR
Fn::If, Fn::Equals, Fn::Not, Fn::And, Fn::OrCondition logic
Fn::ForEach, Fn::Length, Fn::ToJsonStringLoops and helpers, available only with the AWS::LanguageExtensions transform

Intrinsic functions are not usable everywhere. You can use them in resource properties, outputs, metadata attributes, update policy attributes, and conditions. You cannot use them in the Parameters section, which is why a parameter default can never be computed.

The dependency graph you did not have to write

Here is where declarative stops being an abstract word.

Resources:
  AppVpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16

  WebSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Web tier
      VpcId: !Ref AppVpc

  WebInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref LatestAmiId
      SecurityGroupIds:
        - !Ref WebSecurityGroup

Nothing in that template states an order. But WebSecurityGroup refers to AppVpc, and WebInstance refers to WebSecurityGroup, so CloudFormation builds a directed graph out of those references and creates resources in an order that satisfies it: VPC, then security group, then instance. Resources with no edge between them are created in parallel, which is why a 40-resource stack does not take 40 times as long as a 1-resource stack.

Deletion runs the same graph in reverse. That is why you cannot delete a VPC stack while an instance from it still exists, and why an unexpected delete order usually means an unexpected reference.

Sometimes two resources must be ordered even though neither refers to the other. The classic case is an Elastic IP in a VPC: it needs the internet gateway attached before it can be allocated, but nothing in its properties mentions the attachment. For that, and only that, you declare the edge yourself:

  NatEip:
    Type: AWS::EC2::EIP
    DependsOn: AttachGateway
    Properties:
      Domain: vpc

Reach for DependsOn when a reference cannot express the ordering, not as a general safety measure. Sprinkling it everywhere serializes a stack that could have deployed in parallel, and it hides the real relationships from the next reader.

Conditions and Mappings: one template, many environments

Production needs a Multi-AZ database and a bastion host. Dev needs neither, and paying for both is silly. Two mechanisms handle this without forking the template.

Mappings are a static lookup table:

Mappings:
  EnvConfig:
    dev:
      InstanceType: t3.small
      MinSize: 1
    prod:
      InstanceType: m6i.large
      MinSize: 3

Resources:
  AppGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      MinSize: !FindInMap [EnvConfig, !Ref EnvironmentName, MinSize]

Conditions decide whether something exists at all:

Conditions:
  IsProduction: !Equals [!Ref EnvironmentName, prod]

Resources:
  BastionHost:
    Type: AWS::EC2::Instance
    Condition: IsProduction
    Properties:
      # ...

  AppDatabase:
    Type: AWS::RDS::DBInstance
    Properties:
      MultiAZ: !If [IsProduction, true, false]
      DBSnapshotIdentifier: !If [IsProduction, !Ref SnapshotId, !Ref "AWS::NoValue"]

Two details in that last block are worth stopping on. A Condition on a resource controls whether the resource is created; Fn::If inside a property controls the value of that property. And AWS::NoValue is the pseudo parameter that means "omit this property entirely," which is the only way to express an absent optional property conditionally.

The decision rule: mappings for values that differ, conditions for resources and properties that exist or do not.

Outputs, and sharing values between stacks

A stack that builds a VPC is not much use if nothing else can find the subnet IDs.

Outputs:
  AppVpcId:
    Description: VPC created by this stack
    Value: !Ref AppVpc
    Export:
      Name: !Sub "${AWS::StackName}-VpcId"

Value makes the output visible on the stack. Adding Export publishes it under a name that must be unique per account and Region, which is why prefixing with ${AWS::StackName} is the standard habit.

Another stack then consumes it:

  AppSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !ImportValue network-stack-VpcId

There are now three ways to pass a value from one stack to another, and they differ in strength and reach, not in syntax.

Fn::ImportValueFn::GetStackOutputNested stack outputs
Requires an Export on the producerYesNoNo
Same account, same RegionYesYesYes
Cross-RegionNoYes, with the Region parameterNo
Cross-accountNoYes, with a RoleArnNo
Reference strengthStrong: producer cannot be deleted while importedWeak: resolved at deploy time onlyOwned by the parent stack
Can the exported value change while in use?No, the export is lockedYes, silentlyManaged with the parent

That strength column is the whole trade. Fn::ImportValue gives you referential integrity: CloudFormation refuses to delete the exporting stack, and refuses to change or remove an export that another stack imports. That protection is genuinely valuable and genuinely annoying, because it means you cannot rename a subnet export without first unwiring every consumer. Fn::GetStackOutput gives up the protection to buy cross-Region and cross-account reach, and resolves the value fresh on each operation.

Keeping credentials out of the file

Never put a password in a template. That is easy to agree with and easy to violate accidentally, so use the mechanism built for it: dynamic references.

  AppDatabase:
    Type: AWS::RDS::DBInstance
    Properties:
      MasterUsername: '{{resolve:ssm:/app/db/username}}'
      MasterUserPassword: '{{resolve:secretsmanager:prod/app/db:SecretString:password}}'

Three patterns exist:

  • {{resolve:ssm:parameter-name:version}} for a plaintext Parameter Store value.
  • {{resolve:ssm-secure:parameter-name:version}} for a SecureString parameter.
  • {{resolve:secretsmanager:secret-id:SecretString:json-key:version-stage:version-id}} for a Secrets Manager secret.

CloudFormation resolves the reference when it needs the value and never stores it. Rotate the secret and the next stack operation picks up the new value with no template change.

The limits are small but real: a template can hold up to 60 dynamic references, they are not supported in AWS::CloudFormation::Init metadata or in EC2 UserData, and they are not resolved before a transform runs. A reference that ends in a backslash fails to resolve at all.

Resource attributes that change the blast radius

Resource attributes sit next to Type and Properties, and they change what CloudFormation does to a resource rather than what the resource is.

DeletionPolicy decides what happens when the resource leaves the stack.

ValueEffect
DeleteDelete the resource. The default for nearly every type.
RetainKeep the resource, remove it from CloudFormation's scope. It keeps billing.
RetainExceptOnCreateBehaves like Retain, except that a resource created by an operation that then rolls back is deleted.
SnapshotTake a snapshot, then delete. Supported on EBS volumes, RDS and DocumentDB and Neptune clusters and instances, ElastiCache clusters and replication groups, and Redshift clusters.

The exception that catches people: AWS::RDS::DBCluster, and any AWS::RDS::DBInstance that does not specify DBClusterIdentifier, default to Snapshot, not Delete. Delete such a stack and you get a surviving snapshot and a surviving bill.

UpdateReplacePolicy does the same job for a different event. DeletionPolicy covers a resource being removed from the stack; UpdateReplacePolicy covers a resource being replaced during an update, where CloudFormation creates a new physical resource and deletes the old one. Setting DeletionPolicy: Retain on a database and forgetting UpdateReplacePolicy: Retain leaves you protected against a stack delete and unprotected against the far more likely accidental replacement. Set both.

  AppDatabase:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Snapshot
    UpdateReplacePolicy: Snapshot
    Properties:
      # ...

Metadata attaches arbitrary data to a resource. It has one operational use worth remembering: CloudFormation does not treat a change to a deletion policy, update policy, condition, or output declaration as an update, so a template edit that touches only those produces "No updates to be performed." Changing any metadata value gives CloudFormation something to see, and the update proceeds.

CreationPolicy and UpdatePolicy, the two attributes that control waiting for signals and rolling Auto Scaling updates, belong with stack operations and appear in the next lesson.

Exam tips

  • Ref returns the resource's primary identifier; Fn::GetAtt returns any other attribute by name. If the stem needs an ARN and the type's Ref returns a name, the answer is GetAtt.
  • "The same template must work in any Region or account" points at pseudo parameters and AWS-specific or SSM parameter types, and away from hardcoded AMI IDs and account numbers.
  • Fn::ImportValue is same-account, same-Region only. A stem that mentions a second Region or a second account and asks for a stack output rules it out and points at Fn::GetStackOutput.
  • "Cannot delete stack, export in use by another stack" is expected behavior, not a bug. Remove the import first.
  • NoEcho masks describe output. Any answer that treats it as encryption, or as protection for values you copied into Outputs, is wrong.
  • Sensitive values in a template means dynamic references (ssm-secure, secretsmanager), not parameters.
  • The RDS Snapshot default is a favorite. Read "no DeletionPolicy specified" plus "RDS" as a snapshot, and the same words plus "S3 bucket" as a delete.
  • Set UpdateReplacePolicy alongside DeletionPolicy whenever data is involved. Questions about a database that vanished during a routine update are testing exactly this.
  • "No updates to be performed" after editing only a deletion policy, update policy, condition, or output is expected. The fix is to change a resource Metadata value.
  • DependsOn is for ordering that no reference expresses. The internet gateway attachment and an Elastic IP is the canonical pair.

The idea to carry forward is that a template is a description of a desired end state plus a graph of relationships, and CloudFormation derives everything else from it: the order of creation, the order of deletion, what can run in parallel, and what has to change when you edit one line.

Which raises the question this lesson deliberately left open. You now have a template that creates a stack correctly the first time. The next lesson is about the second time and every time after: how to change a running stack without breaking it, how to see what an update will do before it does it, and what to do when someone has been editing your resources in the console.