AWS DevOps

AWS CloudFormation Hands-On: Your First Stack, Change Sets & Intrinsic Functions

You built a VPC, a couple of subnets, a security group, an S3 bucket and an EC2 instance by clicking through the console last month. It works. Now a teammate needs the identical setup in a second Region, an auditor asks “what exactly is deployed and who changed it,” and you need to tear the whole thing down cleanly without leaving an orphaned NAT Gateway quietly billing you. Click-ops answers none of those questions, because the only record of what you built is the running resources themselves — no source of truth, no diff, no review, no repeatable teardown. Infrastructure as Code (IaC) replaces the clicks with a file, and on AWS the native, no-extra-charge IaC engine is AWS CloudFormation.

CloudFormation flips the model from imperative (“call RunInstances, then CreateBucket, then…”) to declarative: you write a template describing the desired end state — “a VPC with this CIDR, a bucket with this name, an instance of this type” — and hand it to CloudFormation, which works out the API calls, the ordering (a subnet needs its VPC first), the wait conditions, and — crucially — what to do when something fails. A deployed instance of a template is a stack: create the stack and the resources appear together; update the stack and CloudFormation computes the delta; delete the stack and everything it created is removed in reverse dependency order. One file, one unit, one lifecycle.

This article installs the whole mental model by using it. You will read a template section by section (Parameters, Mappings, Conditions, Resources, Outputs and the rest), learn the intrinsic functions that turn a static document into a dynamic one (Ref, Fn::GetAtt, Fn::Sub, Fn::FindInMap, Fn::ImportValue and friends), then deploy a real stack — a VPC, an S3 bucket and an EC2 instance — using change sets so you preview every change before it happens, make an update, detect drift when someone edits a resource by hand, and delete it cleanly. After the build comes the part you’ll bookmark: a symptom-to-fix troubleshooting playbook for CREATE_FAILED rollbacks, the Requires capabilities: [CAPABILITY_IAM] wall, circular dependencies, “export in use” delete failures and the rest. Read the prose once; keep the tables open when your own first stack won’t deploy.

What problem this solves

Without IaC, infrastructure lives only as running resources plus tribal memory. That breaks in production in specific, expensive ways. Reproducibility vanishes: standing up an identical environment in another Region or account is a manual re-click that drifts from the original in a dozen small ways. Review vanishes: nobody can diff “what will change” before it happens, so a mistyped instance type or an over-broad security group lands straight in prod. Teardown becomes guesswork: you delete the instance and the bucket but forget the Elastic IP and the security group, and the account slowly fills with orphans. Auditability vanishes: when something changes, there is no record of what, by whom, from what previous state.

CloudFormation solves each of those. The template is the source of truth and lives in git, so infrastructure gets pull-requested, reviewed and versioned like application code. Change sets produce a diff — “these 3 resources will be Modified, this 1 will be Replaced” — before you apply anything. A stack deletes as a unit, in reverse order, so teardown is one command and there are no orphans (unless you deliberately retain them). Every operation is recorded in CloudFormation stack events and in CloudTrail, so “who changed what, when” is answerable. And because CloudFormation understands the dependency graph, it creates a subnet only after its VPC exists and deletes them in the right order without you sequencing anything by hand.

Who hits the pain of skipping it: every engineer who has tried to “recreate prod in staging” and gotten it subtly wrong; every team that has suffered a change nobody reviewed; every account owner who has hunted orphaned resources on the monthly bill. CloudFormation is a CLF-C02 foundational concept, a SAA-C03 architecture staple (repeatable, multi-AZ, multi-account deployments), and a DVA-C02 developer skill (templates, change sets, sam/cdk which sit on top of it). Here is the whole field on one screen — the pieces you meet, and the classic trap on each:

Piece What it is You write / call it as The classic trap
Template A YAML/JSON file of desired state A file with up to 10 top-level sections Editing live resources instead of the template
Stack A deployed instance of a template create-stack / deploy Treating a stack’s resources as independently editable
Change set A preview of what an update will do create-change-setexecute-change-set Executing without reading the Replacement column
Parameter An input you pass at deploy time Parameters: + --parameters No AllowedValues, so bad input reaches resources
Mapping A static lookup table (e.g. region→AMI) Mappings: + Fn::FindInMap Hard-coding AMIs instead of an SSM parameter
Intrinsic function Runtime logic in the template Ref, !GetAtt, !Sub, … Nesting two short-form functions (invalid YAML)
Output / Export A value the stack publishes Outputs: + Export Exporting a value then being unable to change it
DeletionPolicy What happens to a resource on delete DeletionPolicy: Retain/Snapshot Default Delete wiping a database on teardown
Drift Live state diverging from the template detect-stack-drift Console edits silently making the template a lie
Capability Your ack that a template creates IAM --capabilities CAPABILITY_IAM Deploy rejected until you acknowledge it

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You need an AWS account with permission to create the resources in the lab (VPC, S3, EC2) and to call CloudFormation — a personal or sandbox account, never straight into production. Have the AWS CLI v2 configured (aws configure or aws sso login), a text editor, and optionally cfn-lint (pip install cfn-lint) and cfn-guard for local validation. You should be comfortable reading YAML and know roughly what a VPC, subnet, security group, S3 bucket and EC2 instance are — this article is about orchestrating them, not introducing them. If you need those primitives first, see Launch Your First EC2 Instance and Connect over SSH and Amazon S3 Buckets Fundamentals Hands-On. CloudFormation itself is free; you pay only for the resources it creates (this lab’s are free-tier-friendly and torn down at the end).

Where this sits: CloudFormation is the native AWS IaC engine and the layer several higher-level tools compile down to. Knowing where it fits stops you reaching for the wrong tool:

Tool What it is Language Compiles to When to reach for it
CloudFormation AWS-native declarative IaC YAML/JSON AWS API calls directly AWS-only estates; the substrate for the others
AWS SAM Serverless macro over CloudFormation YAML (SAM) CloudFormation (via Transform) Lambda/API/DynamoDB serverless apps
AWS CDK Imperative code that emits templates TS/Python/Java/Go CloudFormation templates You want loops, types and abstractions
Terraform Multi-cloud declarative IaC HCL Provider API calls Multi-cloud, or a large existing HCL estate
Pulumi Imperative multi-cloud IaC TS/Python/Go/… Provider API calls Code-first, multi-cloud

The two wave siblings extend this article directly: AWS CDK with TypeScript: Infrastructure Hands-On writes real code that synthesises the kind of template you read here, and AWS SAM: Serverless Application Model Hands-On is a CloudFormation Transform that expands into one. When a deploy fails, knowing which layer owns the failure is half the fix:

Layer What lives here Who “owns” it What it can cause
Your IAM identity Permission to call CloudFormation and the resources You AccessDenied on a resource mid-create
The template Sections, functions, resource properties You Validation errors, circular deps
The change set The computed diff (Add/Modify/Remove/Replace) CloudFormation Surprise replacement / no-op
The stack The live resources + their state machine CloudFormation ROLLBACK_COMPLETE, UPDATE_ROLLBACK_FAILED
The resource provider The service that actually creates it (EC2, S3…) AWS + you The real error string (quota, name taken)

Core concepts

Six ideas make everything later obvious. Read them once; the deep sections expand each.

A template is desired state; a stack is a running instance of it. The template says what should exist. CloudFormation compares that to what does exist for a given stack and makes the difference happen — creating, updating or deleting resources. You never issue RunInstances yourself; you declare an AWS::EC2::Instance and CloudFormation calls the API for you, in the right order.

CloudFormation builds a dependency graph and works it in order. When resource B references resource A (via Ref or Fn::GetAtt), CloudFormation infers that A must exist first — an implicit dependency. You rarely order anything by hand; you only add an explicit DependsOn for the cases the engine can’t infer (e.g. an IAM policy that must attach before an instance uses it). On delete, it walks the graph in reverse.

A stack has a state machine, and some states are terminal for updates. Every operation moves the stack through statuses like CREATE_IN_PROGRESS → CREATE_COMPLETE. If a create fails, the stack rolls back to ROLLBACK_COMPLETE — a dead end you can only delete, not update. If an update fails, it rolls back to UPDATE_ROLLBACK_COMPLETE (recoverable). Knowing which state you’re in tells you whether to fix-and-retry or delete-and-recreate.

Change sets are “diff before apply.” Instead of updating blind, you create a change set — CloudFormation computes exactly which resources will be Added, Modified, Removed or Replaced, and whether a modification needs a replacement (a new physical resource, old one deleted). You read that diff, then execute it. This is the single most important safety habit in CloudFormation.

Intrinsic functions turn a static file into a dynamic one. Ref pulls a value (a parameter’s value, a resource’s physical ID); Fn::GetAtt pulls a specific attribute (a bucket’s ARN, an instance’s private IP); Fn::Sub interpolates strings; Fn::FindInMap looks up a table; Fn::If branches on a condition; Fn::ImportValue reads another stack’s export. Combined with pseudo parameters (AWS::Region, AWS::AccountId, AWS::StackName) they let one template adapt to any account, Region or environment.

