CloudFormation

Templates, stacks, change sets, safe updates.

Beginner35 min · lesson 4 of 15

Hand a builder one drawing of a finished house and walk away. They work out that the foundation goes in before the walls, they build in that order, and if a beam will not fit they undo what they started and leave the lot exactly as they found it. CloudFormation is that drawing plus that builder. The drawing is a template, a text file describing the Amazon Web Services (AWS) resources you want. Working this way is *Infrastructure as Code* (IaC), meaning you write the end state you want as version-controlled text instead of clicking through the web console, and the service does the work of making reality match. This lesson takes you from a template to a running stack, through change-set previews, update behaviors, rollback, and the guardrails that stop a one-line edit from deleting a production database.

From one file to a running stack

A template is a YAML or JSON file (two plain-text formats for structured data, YAML being the indented one people write by hand) listing the resources you want. Deploy it and you get a stack: the bundle CloudFormation manages as one unit, with one create, one update, one delete. Templates have several sections and only Resources is required. Parameters feed in values at deploy time, such as the environment name or the database instance class. Mappings are lookup tables, for example region to AMI id (Amazon Machine Image, the disk image a virtual server boots from). Conditions switch resources on or off. Outputs publish values other stacks can import. Transform pulls in a macro or the SAM (Serverless Application Model) shorthand. You wire resources to each other with intrinsic functions: !Ref hands back another resource's id, !GetAtt reads one of its attributes, and !Sub drops either of them into a string. Here is what all of that looks like in one file.

template.yaml
# template.yaml - the stack every command in this lesson points at
AWSTemplateFormatVersion: '2010-09-09'
Parameters: # values you pass in at deploy time
Env:
Type: String
AllowedValues: [dev, prod]
DbInstanceClass:
Type: String
Default: db.t3.micro
Mappings: # a lookup table: environment -> backup days
EnvSettings:
dev: { BackupDays: '1' }
prod: { BackupDays: '14' }
Conditions:
IsProd: !Equals [!Ref Env, prod]
Resources:
AppBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain # deleting the stack leaves the bucket standing
Properties:
BucketName: !Sub 'app-assets-${Env}-${AWS::AccountId}' # !Sub builds a string
AppDatabase:
Type: AWS::RDS::DBInstance
DeletionPolicy: Snapshot # leaving the stack: take a final snapshot
UpdateReplacePolicy: Snapshot # replaced by an update: snapshot the OLD one
Properties:
DBInstanceIdentifier: !Sub 'app-db-${Env}' # editing this forces a replacement
Engine: postgres
DBInstanceClass: !Ref DbInstanceClass # !Ref a parameter: its value
AllocatedStorage: '20'
MasterUsername: appadmin
ManageMasterUserPassword: true # RDS parks it in Secrets Manager
MultiAZ: !If [IsProd, true, false]
BackupRetentionPeriod: !FindInMap [EnvSettings, !Ref Env, BackupDays]
Outputs:
BucketName:
Value: !Ref AppBucket # !Ref a resource: its id
DbEndpoint:
Value: !GetAtt AppDatabase.Endpoint.Address # !GetAtt: one of its attributes

Those references are not decoration. They build a dependency graph. CloudFormation reads every !Ref, every !GetAtt, and every explicit DependsOn, assembles them into a directed acyclic graph (a map of what must exist before what, with no loops allowed), then creates independent resources side by side while queueing anything that depends on another behind it. Each resource type has a handler in the provisioning engine that calls the service's own API (application programming interface, the same CreateBucket and RunInstances calls you could make by hand) and then *waits until the resource settles down*. An RDS (Relational Database Service) instance is not finished until it reports available, which is why a database stack can sit at CREATE_IN_PROGRESS for fifteen minutes. Because the template is code, it lives in git, gets reviewed like any other change, and stands up dev, staging, and prod from the same file. That kills the whole 'works in dev' family of bugs.

Change sets: the rehearsal before prod

Running deploy or update-stack straight at a live stack is a leap of faith. You find out what changed by watching it change. A change set is the dry run. CloudFormation compares your new template against what the stack looks like right now and hands back a list of exactly what it *would* add, modify, or replace. Nothing moves until you execute it. The field that matters most is Replacement. Replacement: False means an in-place edit. Replacement: True means CloudFormation will build the new resource first, repoint everything that referenced the old one at it, then delete the original in a cleanup pass at the end, which is a disaster for anything holding data. There is a third value, Conditional, and it is the one to slow down on: it means the outcome depends on how the service resolves the change when the update actually runs, so the preview genuinely cannot tell you either way. On anything holding state, read Conditional as True until you have proved otherwise. Lint the file locally first, because cfn-lint catches most template mistakes before AWS ever sees them, then read the change set.

preview-change-set.sh
# Lint locally + confirm the template's parameters before anything touches AWS
$ cfn-lint template.yaml && aws cloudformation validate-template \
--template-body file://template.yaml --query 'Parameters[].ParameterKey'
[
"Env",
"DbInstanceClass"
]
# Create a change set instead of updating directly — NOTHING changes yet
$ aws cloudformation create-change-set \
--stack-name app-prod --change-set-name rename-db \
--template-body file://template.yaml \
--parameters ParameterKey=Env,ParameterValue=prod \
--capabilities CAPABILITY_NAMED_IAM
{
"Id": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/rename-db/8f2c1e3a",
"StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/app-prod/1a2b3c4d"
}
# Preview EXACTLY what will change - read 'Replace' before you execute
$ aws cloudformation describe-change-set \
--stack-name app-prod --change-set-name rename-db \
--query 'Changes[].ResourceChange.{Id:LogicalResourceId,Action:Action,Replace:Replacement}'
[
{ "Id": "AppDatabase", "Action": "Modify", "Replace": "True" },
{ "Id": "AppBucket", "Action": "Modify", "Replace": "False" }
]

Replace: "True" on AppDatabase is your stop sign. Execute that set and the RDS instance gets recreated, taking its data with it. The --capabilities CAPABILITY_NAMED_IAM flag on the command above is not optional paperwork. CloudFormation refuses to touch a template that creates named IAM (Identity and Access Management, the service deciding who and what may call AWS APIs) resources until you acknowledge it in writing, which stops a template from quietly minting a privileged role while you skim the diff. Once the preview reads the way you expect, and only then, you run execute-change-set.

How an update lands, and how rollback works

Every property change falls into one of three buckets, and AWS documents which bucket applies per property in the resource reference. *No interruption* edits in place with zero downtime, like a tag on an S3 (Simple Storage Service) bucket or a security-group rule. *Some interruption* keeps the resource but takes it offline briefly; changing InstanceType on an EC2 (Elastic Compute Cloud) virtual server stops and restarts it, same instance id on the other side. *Replacement* is the one that bites. CloudFormation builds a brand-new resource with a new physical id, repoints every reference at it, and deletes the original in a cleanup pass at the end of the update, which is the moment UpdateReplacePolicy is read. Whatever lived on the old resource is gone unless you told CloudFormation otherwise. The two also exist side by side for a while, which is why a replacement can fail outright on a name collision or a service quota. All three show up in the change set ahead of time, so Replacement: True on anything holding state should be a decision you made, never news.

What a property change actually does
You edit a property and execute the change set
CloudFormation compares what you asked for against what exists
No interruption
Update in place
Same physical id, no restart, nothing to schedule
Some interruption
Brief unavailability
Same physical id, one restart: book the window
Replacement
Create new, then delete old
New physical id, old one deleted last: name and quota clashes fail here
Replacement comes back True, False, or Conditional, and only the first two are a promise.

When a resource fails partway through an update, CloudFormation turns the car around by itself. The stack moves to UPDATE_ROLLBACK_IN_PROGRESS, every resource goes back to its last known-good state, and the stack settles at UPDATE_ROLLBACK_COMPLETE. A failed *create* unwinds the whole thing back to nothing. All of that fires only when a resource reports failure, which is no help when every resource comes up healthy and the application on top of them starts returning errors. That case is what RollbackConfiguration is for: it hangs a list of CloudWatch alarms off the update itself and keeps watching for up to 180 minutes after the update finishes, and if one of those alarms trips inside that window the stack rolls itself back. That automatic reversal is the main safety property IaC buys you, and it is not guaranteed to work, as the gotcha further down shows. Stack events are where you watch the sequence play out, and they are the first place to look when a deploy stalls.

watch-rollback.sh
# An update that failed mid-flight - CloudFormation reversing course on its own
$ aws cloudformation describe-stack-events --stack-name app-prod \
--query 'StackEvents[:5].{Time:Timestamp,Status:ResourceStatus,Res:LogicalResourceId,Why:ResourceStatusReason}' \
--output table
---------------------------------------------------------------------------------------------
| Time | Status | Res | Why |
| 2026-07-14T09:41:12Z | UPDATE_ROLLBACK_COMPLETE | app-prod | - |
| 2026-07-14T09:40:58Z | UPDATE_ROLLBACK_IN_PROGRESS | app-prod | - |
| 2026-07-14T09:40:55Z | CREATE_FAILED | AppQueue | Resource limit ... |
| 2026-07-14T09:40:31Z | UPDATE_IN_PROGRESS | AppQueue | Requested update |
| 2026-07-14T09:40:12Z | UPDATE_IN_PROGRESS | app-prod | User Initiated |
---------------------------------------------------------------------------------------------

Four locks, and picking the right one

CloudFormation hands you four separate locks, and mixing them up is a classic production mistake. A stack policy is a JSON document attached to the stack governing what an *update* may do to which resources; a Deny on Update:Replace for your database's logical id blocks any update that would recreate it. It is checked only during updates, and the default flips the moment you use one: with no policy attached every update is allowed, but attach a policy and every resource is protected unless the document says otherwise. That is why the JSON below opens with a blanket Allow on Update:* before it denies anything. Ship the Deny statement on its own and you have frozen the entire stack. TerminationProtection is a stack-level switch that makes delete-stack fail outright. DeletionPolicy is a per-resource attribute in the template (Retain, Snapshot, or Delete) deciding what happens to a resource when it leaves the stack. UpdateReplacePolicy decides the fate of the *old* resource when an update replaces it, so set it to Snapshot on databases and a replacement leaves you a restorable backup. If the goal is stopping a prod database from ever being replaced by an update, the stack policy is the lock you want.

protect-and-drift.sh
# Stack policy: deny any UPDATE that would replace or delete the database
$ cat stack-policy.json
{
"Statement": [
{ "Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*" },
{ "Effect": "Deny", "Action": ["Update:Replace","Update:Delete"],
"Principal": "*", "Resource": "LogicalResourceId/AppDatabase" }
]
}
$ aws cloudformation set-stack-policy --stack-name app-prod \
--stack-policy-body file://stack-policy.json
# The rename-db change set now fails at execute time with:
# Action denied by stack policy: [Update:Replace] for resource [AppDatabase]
# Detect drift: has anyone changed prod outside the template?
$ aws cloudformation detect-stack-drift --stack-name app-prod
{ "StackDriftDetectionId": "9a1f7c30-6b2e-4c8a-b1d0-2f5e9c74a1bb" }
# The scan runs in the background. Poll it until DetectionStatus is complete,
# otherwise the next command reads a half-finished result.
$ aws cloudformation describe-stack-drift-detection-status \
--stack-drift-detection-id 9a1f7c30-6b2e-4c8a-b1d0-2f5e9c74a1bb \
--query '{Detection:DetectionStatus,Stack:StackDriftStatus,Drifted:DriftedStackResourceCount}'
{
"Detection": "DETECTION_COMPLETE",
"Stack": "DRIFTED",
"Drifted": 1
}
$ aws cloudformation describe-stack-resource-drifts --stack-name app-prod \
--stack-resource-drift-status-filters MODIFIED \
--query 'StackResourceDrifts[].{Res:LogicalResourceId,Status:StackResourceDriftStatus}'
[
{ "Res": "AppBucket", "Status": "MODIFIED" }
]
A rollback that fails leaves the whole stack stuck
Automatic rollback can fail too. If putting a resource back the way it was is itself impossible, say an S3 bucket that is not empty, an RDS instance somebody edited by hand, or an IAM role deleted outside the template, the stack lands in UPDATE_ROLLBACK_FAILED and rejects every further update until a human steps in. Repair the offending resource manually, then run aws cloudformation continue-update-rollback --stack-name app-prod, adding --resources-to-skip if one resource is genuinely wedged. Do not delete a prod stack and rebuild it to escape this state. You will destroy the stateful resources you were trying to protect.

Treat a change set as a dress rehearsal, and never ship an unread one at prod when it says a database gets replaced. DeletionPolicy and UpdateReplacePolicy are what stand between a mistaken stack delete and a very long afternoon.

Drift detection catches the console cowboy: the person who patched prod by hand at 2am and never told the template. Catching drift once is useful. Catching it every week means your IaC is theater, and the fix is the process, not the YAML.

Limits, cost, and when to split a stack

A few hard limits are worth knowing before a stack grows unwieldy: 500 resources per stack, 200 parameters, 200 mappings, 200 outputs, and a template body capped at 51,200 bytes when passed inline versus 1 MB when uploaded to S3 and referenced with --template-url. Accounts start at 2,000 stacks per region, raisable through Service Quotas. Bumping into the resource ceiling is your signal to break the stack apart. Nested stacks and modules let a parent template treat an entire child stack as a single resource, which is how a large estate stays under that ceiling without one unreadable file.

Cost is refreshingly easy for the normal case. CloudFormation charges nothing to provision and manage standard AWS::* resource types, so you pay for the resources themselves and nothing for the orchestration. Charges show up only once you use the registry for third-party or private resource types, for modules, or for Hooks: the first 1,000 handler operations per account per region each month are free, after which it runs about $0.0009 per handler operation, plus a small per-second charge for operations running past 30 seconds. For an ordinary AWS-native stack, CloudFormation is free tooling.

Try this

Build the stack yourself so nothing you care about is in range. The template below creates a single Systems Manager parameter (a small key-value entry in AWS's own config store), which costs nothing and takes seconds. You will stand it up, change the one property that cannot be edited in place, and read what the change set says before anything happens. If any line in that list surprises you, stop there and leave it unexecuted.

lab.yaml
# lab.yaml - one Systems Manager parameter: free, instant, no network to set up
AWSTemplateFormatVersion: '2010-09-09'
Resources:
LabParam:
Type: AWS::SSM::Parameter
Properties:
Name: /lab/app/greeting
Type: String
Value: hello
terminal
# 1. Create the lab stack, then wait for it to settle
aws cloudformation create-stack --stack-name lab-cfn --template-body file://lab.yaml
aws cloudformation wait stack-create-complete --stack-name lab-cfn
# 2. In lab.yaml, change Name to /lab/app/greeting-v2 and save. Name cannot be
# edited in place, so that one line forces a replacement.
# 3. Preview it. Nothing moves until you run execute-change-set.
aws cloudformation create-change-set --stack-name lab-cfn --change-set-name preview-1 \
--template-body file://lab.yaml
aws cloudformation wait change-set-create-complete --stack-name lab-cfn \
--change-set-name preview-1
aws cloudformation describe-change-set --stack-name lab-cfn --change-set-name preview-1 \
--query 'Changes[].ResourceChange.{Logical:LogicalResourceId,Action:Action,Replace:Replacement}'
# 4. Tear the lab down when you are done
aws cloudformation delete-stack --stack-name lab-cfn
output
[
{ "Logical": "LabParam", "Action": "Modify", "Replace": "True" }
]
# Replace: True on a throwaway parameter costs you nothing. The same line against
# a database is the one you stop on. Execute a change set only once every Action
# in the list is one you meant.

Takeaway

The template states what you want. The change set tells you what that will cost you before it costs you. Rollback, stack policies, and UpdateReplacePolicy are the seatbelts for the day the template said something you did not mean.

Two things to do next: turn on TerminationProtection for every prod stack you own, and make 'I read the change set' a required checkbox on any pull request that touches a template.

Quick check
01Your team wants a hard stop so that no update to app-prod can ever recreate the RDS instance, whatever a template edit says. The database already carries UpdateReplacePolicy: Snapshot, and the stack already has TerminationProtection switched on. Which of the four guardrails actually refuses the replacement?
Incorrect — That switch guards one thing: the delete-stack call aimed at the whole stack. An update that swaps a single resource for a fresh one walks straight past it.
Incorrect — That attribute speaks up when a resource drops out of the stack, and Retain leaves the old instance standing rather than calling off the swap. You would end up with an orphan running beside a brand new database.
Correct — Of the four, this is the only one that can turn the change down. The run stops with a message naming the denied action and the resource, so the live instance is never touched.
Incorrect — That attribute decides the fate of the old instance after the swap has already happened. It hands you something to restore from, not a veto over the update.
02Someone patched AppBucket by hand in the console overnight. Your morning script calls detect-stack-drift, takes the id it gets back, and goes straight on to describe-stack-resource-drifts with the MODIFIED filter. The list prints empty, yet a colleague opening the console two minutes later sees the stack flagged as drifted. What happened?
Correct — The first call only hands you an id. Feed that id to describe-stack-drift-detection-status and wait until it reports DETECTION_COMPLETE, then read the resource drifts.
Incorrect — The scan is read-only. It tells you what someone changed outside the template and never writes a single property back, which is why closing the gap is still a job for a person or a redeploy.
Incorrect — The worked example in this lesson ends with AppBucket listed as MODIFIED after exactly this kind of hand edit, so the filter was fine. Timing was the problem.
Incorrect — Retain only governs what becomes of the bucket when it leaves the stack. It has nothing to say about whether the resource is compared against the template.
03app-prod's template has grown to roughly 60 KB of YAML describing 140 resources. create-stack now rejects it before touching a single resource, saying the template body is too large. What is the smallest change that gets today's deploy through?
Incorrect — Splitting up is the answer to the 500 resource ceiling, and 140 is nowhere near that. It would clear this error by accident, at the cost of a week of restructuring.
Incorrect — Service Quotas is where the 2,000 stacks per region default gets lifted. The size of a body you pass on the command line is fixed, so there is no ticket to file.
Incorrect — The same template written as JSON usually comes out longer than the YAML, and you would trade a readable file for a few kilobytes. The ceiling on an inline body does not shift either way.
Correct — An inline body stops at 51,200 bytes while one fetched from S3 gets 1 MB, so 60 KB fits with plenty of headroom. Not one line of the template has to change.

Two things belong in your pipeline that this lesson never set up: cfn-guard, which checks a template against written policy in CI (continuous integration, the automated checks that run on every commit) the way cfn-lint checks its syntax, and a scheduled drift detection run, so an out-of-band edit surfaces on a Monday morning rather than halfway through an update. Raw YAML also gets long. Hundreds of lines go into describing one Lambda function (a small piece of code AWS runs on demand) and the role it runs as. The next lesson, CDK & SAM, shows how to generate these same templates from real code and from serverless shorthand, keeping every safety property you learned here while dropping most of the typing.

Related