Policies on a resource decide its fate on change and delete. DeletionPolicy controls what happens to a resource when the stack is deleted (or the resource removed); UpdateReplacePolicy controls the old resource when an update replaces it. Set both to Retain/Snapshot on databases and you can never accidentally destroy data with a template change. Pin the vocabulary before the deep dive:

Term One-line definition Where you set/see it Why it matters early
Template The declarative desired-state file A .yaml/.json document Your source of truth
Stack A deployed instance of a template create-stack, console The lifecycle unit
Logical ID A resource’s name in the template The key under Resources: How functions reference it
Physical ID The real AWS ID it maps to i-0abc…, a bucket name What Ref usually returns
Change set A previewed diff of an update create-change-set Preview before apply
Intrinsic function Runtime logic (Ref, !GetAtt…) Anywhere a value is needed Dynamic templates
Pseudo parameter Built-in value (AWS::Region…) Used like a parameter Account/Region portability
Export / Import A published/consumed cross-stack value Export: / Fn::ImportValue Loose coupling between stacks
Drift Live state ≠ template state detect-stack-drift Detects out-of-band edits
Capability Ack that a template makes IAM/macros --capabilities Deploy gate for IAM/expand

Template anatomy: the ten sections

A CloudFormation template is a map with a fixed set of top-level sections. Only one — Resources — is required; the rest are optional and can appear in any order (though the order below reads best). Here is every section, what it does, and the gotcha:

Section Required? What it holds Gotcha
AWSTemplateFormatVersion No The template language version Only valid value is "2010-09-09"; omit and it defaults
Description No A human string describing the template Max 1024 bytes; must follow the format version
Metadata No Arbitrary data + AWS::CloudFormation::Interface (console UX) Not passed to resources; easy to bloat
Parameters No Inputs supplied at deploy time Max 200; no default = must supply every deploy
Rules No Cross-parameter validation rules Rarely used; validates parameter combinations
Mappings No Static lookup tables Max 200; keys must be literals (no functions)
Conditions No Named booleans for conditional creation Can only use condition functions + params/mappings
Transform No Macros to run (AWS::Serverless, AWS::LanguageExtensions) Needs CAPABILITY_AUTO_EXPAND
Resources Yes The AWS resources to manage The only required section; max 500 resources
Outputs No Values to return / export Max 200; an in-use export can’t be changed

A few structural limits are worth committing to memory because you hit them at scale: 500 resources, 200 parameters, 200 mappings, 200 outputs per template; a template body is capped at 51,200 bytes when passed inline and up to ~1 MB when referenced from an S3 URL; a stack name is at most 128 characters. When you outgrow 500 resources you split into nested stacks (covered later).

Resources — the only section that matters if you delete the rest

Every entry under Resources has a logical ID (the key), a Type (AWS::<service>::<resource>), and a Properties map. Beyond properties, a resource can carry resource attributes that aren’t properties of the underlying service but instructions to CloudFormation itself:

Attribute What it does Values When you need it
Type The resource type AWS::EC2::Instance, … Always (required)
Properties The service-specific config A map Almost always
DependsOn Force ordering CloudFormation can’t infer A logical ID or list Explicit sequencing (e.g. IAM before use)
DeletionPolicy Fate on stack/resource delete Delete (default) / Retain / Snapshot / RetainExceptOnCreate Protect stateful data
UpdateReplacePolicy Fate of the old resource on replacement Delete (default) / Retain / Snapshot Protect data during replacements
Condition Create this resource only if a condition is true A condition name Env-specific resources
Metadata Arbitrary data on the resource A map cfn-init, tooling hints
CreationPolicy Wait for a success signal before CREATE_COMPLETE Signal count + timeout ASG/instance readiness gates
UpdatePolicy How to roll updates (ASG, Lambda alias) Policy object Rolling ASG/AutoScalingRollingUpdate

The distinction that trips people: Properties configure the resource; the attributes configure CloudFormation’s behaviour toward it. DeletionPolicy: Retain is not an S3 setting — it’s an instruction to CloudFormation to not call DeleteBucket on teardown.

Parameters: typed, constrained inputs

Parameters are the inputs you pass at deploy time so one template serves many situations — different environments, sizes, or names. Each parameter has a Type and optional constraints; CloudFormation validates the input before touching any resource, so a bad value fails fast with a clear message instead of halfway through a create.

Parameter types

Type Accepts Example use
String Any string Environment name, a tag value
Number An integer or float (as a string) A port, a count
List<Number> Comma-separated numbers A list of ports
CommaDelimitedList Comma-separated strings A list of CIDRs / subnet names
AWS::EC2::KeyPair::KeyName An existing key pair name SSH key selection (validated to exist)
AWS::EC2::VPC::Id An existing VPC id Deploy into a chosen VPC
AWS::EC2::Subnet::Id An existing subnet id Place an instance/ENI
AWS::EC2::SecurityGroup::Id An existing SG id Attach to an instance
AWS::EC2::Image::Id An existing AMI id Choose an image (validated)
AWS::EC2::AvailabilityZone::Name An AZ name Pin an AZ
List<AWS::EC2::Subnet::Id> Multiple subnet ids ALB across subnets

The AWS-specific types are more than convenience: CloudFormation validates that the value exists (a wrong subnet id fails before deploy) and the console renders a dropdown of your real resources instead of a free-text box — far fewer fat-finger errors.

Parameter properties

Property Meaning Example
Type The parameter type (required) String
Default Value used if none supplied dev
AllowedValues An explicit whitelist [dev, staging, prod]
AllowedPattern A regex the value must match ^[a-z0-9-]+$
ConstraintDescription Friendly message on constraint failure must be dev, staging or prod
MinLength / MaxLength String length bounds 3 / 20
MinValue / MaxValue Numeric bounds 1 / 65535
NoEcho Mask the value in console, describe-stacks, logs true for secrets
Description Human help text Shown in the console form

NoEcho: true is important but not encryption — it masks the value in the console and API output (shows ****) so a password isn’t visible in describe-stacks, but the value is still passed to resources in plaintext and can appear in resource properties. For real secrets, reference SSM Parameter Store or Secrets Manager by ARN rather than passing the secret as a parameter.

SSM-parameter types: resolve live values at deploy

A powerful third family lets a parameter’s value be looked up from SSM Parameter Store at deploy time instead of typed in. This is the modern way to always get the latest AMI without hard-coding an id per Region:

SSM parameter type Resolves to Classic use
AWS::SSM::Parameter::Value<String> The string value of an SSM parameter A config value stored centrally
AWS::SSM::Parameter::Value<List<String>> A StringList SSM parameter A list of subnets/CIDRs
AWS::SSM::Parameter::Value<AWS::EC2::Image::Id> An AMI id from a public/SSM parameter Latest AL2023/Windows AMI
AWS::SSM::Parameter::Value<AWS::EC2::KeyPair::KeyName> A key pair name Centrally-managed key selection

Passing LatestAmiId with a default of /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 means CloudFormation asks SSM for the current AL2023 AMI id at deploy time — so you never ship a stale, hard-coded AMI and the same template works in every Region. You will use exactly this in the lab.

Mappings and Conditions: one template, many environments

Mappings + Fn::FindInMap

A Mapping is a static, two-level lookup table baked into the template. You retrieve a value with Fn::FindInMap [MapName, TopLevelKey, SecondLevelKey]. Because it’s static, keys must be literal strings (you can’t compute a mapping’s contents with functions, though the keys you look up with can be dynamic).

Aspect Detail Gotcha
Shape MapName → TopKey → { SecondKey: value } Exactly two levels — no more, no less
Lookup !FindInMap [MapName, TopKey, SecondKey] A missing key fails the whole deploy
Dynamic top key !FindInMap [EnvConfig, !Ref Env, InstanceType] The key can be a Ref; the table cannot
Common use region→AMI, env→sizing, account→settings SSM parameter types often beat region→AMI now
Limits 200 mappings/template; literal values only No intrinsic functions inside the map body

The historically dominant use — a RegionMap of region→AMI id — is increasingly replaced by the SSM-parameter type above (which self-updates), but mappings remain the clean way to encode env→sizing (dev gets t3.micro, prod gets t3.medium) with no external dependency.

Conditions + Fn::If

A Condition is a named boolean, defined in the Conditions section using condition functions, then referenced from a resource’s Condition attribute, from a property (via Fn::If), or from an output. It’s how one template creates a NAT Gateway in prod but not in dev, or turns on versioning only for production data.

Condition function Form Returns
Fn::Equals !Equals [a, b] true if a == b
Fn::And !And [c1, c2, …] true if all true (2–10 conditions)
Fn::Or !Or [c1, c2, …] true if any true (2–10 conditions)
Fn::Not !Not [c] the negation
Fn::If !If [CondName, valueIfTrue, valueIfFalse] one of two values (used in Resources/Outputs)
AWS::NoValue !Ref AWS::NoValue removes the property entirely (as a !If branch)

The Fn::If … AWS::NoValue pattern is the idiom for “include this property only in prod”: VersioningConfiguration: !If [IsProd, {Status: Enabled}, !Ref AWS::NoValue]. Note Conditions can only reference parameters, mappings and other conditions — never a resource’s runtime attribute (a resource doesn’t exist yet when conditions are evaluated).

Outputs and cross-stack exports

The Outputs section publishes values out of a stack — an instance id to print, a VPC id for another stack to consume, an endpoint URL for a human. Each output has a Value (required) and optional Description, Condition, and — the powerful one — Export:

Output field Meaning Note
Value The value to return (required) Usually a Ref or !GetAtt
Description Human text Shown in console/describe-stacks
Condition Only emit if this condition is true Optional
Export.Name Publish under a cross-account/Region-unique name Enables Fn::ImportValue

Exports are how independent stacks share values without tight coupling: a network stack exports its VPC id as network-VpcId, and an app stack imports it with Fn::ImportValue: network-VpcId. Three rules govern exports, and each is a troubleshooting row later:

Rule Consequence
Export names are unique per account per Region Two stacks can’t both export VpcId in the same Region
You can’t delete a stack whose export is imported The importing stack locks it
You can’t change/remove an output that’s imported The value is frozen while consumed

That last rule is the sharp edge: once network-VpcId is imported anywhere, you cannot modify that output until every importer stops importing it. Exports are a contract — design them deliberately, because they’re hard to change once consumed.

YAML vs JSON

CloudFormation accepts both YAML and JSON for the same template. They’re interchangeable in capability, but not in ergonomics:

Dimension YAML JSON
Comments Yes (#) No
Short-form functions Yes (!Ref, !GetAtt) No (full { "Ref": … } only)
Multi-line strings Clean (| block scalars for UserData) Escaped \n soup
Verbosity Lower (no braces/quotes everywhere) Higher
Whitespace sensitivity Significant (indent errors bite) Insensitive
Tooling/merge Occasionally trips on tabs Universally parseable

Write YAML. Comments, the !Ref short forms, and block scalars for UserData make it dramatically more readable, and every example here is YAML. The one YAML trap worth pre-empting: you cannot nest two short-form functions on the same node!Base64 !Sub "…" is invalid YAML (two tags on one node). Use the full form for the outer one: Fn::Base64: !Sub "…". You’ll see exactly that in the lab’s UserData.

Intrinsic functions and pseudo parameters

Intrinsic functions are CloudFormation’s small runtime language — they resolve at deploy time to real values. Here is the working set, with the short form and what each returns:

Function Short form Purpose Example
Ref !Ref X A param’s value, or a resource’s default (usually physical ID) !Ref AppVpcvpc-0abc…
Fn::GetAtt !GetAtt X.Attr A specific resource attribute !GetAtt AppBucket.Arn
Fn::Sub !Sub "…${X}…" String interpolation of refs/attrs !Sub "${Env}-app"
Fn::Join !Join [d, [a,b]] Join a list with a delimiter !Join ["-", [a, b]]
Fn::Split !Split [d, s] Split a string into a list !Split [",", !Ref List]
Fn::Select !Select [i, list] Pick an element by index !Select [0, !GetAZs ""]
Fn::FindInMap !FindInMap [M, k1, k2] Look up a mapping value !FindInMap [EnvConfig, dev, InstanceType]
Fn::GetAZs !GetAZs region List of AZs in a Region !GetAZs "" (current Region)
Fn::If !If [c, t, f] Conditional value !If [IsProd, Enabled, Suspended]
Fn::Equals !Equals [a, b] Boolean equality (in Conditions) !Equals [!Ref Env, prod]
Fn::ImportValue !ImportValue name Read another stack’s export !ImportValue network-VpcId
Fn::Base64 !Base64 s Base64-encode (UserData) Fn::Base64: !Sub "…"
Fn::Cidr !Cidr [block, count, bits] Carve subnet CIDRs from a block !Cidr [10.0.0.0/16, 4, 8]
Fn::GetAtt (nested) Attribute of a nested stack output !GetAtt Net.Outputs.VpcId
Fn::ToJsonString Serialize to JSON (LanguageExtensions) needs Transform
Fn::Length Length of a list (LanguageExtensions) needs Transform

The two you’ll reach for constantly are Ref and Fn::GetAtt, and the difference matters: Ref returns a resource’s default value (usually its physical ID), while GetAtt returns a named attribute. What Ref returns is resource-specific and a frequent surprise:

Resource Ref returns Useful Fn::GetAtt
A Parameter The parameter’s value
AWS::EC2::Instance Instance id (i-…) .PrivateIp, .PublicIp, .PublicDnsName, .AvailabilityZone
AWS::S3::Bucket Bucket name .Arn, .DomainName, .RegionalDomainName, .WebsiteURL
AWS::EC2::VPC VPC id .CidrBlock, .DefaultSecurityGroup
AWS::EC2::Subnet Subnet id .AvailabilityZone, .VpcId
AWS::IAM::Role Role name .Arn, .RoleId
AWS::SNS::Topic Topic ARN .TopicName
AWS::SQS::Queue Queue URL .Arn, .QueueName
AWS::DynamoDB::Table Table name .Arn, .StreamArn

Notice the inconsistency that catches everyone: Ref on an SNS topic returns an ARN, on an SQS queue returns a URL, on an S3 bucket returns a name, on an IAM role returns a name (you need !GetAtt Role.Arn for the ARN). When a downstream property “isn’t the value you expected,” this table is why — check the per-resource Ref return before assuming a bug.

Fn::Sub vs Fn::Join

Both build strings; Fn::Sub is almost always cleaner:

Fn::Sub Fn::Join
Form !Sub "arn:${AWS::Partition}:s3:::${Bucket}" !Join [":", ["arn", "aws", "s3", …]]
Readability High (inline ${}) Low (positional list)
Variable map Optional 2nd arg for locals N/A
Escaping a literal ${} ${!Literal} N/A
Best for Almost everything Joining a dynamic list (e.g. !Split output)

Pseudo parameters

Pseudo parameters are values CloudFormation provides for free — you Ref them like a parameter but never define them:

Pseudo parameter Resolves to Typical use
AWS::Region The stack’s Region (ap-south-1) Region-portable names, !GetAZs
AWS::AccountId The 12-digit account id Globally-unique bucket names, ARNs
AWS::StackName The stack’s name Prefixing exports/tag values
AWS::StackId The full stack ARN Unique correlation
AWS::Partition aws / aws-cn / aws-us-gov Partition-correct ARNs
AWS::URLSuffix amazonaws.com (varies by partition) Endpoint hostnames
AWS::NotificationARNs The stack’s SNS notification ARNs Wiring alarms
AWS::NoValue Removes a property (with Fn::If) Conditional properties

Using !Sub "${AWS::AccountId}" and ${AWS::Region} in a bucket name is how you get a globally-unique S3 bucket name that still works when you deploy the same template into another account or Region — exactly what the lab does.

Stacks and change sets

The stack state machine

A stack is always in one status. Reading it correctly tells you whether to retry, wait, or delete:

Status Meaning What to do
CREATE_IN_PROGRESS Creating resources Wait
CREATE_COMPLETE All resources created Done
CREATE_FAILED A resource failed to create Read events; usually rolls back next
ROLLBACK_IN_PROGRESS Undoing a failed create Wait
ROLLBACK_COMPLETE Create failed and rolled back Terminal — delete and recreate
ROLLBACK_FAILED The rollback itself failed delete-stack, or manual cleanup
REVIEW_IN_PROGRESS Change set created for a new stack, not yet executed Execute or delete the change set
UPDATE_IN_PROGRESS Applying an update Wait
UPDATE_COMPLETE_CLEANUP_IN_PROGRESS Update done; deleting old resources Wait (already succeeded)
UPDATE_COMPLETE Update applied Done
UPDATE_ROLLBACK_IN_PROGRESS Undoing a failed update Wait
UPDATE_ROLLBACK_COMPLETE Update rolled back to prior state Fix template, retry
UPDATE_ROLLBACK_FAILED The update rollback failed continue-update-rollback (± skip)
DELETE_IN_PROGRESS Deleting the stack Wait
DELETE_COMPLETE Stack and resources gone Done
DELETE_FAILED A resource wouldn’t delete Fix blocker; --retain-resources

The one that ambushes newcomers is ROLLBACK_COMPLETE: after a failed first create, the stack sits in this state, and any update-stack returns an error. There is no recovery — you must delete-stack and create again. (An update failure lands in the recoverable UPDATE_ROLLBACK_COMPLETE instead.) The full anatomy of failure and rollback — and how to keep resources for inspection — is the subject of the sibling AWS CloudFormation: Stack Failed & Rollback Troubleshooting.

Change sets: preview before you apply

A change set is CloudFormation’s dry run. You submit a proposed template (or parameters), CloudFormation computes the diff, and you inspect it before executing. It’s the difference between “hope this update is safe” and “know exactly what it does.”

Change field Values Meaning
Action Add / Modify / Remove / Import / Dynamic What happens to the resource
LogicalResourceId The template key Which resource
ResourceType AWS::S3::Bucket, … Its type
Replacement True / False / Conditional Whether a Modify recreates it
Scope Properties / Tags / Metadata / … What part changes
Details Per-property causes Why a replacement is needed

The column to never skim is Replacement. False means an in-place update (safe). True means CloudFormation will create a new resource and delete the old — a new physical ID, and for stateful resources, potential data loss. Conditional means it might replace depending on values known only at execution. Reading this before executing is the habit that separates a calm CloudFormation operator from one who deletes a database by editing a name. Two change-set types exist:

--change-set-type Use Resulting pre-execute status
CREATE Preview a brand-new stack Stack shows REVIEW_IN_PROGRESS
UPDATE (default) Preview changes to an existing stack Stack stays in its current status

Dependencies and deploy order

CloudFormation figures out order from references — you mostly don’t sequence anything. But knowing the three mechanisms saves you from both under- and over-specifying:

Mechanism How it works When to use
Implicit (Ref/GetAtt) Referencing B from A makes A wait for B The default — 95% of ordering
Explicit (DependsOn) Force A to wait for B with no reference IAM policy before the role’s user acts; ordering side effects
None Independent resources create in parallel Faster deploys; the default when nothing references

Two failure shapes come from dependencies. A missing implicit dependency is rare (you usually do reference what you need). A circular dependency is common when refactoring: resource A references B and B references A, so CloudFormation can’t order them and fails with Circular dependency between resources. DependsOn does not fix a true cycle (it makes it worse) — you break the cycle by restructuring, e.g. moving a security-group rule out of the group into a separate AWS::EC2::SecurityGroupIngress resource so the two groups don’t reference each other directly.

Update behaviours: no-interruption, some-interruption, replacement

When you change a resource property, CloudFormation applies one of three update behaviours — and which one depends on the specific property, documented per-property in the AWS resource reference:

Behaviour What happens Physical ID Risk
No interruption Updated in place, no downtime Unchanged Low
Some interruption Updated with brief disruption (e.g. reboot) Unchanged Medium (short downtime)
Replacement New resource created, old deleted Changes High (data loss on stateful)

Concrete examples make it stick — the same “change a property” action ranges from harmless to destructive:

Change Resource Behaviour Note
Add/change a Tag Most resources No interruption Safe
Change InstanceType AWS::EC2::Instance Some interruption Stop/start; same instance id
Change ImageId (AMI) AWS::EC2::Instance Replacement New instance; old terminated
Change UserData AWS::EC2::Instance Replacement New instance (unless you signal otherwise)
Change BucketName AWS::S3::Bucket Replacement New bucket; old deleted (must be empty)
Change AllocatedStorage (grow) AWS::RDS::DBInstance Some interruption Online in many cases
Change DBInstanceIdentifier AWS::RDS::DBInstance Replacement New DB — data loss without a snapshot
Change SG SecurityGroupIngress AWS::EC2::SecurityGroup No interruption Rules updated live
Change AvailabilityZone AWS::EC2::Subnet Replacement AZ is immutable

The rule: the change set’s Replacement: True is your early warning. Any replacement on a stateful resource (RDS, EBS volume, S3 bucket with data) must be paired with a Snapshot/Retain policy or an explicit migration — never executed casually.

DeletionPolicy and UpdateReplacePolicy

These two attributes are your data-safety seatbelts, and they answer different questions:

Value DeletionPolicy UpdateReplacePolicy Supported on
Delete (default*) Deletes the resource Deletes the old resource All
Retain Keeps it (orphaned from the stack) Keeps the old one All
Snapshot Snapshots then deletes Snapshots then deletes RDS, EBS Volume, ElastiCache, Redshift, Neptune, etc.
RetainExceptOnCreate Retain — unless the resource was created by the failed op N/A All (delete/rollback only)

*Default is Delete for most resources, but note S3 buckets must be empty to delete, and some resources default differently. The professional pattern for anything stateful is to set both to Retain or Snapshot:

AppDatabase:
  Type: AWS::RDS::DBInstance
  DeletionPolicy: Snapshot          # snapshot on stack delete
  UpdateReplacePolicy: Snapshot     # snapshot if an update replaces it
  Properties: { ... }

Set only DeletionPolicy and a replacing update still destroys the old database (that’s governed by UpdateReplacePolicy). Forgetting the pairing is a classic and painful data-loss cause. RetainExceptOnCreate (added in 2023) solves the annoyance where Retain leaves orphaned resources behind after a failed create rollback — it retains on genuine deletes but still cleans up resources the failed create just made.

Drift detection

CloudFormation assumes it’s the only thing changing a stack’s resources. When someone edits a resource by hand — tweaks a security group in the console, changes a bucket policy with the CLI — the live state drifts from the template, and your template quietly becomes a lie. Drift detection finds those out-of-band changes.

Concept Values Meaning
Stack drift status DRIFTED / IN_SYNC / NOT_CHECKED / UNKNOWN Whole-stack summary
Resource drift status IN_SYNC / MODIFIED / DELETED / NOT_CHECKED Per resource
Detection is async Start → poll status → read drifts Not instant on big stacks
Coverage Most but not all resource types support drift NOT_CHECKED = unsupported
Property scope Compares actual vs expected properties Shows the exact differing property

The workflow is three calls: detect-stack-drift (returns a detection id) → describe-stack-drift-detection-status (poll until DETECTION_COMPLETE) → describe-stack-resource-drifts (read the MODIFIED/DELETED resources and the exact property diffs). Drift detection is read-only — it reports, it doesn’t fix. To reconcile, you either update the template to match reality (accept the change) or re-deploy to overwrite the drift (revert it). You’ll run this in the lab.

Nested stacks vs cross-stack exports

Real estates outgrow one template. Two composition patterns exist, and they solve different coupling problems:

Dimension Nested stacks Cross-stack exports
Mechanism AWS::CloudFormation::Stack with a TemplateURL Export in Outputs + Fn::ImportValue
Coupling Tight — parent owns children Loose — independent stacks
Lifecycle Children deploy/update/delete with the parent Each stack has its own lifecycle
Reuse model A component “class” instantiated many times A shared value (a VPC id) consumed once
Pass data in Parent → child via Parameters Not applicable (import by name)
Read data out !GetAtt Child.Outputs.X !ImportValue export-name
Update blast radius Whole tree can update together Changing an in-use export is blocked
Best for Reusable modules, breaking the 500-resource limit Long-lived shared infra (network, IAM)

Nested stacks are how you modularise and reuse: a vpc.yaml template becomes a component you instantiate from a parent, passing parameters in and reading Outputs out via !GetAtt NestedStack.Outputs.VpcId. They update as a unit and are ideal for “the same three-tier module in five apps.” Cross-stack exports are for loosely-coupled, independently-owned stacks — a foundational network stack exports its VPC/subnet ids, and many app stacks import them, each with its own release cadence. The trade-off is the rigidity you met earlier: an exported value is frozen while imported. Use nested stacks for reuse; use exports for stable, shared, rarely-changing contracts. The classic reference target for both is a layered app like the AWS Three-Tier Web Application Architecture, where a network stack, a data stack and an app stack compose cleanly.

StackSets: one template, many accounts and Regions

A stack lives in one account, one Region. StackSets deploy the same template as stack instances across many accounts and Regions from a single operation — the mechanism behind org-wide guardrails, baseline VPCs, and centralized logging. Two permission models exist:

Model Trust setup Targets Auto-deploy to new accounts
Self-managed You create AWSCloudFormationStackSetAdministrationRole (admin acct) + AWSCloudFormationStackSetExecutionRole (each target) Any account ids you specify No (add instances manually)
Service-managed Uses AWS Organizations + service-linked roles OUs (organizational units) Yes — new accounts in the OU get it automatically

Service-managed StackSets integrate with AWS Organizations so you target an OU and every account in it (now and future) receives the stack — the same foundation used by AWS Organizations & SCPs: Multi-Account Guardrails and AWS Control Tower Landing Zone & Account Factory. Rollout is governed by operation preferences so a bad template doesn’t hit every account at once:

Preference Controls Example
MaxConcurrentCount / MaxConcurrentPercentage How many accounts deploy at once 10% at a time
FailureToleranceCount / FailureTolerancePercentage How many failures before stopping Stop after 1 failure
RegionConcurrencyType SEQUENTIAL or PARALLEL across Regions Region-by-region for safety
RegionOrder The Region rollout order Canary Region first

StackSets are a topic in their own right; for a first stack you only need to know they exist and that they turn “deploy this everywhere” into one governed operation instead of N manual deploys.

Capabilities and safety controls

Capabilities

Some templates can do things powerful enough that CloudFormation makes you acknowledge them explicitly — a deliberate speed bump, not a bug:

Capability Required when the template… Error if missing
CAPABILITY_IAM Creates IAM resources (roles, policies) with generated names Requires capabilities: [CAPABILITY_IAM]
CAPABILITY_NAMED_IAM Creates IAM resources with custom names Requires capabilities: [CAPABILITY_NAMED_IAM]
CAPABILITY_AUTO_EXPAND Uses macros/Transforms (SAM, nested-change-set expansion) Requires capabilities: [CAPABILITY_AUTO_EXPAND]

You pass these on the create/update/change-set call: --capabilities CAPABILITY_IAM. NAMED_IAM is stricter because a custom-named role can collide across stacks/accounts, so AWS wants a louder acknowledgement. The permission model of what those IAM resources grant is exactly the territory of IAM Users, Groups, Roles & Policies Hands-On — capabilities are the gate; least-privilege is the discipline behind the gate.

Safety controls

Beyond capabilities, CloudFormation offers several guards you should know:

Control What it does Set via
Stack policy Deny updates/replacements to protected resources set-stack-policy (JSON)
Termination protection Block accidental delete-stack update-termination-protection --enable-...
Rollback triggers Roll back if a CloudWatch alarm fires during deploy --rollback-configuration
--disable-rollback / OnFailure Keep failed resources for inspection instead of rolling back create-stack --on-failure DO_NOTHING
DeletionPolicy: Retain Preserve a resource on delete Resource attribute
Service role (--role-arn) CloudFormation acts with a scoped role, not your identity --role-arn

A stack policy is a JSON document that, once set, denies update actions on matching resources unless you explicitly override — the seatbelt that stops an errant template edit from replacing the production database. Termination protection is the equivalent for deletes. Rollback triggers wire CloudWatch alarms into the deploy so a create/update that technically succeeds but breaks a health metric still rolls back. And --disable-rollback (or --on-failure DO_NOTHING) is the debugging switch you’ll wish you’d used — it leaves the failed resources in place so you can read the real error instead of watching everything vanish in a rollback.

Validation tooling

Catch template problems before CloudFormation does, in CI:

Tool Checks Depth
aws cloudformation validate-template JSON/YAML syntax + basic structure Shallow — not property validity
cfn-lint Resource properties, intrinsic-function usage, best practices Deep — the essential local linter
cfn-guard (Guard) Policy-as-code rules (“all buckets must be encrypted”) Compliance/governance
cdk synth / sam validate For CDK/SAM sources Tool-specific

The trap is trusting validate-template — it only confirms the template parses, not that InstanceTyp (typo) is a real property or that your !GetAtt names a real attribute. cfn-lint catches those, so run it locally and in CI; add cfn-guard when you need to enforce policy (encryption, no public buckets) rather than just catch typos.

Architecture at a glance

The diagram below is the exact flow you build in the lab, drawn left to right. On the left you author one template — its Parameters, Mappings, Conditions, Resources and Outputs — and lint it with cfn-lint/cfn-guard before anything touches AWS. You then ask CloudFormation for a change set: it computes exactly what will be Added, Modified, Removed or Replaced, and you read that diff (especially the Replacement column) before you execute. On execute, the engine resolves the dependency graph and creates the resources as a single stack — an App VPC, an S3 bucket and an EC2 instance whose type comes from a Fn::FindInMap and whose AMI comes from an SSM parameter. To the right, the stack is governed: a CREATE_FAILED triggers automatic rollback, and drift detection flags any resource later edited by hand. Finally the stack publishes Outputs, some Exported for other stacks to Fn::ImportValue.

The six numbered badges mark the six places a first stack bites, and the legend narrates each as symptom · confirm · fix — the same map as the troubleshooting playbook, drawn onto the architecture so you can see where each failure lives.

AWS CloudFormation first-stack lifecycle: on the left an author writes a YAML template with nine sections and validates it with cfn-lint and cfn-guard; CloudFormation produces a change set previewing Add, Modify, Remove and Replacement actions which the author reviews before executing with the required capabilities; the engine resolves the dependency graph and builds one stack containing an App VPC at 10.20.0.0/16, an S3 bucket with a DeletionPolicy, and a t3.micro EC2 instance sized by Fn::FindInMap; a governance zone shows automatic rollback on CREATE_FAILED and drift detection reporting IN_SYNC or MODIFIED; and an outputs zone exposes values via Ref and GetAtt and exports them for other stacks to import, with six numbered badges marking template/parameter validation, change-set replacement or no-op, the CAPABILITY_IAM requirement, update-behaviour and circular-dependency failures, CREATE_FAILED auto-rollback, and export-in-use lock.

Badge Failure class Lives at Playbook row
1 Template / parameter validation The lint + validate-template step rows 7, 15
2 Change set: replacement / no-op create-change-set preview rows 3, 8
3 Requires capabilities: [CAPABILITY_IAM] The execute/deploy call row 2
4 Update behaviour / circular dependency The resource graph rows 4, 8
5 CREATE_FAILED → auto-rollback The stack state machine rows 1, 10
6 Export in use — locked Cross-stack outputs rows 5, 6

Real-world scenario

Meadowlark Health, a health-tech startup in Bengaluru, ran its patient-portal infrastructure — a VPC, an ALB, an Auto Scaling group and an RDS database — entirely through the console. It worked until three things collided in one quarter, each a lesson from this article.

First, an auditor asked for reproducible environments. The compliance team needed staging to be provably identical to production, and nobody could prove it, because both were hand-built and had drifted apart over eighteen months. A platform engineer, Ananya, spent a week codifying the estate into CloudFormation templates — a network stack that exports its VPC and subnet ids, a data stack, and an app stack that imports them. Re-deploying the template into a fresh account produced a byte-for-byte identical environment, and the audit question became a git diff. The cross-stack exports gave her the loose coupling she wanted: the network stack rarely changes; the app stack ships daily.

Second, a careless edit nearly destroyed the database. An engineer, tidying naming conventions, changed DBInstanceIdentifier in the template and ran an update. Before executing, Ananya’s new rule — always create a change set first — surfaced the killer line: Replacement: True on the RDS instance. Executing it would have deleted the production database and created a new empty one. They cancelled, reverted the name change, and added DeletionPolicy: Snapshot and UpdateReplacePolicy: Snapshot to every stateful resource so a future slip would snapshot rather than vaporize. The change set preview, a habit that costs ten seconds, saved the company its patient data.

Third, someone edited a security group in the console during an incident and forgot to put it back. Two weeks later a template re-deploy behaved unexpectedly, and it took hours to realize the live SG no longer matched the template. Ananya wired a scheduled drift detection run into their pipeline; it now reports any MODIFIED resource within an hour, and the team either folds the change into the template or reverts it. The out-of-band edit that used to be invisible is now a dashboard row.

Ananya’s summary for the team wiki was the thesis of this article: “CloudFormation didn’t make us slower — it made us honest. The template is the truth, the change set is the safety check, the deletion policies are the seatbelt, and drift detection is the smoke alarm. We stopped clicking, and we stopped guessing.” Their environment-provisioning time dropped from a two-day manual checklist to a fifteen-minute deploy, and they have not had an unreviewed infrastructure change since.

Advantages and disadvantages

Advantages Disadvantages
Native to AWS — no extra tool, no state file to host AWS-only (no multi-cloud)
Change sets preview every change before apply YAML/JSON is verbose vs a real language (→ CDK)
Understands the dependency graph and rollback ROLLBACK_COMPLETE after a failed create is terminal
Drift detection surfaces out-of-band edits Drift is read-only and not all resources support it
DeletionPolicy/UpdateReplacePolicy protect data Easy to forget the pairing and lose data on replace
StackSets deploy across accounts/Regions Cross-stack exports are rigid once imported
Free (you pay only for resources) Slower feedback loop than local tools for iteration
Deep service coverage + managed by AWS New resource properties sometimes lag the API

When each side matters: CloudFormation is the obvious default for an AWS-only estate where you want the vendor-native engine, no state file to secure, and preview-before-apply built in. The friction shows up at the edges — multi-cloud (reach for Terraform), authoring ergonomics at scale (reach for CDK, which emits CloudFormation), and the specific sharp states (ROLLBACK_COMPLETE, export locks) you now know to expect. None are blockers; they’re the specific things to remember, each mapped to a troubleshooting row below.

Choose CloudFormation when… Look wider when…
Your estate is AWS-only You deploy across AWS + Azure/GCP (→ Terraform/Pulumi)
You want the AWS-native engine + no state hosting You want loops/types/abstractions in a real language (→ CDK)
You need change-set previews and drift built in Your team already runs a mature Terraform estate
You deploy org-wide with StackSets/Organizations You need a provider AWS doesn’t model well

Hands-on lab

You will build the diagram: author a template with a Parameter, a Mapping, Conditions, Outputs and three resources (a VPC, an S3 bucket and an EC2 instance), then deploy it through a change set, make an update (another change set), detect drift, and delete it. This lab uses ap-south-1 (Mumbai); pick one Region and stick to it.

⚠️ Cost note: Everything here is free-tier-friendly. The t3.micro instance is free-tier-eligible (or a few paise/hour beyond it), the VPC/subnet/SG are free, and the S3 bucket stores nothing. CloudFormation itself is free. The teardown at the end removes all of it. If you deploy EnvironmentName=prod, the mapping selects a t3.medium (not free) — keep it dev.

What you’ll create

Resource Purpose Cost at lab volume
AppVpc (AWS::EC2::VPC) The network, 10.20.0.0/16 Free
AppSubnet (AWS::EC2::Subnet) One subnet via !Select/!GetAZs Free
AppSg (AWS::EC2::SecurityGroup) Egress-only SG for the lab Free
AppBucket (AWS::S3::Bucket) Globally-unique bucket via !Sub Free (empty)
AppInstance (AWS::EC2::Instance) t3.micro sized by !FindInMap, AMI via SSM Free-tier eligible

Step 1 — Author the template

Save this as kv-first-stack.yaml. It exercises every concept above: a String parameter with AllowedValues, an SSM-parameter type for the AMI, a Mapping + Fn::FindInMap, a Condition + Fn::If, Ref/Fn::GetAtt/Fn::Sub/Fn::Select/Fn::GetAZs/Fn::Base64, pseudo parameters, a DeletionPolicy, and Outputs with an Export.

AWSTemplateFormatVersion: "2010-09-09"

Description: >
  KloudVin first stack — a minimal VPC, an S3 bucket and an EC2 instance,
  parameterised by environment, sized from a Mapping, with cross-stack Outputs.

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: { default: "Environment" }
        Parameters: [EnvironmentName, LatestAmiId]

Parameters:
  EnvironmentName:
    Type: String
    Default: dev
    AllowedValues: [dev, staging, prod]
    Description: Drives instance sizing (via a Mapping) and S3 versioning (via a Condition).

  LatestAmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
    Description: Resolved from SSM Parameter Store at deploy time  always the latest AL2023 AMI.

Mappings:
  EnvConfig:
    dev:     { InstanceType: t3.micro }
    staging: { InstanceType: t3.small }
    prod:    { InstanceType: t3.medium }

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

Resources:
  AppVpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.20.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub "${EnvironmentName}-kv-vpc"

  AppSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref AppVpc                      # implicit dependency on AppVpc
      CidrBlock: 10.20.1.0/24
      AvailabilityZone: !Select [0, !GetAZs ""]   # first AZ in this Region
      Tags:
        - Key: Name
          Value: !Sub "${EnvironmentName}-kv-subnet-a"

  AppSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: KloudVin lab SG  egress only
      VpcId: !Ref AppVpc
      SecurityGroupEgress:
        - IpProtocol: "-1"
          CidrIp: 0.0.0.0/0

  AppBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete            # lab: delete on teardown (bucket stays empty)
    UpdateReplacePolicy: Retain       # but keep the old one if an update ever replaces it
    Properties:
      BucketName: !Sub "${EnvironmentName}-kv-cfn-${AWS::AccountId}-${AWS::Region}"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      VersioningConfiguration:
        Status: !If [IsProd, Enabled, Suspended]   # versioning only in prod

  AppInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref LatestAmiId
      InstanceType: !FindInMap [EnvConfig, !Ref EnvironmentName, InstanceType]
      SubnetId: !Ref AppSubnet
      SecurityGroupIds: [!Ref AppSg]
      UserData:
        Fn::Base64: !Sub |          # full-form Fn::Base64 wrapping short-form !Sub
          #!/bin/bash
          echo "hello from ${EnvironmentName} in ${AWS::Region}" > /tmp/kv.txt
      Tags:
        - Key: Name
          Value: !Sub "${EnvironmentName}-kv-app"

Outputs:
  VpcId:
    Description: The VPC id, exported for app-tier stacks to import.
    Value: !Ref AppVpc
    Export:
      Name: !Sub "${AWS::StackName}-VpcId"
  BucketName:
    Description: The globally-unique bucket name.
    Value: !Ref AppBucket
  BucketArn:
    Description: The bucket ARN (via GetAtt).
    Value: !GetAtt AppBucket.Arn
  InstanceId:
    Value: !Ref AppInstance
  InstancePrivateIp:
    Description: The instance private IP (via GetAtt).
    Value: !GetAtt AppInstance.PrivateIp

Step 2 — Validate and lint

Catch problems before AWS does:

aws cloudformation validate-template \
  --template-body file://kv-first-stack.yaml --region ap-south-1
# Returns the Parameters and Description if the syntax is valid.

cfn-lint kv-first-stack.yaml   # deep check: properties, functions, best practices

Expected: validate-template echoes the parameters; cfn-lint prints nothing (clean). If cfn-lint flags a property, fix it now — this is the cheapest place to catch a typo.

Step 3 — Create the stack via a change set (preview, then execute)

Rather than create-stack, use a change set so you preview the create. For a new stack, use --change-set-type CREATE:

aws cloudformation create-change-set \
  --stack-name kv-first-stack \
  --change-set-name cs-create-1 \
  --change-set-type CREATE \
  --template-body file://kv-first-stack.yaml \
  --parameters ParameterKey=EnvironmentName,ParameterValue=dev \
  --region ap-south-1

aws cloudformation wait change-set-create-complete \
  --stack-name kv-first-stack --change-set-name cs-create-1 --region ap-south-1

Preview exactly what will be created (read this before executing):

aws cloudformation describe-change-set \
  --stack-name kv-first-stack --change-set-name cs-create-1 --region ap-south-1 \
  --query 'Changes[].ResourceChange.{Action:Action,Type:ResourceType,Id:LogicalResourceId,Replacement:Replacement}' \
  --output table

Expected: five rows, all Action=Add, Replacement=None (nothing to replace on a create). Now execute and wait:

aws cloudformation execute-change-set \
  --stack-name kv-first-stack --change-set-name cs-create-1 --region ap-south-1

aws cloudformation wait stack-create-complete \
  --stack-name kv-first-stack --region ap-south-1

If your template had created IAM resources, both the change set and execute would need --capabilities CAPABILITY_IAM. This template creates none, so no capability is required — but you’ll see that error in the playbook.

Step 4 — Read the Outputs

aws cloudformation describe-stacks --stack-name kv-first-stack --region ap-south-1 \
  --query 'Stacks[0].Outputs' --output table

Expected: VpcId (vpc-…), BucketName (dev-kv-cfn-<acct>-ap-south-1), BucketArn, InstanceId (i-…), InstancePrivateIp (10.20.1.x). Confirm the export landed:

aws cloudformation list-exports --region ap-south-1 \
  --query "Exports[?Name=='kv-first-stack-VpcId']"

Shortcut for later: aws cloudformation deploy --template-file kv-first-stack.yaml --stack-name kv-first-stack --parameter-overrides EnvironmentName=dev --region ap-south-1 does create-or-update via a change set in one command (add --capabilities if the template makes IAM). The explicit change-set flow above is what deploy does under the hood.

Step 5 — Make an update via a change set

Add default encryption to the bucket — a no-interruption modify. Insert this under AppBucket’s Properties:

      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256

Create an UPDATE change set (keep the parameter as-is with UsePreviousValue):

aws cloudformation create-change-set \
  --stack-name kv-first-stack --change-set-name cs-update-1 --change-set-type UPDATE \
  --template-body file://kv-first-stack.yaml \
  --parameters ParameterKey=EnvironmentName,UsePreviousValue=true \
  --region ap-south-1

aws cloudformation wait change-set-create-complete \
  --stack-name kv-first-stack --change-set-name cs-update-1 --region ap-south-1

aws cloudformation describe-change-set \
  --stack-name kv-first-stack --change-set-name cs-update-1 --region ap-south-1 \
  --query 'Changes[].ResourceChange.{Action:Action,Id:LogicalResourceId,Replacement:Replacement,Scope:Scope}' \
  --output table

Expected: one row — Action=Modify, Id=AppBucket, Replacement=False, Scope=["Properties"]. That Replacement=False is your green light. Execute:

aws cloudformation execute-change-set \
  --stack-name kv-first-stack --change-set-name cs-update-1 --region ap-south-1
aws cloudformation wait stack-update-complete --stack-name kv-first-stack --region ap-south-1

Step 6 — Detect drift after an out-of-band change

Simulate a console edit by tagging the bucket outside CloudFormation, then detect the drift:

BUCKET=$(aws cloudformation describe-stacks --stack-name kv-first-stack --region ap-south-1 \
  --query "Stacks[0].Outputs[?OutputKey=='BucketName'].OutputValue" --output text)

aws s3api put-bucket-tagging --bucket "$BUCKET" \
  --tagging 'TagSet=[{Key=out-of-band,Value=yes}]' --region ap-south-1

DRIFT_ID=$(aws cloudformation detect-stack-drift --stack-name kv-first-stack \
  --region ap-south-1 --query StackDriftDetectionId --output text)

aws cloudformation describe-stack-drift-detection-status \
  --stack-drift-detection-id "$DRIFT_ID" --region ap-south-1 \
  --query '{Status:DetectionStatus,Drift:StackDriftStatus}'
# { "Status": "DETECTION_COMPLETE", "Drift": "DRIFTED" }

aws cloudformation describe-stack-resource-drifts --stack-name kv-first-stack \
  --stack-resource-drift-status-filters MODIFIED --region ap-south-1 \
  --query 'StackResourceDrifts[].{Res:LogicalResourceId,Status:StackResourceDriftStatus}' \
  --output table

Expected: AppBucket shows MODIFIED. To reconcile, either add the tag to the template (accept the change) or re-run the update (revert it). Drift detection only reports — it never changes resources.

Step 7 — Tear down

Delete the whole stack in one command; CloudFormation removes the resources in reverse dependency order:

aws cloudformation delete-stack --stack-name kv-first-stack --region ap-south-1
aws cloudformation wait stack-delete-complete --stack-name kv-first-stack --region ap-south-1
# Verify it's gone (should error 'does not exist'):
aws cloudformation describe-stacks --stack-name kv-first-stack --region ap-south-1
Teardown step Command Why it matters
Delete the stack delete-stack + wait stack-delete-complete Removes VPC, subnet, SG, bucket, instance as a unit
Empty the bucket first (if you wrote objects) aws s3 rm s3://$BUCKET --recursive DeletionPolicy: Delete fails on a non-empty bucket
Confirm no exports remain aws cloudformation list-exports An in-use export would have blocked delete
Check the console CloudFormation → Stacks Stack should be absent, not DELETE_FAILED

Because the bucket stayed empty and its DeletionPolicy is Delete, the stack deletes cleanly with no orphans — the whole point of managing infrastructure as a stack.

Common mistakes & troubleshooting

This is the section you’ll return to. Match your symptom, run the confirm command, apply the fix.

# Symptom Root cause Confirm (exact command / console path) Fix
1 Stack in CREATE_FAILED then ROLLBACK_COMPLETE; won’t update A resource failed to create; the stack rolled back to a terminal state aws cloudformation describe-stack-events --stack-name S → read the first failure (list is newest-first) Fix the template; delete-stack and recreate (ROLLBACK_COMPLETE can’t be updated). Next time deploy --on-failure DO_NOTHING to keep resources for inspection — see the rollback sibling
2 Requires capabilities: [CAPABILITY_IAM] Template creates IAM roles/policies The error names the capability; NAMED_IAM if IAM resources have custom names Re-run create/change-set/deploy with --capabilities CAPABILITY_IAM (or CAPABILITY_NAMED_IAM)
3 No updates are to be performed. The submitted template equals the current stack state describe-change-set shows zero changes; you edited the wrong file/param Make an actual change; or the diff is live drift (CFN compares template↔template, not template↔reality)
4 Circular dependency between resources Two resources reference each other The create event names the cycle; read your Ref/GetAtt edges Break the cycle by restructuring (e.g. move an SG rule into a separate AWS::EC2::SecurityGroupIngress); DependsOn won’t fix a true cycle
5 Export X cannot be deleted as it is in use by stack Y You’re deleting/changing a stack whose export is imported aws cloudformation list-imports --export-name X Update/delete the importing stack(s) first, then the exporter
6 Update fails: can’t modify/remove an output The output is exported and imported elsewhere list-imports --export-name X shows consumers Stop the importers first; an in-use export is frozen
7 Parameter 'InstanceType' failed to satisfy constraint Value violates AllowedValues/AllowedPattern/bounds The error names the constraint; check the Parameters block Pass an allowed value; widen the constraint if legitimately too narrow
8 Update replaced a resource you didn’t expect (new id / data gone) You executed a change with Replacement: True Before executing: describe-change-set --query 'Changes[].ResourceChange.Replacement' Read the change set first; pair stateful resources with Snapshot/Retain policies
9 Nested stack fails; parent shows CREATE_FAILED A child stack failed to create Parent event points to the nested stack ARN → open that stack’s events Fix the child template; deploy --disable-rollback to keep the child for inspection
10 UPDATE_ROLLBACK_FAILED The rollback itself couldn’t complete (a resource wouldn’t revert) describe-stack-events names the stuck resource continue-update-rollback; if needed --resources-to-skip <LogicalId> to skip the stuck one
11 DELETE_FAILED on delete-stack A resource can’t be deleted (non-empty bucket, external dependency, retained) Events name the resource + reason (bucket not empty) Empty the bucket / clear the dependency; retry, or delete-stack --retain-resources <Id>
12 AccessDenied mid-create on a specific resource Your identity (or the service role) lacks that resource’s permission CloudTrail / the event names the action denied Grant the underlying permission (this is IAM, not a CFN bug); or pass a scoped --role-arn
13 Drift shows MODIFIED after a console edit Someone changed the resource out-of-band detect-stack-driftdescribe-stack-resource-drifts Reconcile: update the template to match, or re-deploy to revert
14 Template edit does nothing to UserData/AMI as expected Those changes force replacement, not in-place update describe-change-set shows Replacement: True on the instance Expected behaviour; accept the replacement or use a launch template/versioning strategy
15 Template body ... exceeds maximum Inline template over 51,200 bytes The submit error names the size limit Upload to S3 and use --template-url; or split into nested stacks
16 !Base64 !Sub "…" / short-form nesting parse error Two short-form intrinsic functions on one YAML node The YAML parser or cfn-lint flags it Use the full form for the outer function: Fn::Base64: !Sub "…"

The error / status reference

The strings CloudFormation emits, decoded:

Message / status Where you see it Meaning Fix
ROLLBACK_COMPLETE Stack status Failed create, rolled back — terminal Delete and recreate
UPDATE_ROLLBACK_COMPLETE Stack status Failed update, reverted (recoverable) Fix template; retry
UPDATE_ROLLBACK_FAILED Stack status The update rollback itself failed continue-update-rollback (± skip)
REVIEW_IN_PROGRESS Stack status A CREATE-type change set exists, unexecuted Execute or delete the change set
Requires capabilities: [CAPABILITY_IAM] Create/update IAM resources present Add --capabilities
No updates are to be performed. Update Template equals current state Change something real
Circular dependency between resources Create Resources reference each other Restructure to break the cycle
Export X cannot be deleted as it is in use Delete/update An import consumes the export Remove importers first
... already exists Create A physical name (bucket, role) is taken Use a unique/generated name
failed to satisfy constraint Validation Parameter violates its constraint Pass an allowed value
Resource is not in a state that can be updated Update Resource busy / prior op incomplete Wait; check events

The three nastiest, explained

ROLLBACK_COMPLETE after a failed create (rows 1, 10) confuses everyone once. A brand-new stack whose first create fails does not sit at CREATE_FAILED waiting for a fix — it rolls back and lands in ROLLBACK_COMPLETE, and every update-stack from there returns an error. The state is terminal: you must delete-stack and create again. The right habit is to read describe-stack-events (it’s newest-first, so scroll to the bottom for the first failure — the root cause), fix the template, and redeploy. When you’re debugging a create that keeps failing, deploy with --on-failure DO_NOTHING (or --disable-rollback) so the failed resources stay and you can inspect the real error instead of watching them vanish. The full anatomy is the sibling AWS CloudFormation: Stack Failed & Rollback Troubleshooting.

A surprise Replacement: True deletes data (row 8). CloudFormation will happily execute a change that recreates a resource — a new physical ID, the old one deleted — if you tell it to. Change the BucketName, the RDS DBInstanceIdentifier, a subnet’s AvailabilityZone, or an instance’s ImageId, and the change set will show Replacement: True. If you execute it on a stateful resource without a Snapshot/Retain policy, the data is gone. The fix is a discipline, not a setting: always describe-change-set and read the Replacement column before execute-change-set, and set DeletionPolicy and UpdateReplacePolicy to Snapshot/Retain on every database, volume and stateful store.

Cross-stack exports lock you in (rows 5, 6). The first time you Export a value and another stack Fn::ImportValues it, you’ve created a contract you can’t unilaterally change: you cannot delete the exporting stack, and you cannot even modify or remove that output, while any importer consumes it. aws cloudformation list-imports --export-name X tells you who’s holding the lock. Plan cross-stack exports as deliberate, stable interfaces (a VPC id that rarely changes), not as a convenient way to pass every value around — and when you must change one, update or remove the importers first, in order.

Best practices

Security notes

CloudFormation’s security surface is about what the template can do and who can run it:

Control What to do Why
Least-privilege deploy role Give CloudFormation a scoped --role-arn, not your admin identity A template can only create what the role allows — blast-radius control
Capabilities as a gate Require CAPABILITY_IAM/NAMED_IAM reviews in CI IAM-creating templates get extra scrutiny before deploy
NoEcho for sensitive parameters Mask secrets in console/describe-stacks output Prevents casual exposure (but is not encryption)
Reference secrets, don’t pass them Pull from SSM/Secrets Manager by ARN via dynamic references Secrets never sit in the template or parameter history
Stack policies on prod Deny update/replace on critical resources Stops an errant edit from replacing a database
Termination protection Enable on prod stacks Blocks accidental delete-stack
DeletionPolicy: Retain/Snapshot On all stateful resources Data survives teardown and replacement
Encrypt stateful resources in the template Set encryption properties (S3 SSE, RDS StorageEncrypted) Encryption is codified, not a manual afterthought
CloudTrail on CloudFormation + resources Audit CreateStack, ExecuteChangeSet, SetStackPolicy Full record of who changed infrastructure

Two things people get wrong first: passing a database password as a plain String parameter (it lands in the parameter history and resource properties — use a dynamic reference to Secrets Manager instead, {{resolve:secretsmanager:…}}), and deploying templates with their personal admin credentials (so the template can do anything they can — instead give CloudFormation a scoped service role and let least privilege bound what any template can create). The permission model behind all of this is the subject of IAM Policy Evaluation & Access-Denied Troubleshooting.

Cost & sizing

CloudFormation the service is free — there is no charge for stacks, change sets, drift detection or StackSets on AWS resource types. You pay only for the resources a stack creates, plus a small charge if you register and use third-party (non-AWS) resource types via the registry (billed per handler-second).

Cost driver How it’s charged Lever to pull
The resources Normal per-resource pricing (EC2, RDS, NAT…) Right-size in the template; delete stacks you don’t need
CloudFormation itself Free for AWS resource types
Third-party resource types Per handler operation-second (registry extensions) Only register extensions you use
Drift/StackSets Free (you pay for the resources deployed)
Leftover retained resources You keep paying for Retained resources after delete Track and clean up retained orphans
Free tier / figure Amount Note
CloudFormation operations Free Stacks, change sets, drift, StackSets
This lab’s t3.micro Free-tier eligible (750 hrs/mo, 12 mo) ~₹0 within free tier
This lab’s VPC/subnet/SG Free No hourly charge
This lab’s empty S3 bucket Free Storage billed per GB — it’s empty
Third-party extensions ~$0.0009 per handler-second Only if you use registry extensions

The sizing lesson is indirect but real: because CloudFormation makes teardown a single command, the biggest cost lever it gives you is no orphans — a stack deletes everything it made (unless you retained it), so you stop paying for the forgotten NAT Gateway and unattached EIP that click-ops leaves behind. The one cost trap unique to CloudFormation is DeletionPolicy: Retain: those resources survive the stack delete and keep billing, so track what you retain and clean it up deliberately.

Interview & exam questions

1. What is the difference between a template and a stack? A template is the declarative YAML/JSON file describing desired state; a stack is a deployed instance of that template — the live resources CloudFormation manages as one unit. One template can produce many stacks (dev, staging, prod). (CLF-C02, SAA-C03)

2. Why use a change set instead of updating directly? A change set is a preview — CloudFormation computes exactly which resources will be Added, Modified, Removed or Replaced (and whether a Modify needs a replacement) before you apply. It’s the safety check that prevents an unreviewed replacement from destroying a stateful resource. (DVA-C02, SAA-C03)

3. Ref vs Fn::GetAtt? Ref returns a resource’s default value (usually its physical ID — but an ARN for SNS, a URL for SQS, a name for S3/IAM); Fn::GetAtt returns a named attribute (!GetAtt Bucket.Arn, !GetAtt Instance.PrivateIp). Use GetAtt when you need a specific attribute rather than the default. (DVA-C02)

4. What does DeletionPolicy do, and how does it differ from UpdateReplacePolicy? DeletionPolicy controls a resource’s fate when the stack is deleted (or the resource removed): Delete/Retain/Snapshot. UpdateReplacePolicy controls the old resource when an update replaces it. Set both to Retain/Snapshot on stateful resources — one alone still loses data on a replacing update. (SAA-C03, DVA-C02)

5. A stack is in ROLLBACK_COMPLETE. What now? It’s a terminal state after a failed first create — you cannot update it. Read describe-stack-events for the first failure, fix the template, then delete and recreate the stack. (A failed update lands in the recoverable UPDATE_ROLLBACK_COMPLETE instead.) (DVA-C02)

6. When do you need CAPABILITY_IAM vs CAPABILITY_NAMED_IAM? CAPABILITY_IAM when a template creates IAM resources with generated names; CAPABILITY_NAMED_IAM when those IAM resources have custom names (stricter, because custom names can collide). Macros/Transforms need CAPABILITY_AUTO_EXPAND. (SAA-C03, SCS)

7. Nested stacks vs cross-stack exports — when each? Nested stacks (AWS::CloudFormation::Stack) for tightly-coupled reuse — a module instantiated from a parent, updated as a unit, read via !GetAtt Child.Outputs.X. Cross-stack exports (Export/Fn::ImportValue) for loosely-coupled, independently-owned stacks sharing a stable value; but an in-use export is frozen. (SAA-C03)

8. How do you always get the latest AMI without hard-coding it? Use an SSM-parameter type parameter, AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>, defaulted to a public SSM path (e.g. /aws/service/ami-amazon-linux-latest/al2023-...). CloudFormation resolves the current AMI id at deploy time, keeping the template Region-portable. (DVA-C02, SAA-C03)

9. What is drift detection and what are its limits? It compares a stack’s live resource properties against the template’s expected state and reports each resource as IN_SYNC/MODIFIED/DELETED/NOT_CHECKED. It’s read-only (reports, doesn’t fix) and not all resource types support it. Reconcile by updating the template or re-deploying. (SOA-C02)

10. What are StackSets and the two permission models? StackSets deploy one template across many accounts/Regions as stack instances. Self-managed uses admin + execution roles you create for specific account ids; service-managed integrates with AWS Organizations, targets OUs, and auto-deploys to new accounts in the OU. (SAA-C03, SAP)

11. How does CloudFormation decide resource creation order? From references: when resource B uses Ref/Fn::GetAtt on A, CloudFormation infers an implicit dependency (A first). Add explicit DependsOn only where there’s no reference. A circular dependency (A↔B) fails — you break it by restructuring, not with DependsOn. (DVA-C02)

12. How do you safely handle a template change that forces replacement of a database? Recognise it in the change set (Replacement: True), ensure DeletionPolicy and UpdateReplacePolicy are Snapshot, and plan the migration — or avoid the replacing change (many properties are immutable, so changing them recreates the resource). Never execute a stateful replacement blind. (SAA-C03, DVA-C02)

Quick check

  1. You edit a template and run an update, and the change set shows Replacement: True on your RDS instance. What will execute-change-set do, and what protects you?
  2. Your first create-stack fails and the stack is now ROLLBACK_COMPLETE. Why does update-stack refuse, and what’s the fix?
  3. Which capability does a template that creates an IAM role with a custom name require, and why the stricter one?
  4. You want the same template to use t3.micro in dev and t3.medium in prod without editing it per environment. Name two mechanisms.
  5. You try to delete a network stack and get Export ... cannot be deleted as it is in use. What’s happening and how do you resolve it?

Answers

  1. It will delete the old RDS instance and create a new one (new physical ID) — data loss unless protected. Your protection is reading the change set before executing, plus DeletionPolicy: Snapshot and UpdateReplacePolicy: Snapshot so the old instance is snapshotted rather than destroyed.
  2. ROLLBACK_COMPLETE is a terminal state for a failed first create — CloudFormation won’t update it. Read describe-stack-events for the first failure, fix the template, then delete the stack and create it again.
  3. CAPABILITY_NAMED_IAM. A custom-named IAM resource can collide with an existing one across stacks/accounts, so CloudFormation demands the louder acknowledgement (generated names only need CAPABILITY_IAM).
  4. A Mapping (EnvConfig → env → InstanceType, retrieved with Fn::FindInMap) driven by an EnvironmentName Parameter; or Conditions with Fn::If selecting the type. (The lab uses the mapping.)
  5. Another stack imports that stack’s exported value via Fn::ImportValue, which freezes it. Run aws cloudformation list-imports --export-name <name> to find the consumers, update or delete them first, then delete the exporter.

Glossary

Term Definition
CloudFormation AWS’s native, declarative infrastructure-as-code service; free, you pay only for resources.
Template A YAML/JSON document describing the desired end state (up to 10 top-level sections; only Resources required).
Stack A deployed instance of a template — the resources CloudFormation manages as one lifecycle unit.
Change set A previewed diff of what an update will Add/Modify/Remove/Replace, executed only after you review it.
Logical ID / Physical ID The resource’s name in the template vs the real AWS identifier it maps to.
Parameter A typed, optionally-constrained input supplied at deploy time; SSM types resolve live values.
Mapping A static two-level lookup table, read with Fn::FindInMap.
Condition A named boolean (from condition functions) that gates resources/properties/outputs.
Intrinsic function Deploy-time logic — Ref, Fn::GetAtt, Fn::Sub, Fn::If, Fn::ImportValue, etc.
Pseudo parameter A built-in value like AWS::Region, AWS::AccountId, AWS::StackName.
Output / Export A value a stack returns; an Export can be Fn::ImportValued by another stack (and is then frozen).
DeletionPolicy Resource attribute controlling its fate on stack/resource delete (Delete/Retain/Snapshot).
UpdateReplacePolicy Resource attribute controlling the old resource when an update replaces it.
Replacement A change-set outcome where CloudFormation recreates a resource (new physical ID, possible data loss).
Drift Divergence of live resource state from the template, found by drift detection (read-only).
Nested stack A stack created as a resource (AWS::CloudFormation::Stack) inside a parent, for reuse/modularity.
StackSet A construct that deploys one template across many accounts and Regions as stack instances.
Capability An explicit acknowledgement (CAPABILITY_IAM/NAMED_IAM/AUTO_EXPAND) required for IAM/macro templates.

Next steps

AWSCloudFormationIaCChange SetsIntrinsic FunctionsDrift DetectionStackSetsDevOps
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading