Template anatomy

Resources, and the sections around them.

Beginner12 min · lesson 2 of 12

A CloudFormation template is a form, not a program. A building permit application is the closest everyday match. The application has a fixed set of boxes: one for the project description, one for the materials the plan needs supplied, one for the structures you actually want built. You fill in the boxes that apply and hand it to the county, and the county works out what to inspect and in what order. You never write 'pour the foundation, then frame the walls.' You describe the finished building. CloudFormation (the Amazon Web Services, or AWS, feature that turns a text file into real cloud infrastructure) works the same way. You declare the end state you want, and it works out the API (application programming interface) calls and the order to make them. So template anatomy comes down to two questions: which box does what, and what a single resource looks like inside the one box that actually matters.

The sections of a template

A template has a small, fixed set of top-level sections, and only one is required: Resources. The rest are optional scaffolding you add as the file grows. AWSTemplateFormatVersion is a version stamp with exactly one legal value, 2010-09-09. Description is free text up to 1024 characters, documentation for humans. Metadata holds structured data about the template itself that tools and the AWS console (Amazon's web dashboard) can read. Parameters are the inputs a person or a deploy pipeline (an automated release system) supplies at deploy time. Rules check those inputs before anything is built, so you can reject, say, a production stack (the live set of cloud resources CloudFormation builds from one template) pointed at a tiny test-sized server. Mappings are static lookup tables. Conditions are named true-or-false expressions that switch resources on or off. Transform pulls in macros, snippets that expand into more template, like the serverless shorthand. Outputs are values the stack hands back, to show you or to export for another stack to read. Parameters, mappings, conditions, and outputs each earn their own lesson; here they are empty stubs so you can see where they sit.

Where a section sits in the file does not matter. CloudFormation reads by key name, not by line number. Put Outputs at the top and Resources at the bottom and the result is identical. The same holds for the order of resources inside Resources, which comes back to bite people, so hold that thought.

skeleton.yaml
AWSTemplateFormatVersion: "2010-09-09" # only valid value; optional but conventional
Description: "A single S3 bucket with versioning." # free text, max 1024 chars
Metadata: {} # arbitrary data about the template itself
Parameters: {} # inputs supplied at deploy time
Rules: {} # validate parameter combinations before deploy
Mappings: {} # static lookup tables
Conditions: {} # named booleans that gate resource creation
Transform: [] # macros, e.g. AWS::Serverless
Resources: # THE ONLY REQUIRED SECTION
MyBucket:
Type: AWS::S3::Bucket
Outputs: {} # values to return or export to other stacks

That example is YAML (a plain-text format that uses indentation instead of brackets). CloudFormation accepts JSON (JavaScript Object Notation, the format built from braces, brackets, and quotes) equally well, and the two convert back and forth. YAML tends to win for hand-written templates because it allows comments and is easier to read.

Anatomy of a resource

Everything real lives inside Resources, and every entry there has the same three-part shape: a logical ID you invent, then a Type, then Properties. Picture the logical ID as the nickname on the blueprint and the physical ID as the street address the city assigns once the building exists. AppBucket below is the logical ID. It is unique within the template, and it is how you point at this resource from anywhere else in the file. It is not the bucket's real name. Type follows an AWS::Service::Type pattern, three colon-separated parts (provider, service, type), which is why AWS::S3::Bucket reads as Amazon's S3 (Simple Storage Service, the object-storage system) bucket type. The type decides which properties are legal. Properties is the type-specific configuration, the actual knobs for that kind of resource.

bucket.yaml
Description: "A single S3 bucket with versioning."
Resources:
# "AppBucket" is the LOGICAL ID: unique in this template, and the name
# you Ref elsewhere in the file. It is NOT the bucket's real name.
AppBucket:
Type: AWS::S3::Bucket # AWS::Service::Type
Properties:
# BucketName is left out on purpose so AWS generates a unique name.
VersioningConfiguration:
Status: Enabled
Tags:
- Key: env
Value: prod

Leaving out BucketName is a deliberate habit. When you let CloudFormation choose the name, it generates a unique one, so you can deploy the same template into two accounts or two regions without a clash. Hard-code the name and the second deployment fails, because that name is already taken. A named resource is also harder to change later, since renaming many resource types forces a full replacement. You can see the name CloudFormation generated by asking the stack what it built.

terminal
aws cloudformation describe-stack-resources \
--stack-name app-storage \
--query 'StackResources[].{Logical:LogicalResourceId,Physical:PhysicalResourceId,Type:ResourceType}' \
--output table
output
------------------------------------------------------------------------------
| DescribeStackResources |
+------------+-----------------------------------------+---------------------+
| Logical | Physical | Type |
+------------+-----------------------------------------+---------------------+
| AppBucket | app-storage-appbucket-1a2b3c4d5e6f | AWS::S3::Bucket |
+------------+-----------------------------------------+---------------------+

The logical ID AppBucket maps to a physical name CloudFormation invented, app-storage-appbucket-1a2b3c4d5e6f. In the template you reference the resource by its logical ID; AWS tracks the physical name behind it. Hold that split firmly, because it is the source of one of the nastiest mistakes in CloudFormation, coming up shortly.

Attributes that sit beside Properties

Properties is not the only thing a resource can carry. Sitting next to it, at the same indent as Type and Properties rather than inside them, are resource attributes. If Properties describes what the thing is, attributes are the handling instructions on the outside of the box: fragile, this side up, do not discard. They control lifecycle and ordering instead of configuration.

DeletionPolicy decides what happens to the resource when the stack is deleted. The default for most types is Delete, though a few data stores default to Snapshot. Set it to Retain and the resource outlives the stack. UpdateReplacePolicy covers a different moment: what happens to the old resource when an update replaces it with a new one. DependsOn forces an ordering CloudFormation cannot infer on its own. Condition ties the resource to a name from the Conditions section, so it is created only when that condition is true. CreationPolicy and UpdatePolicy handle special cases like waiting for a server to report it finished booting, or controlling how a fleet of servers is rolled during an update. Metadata attaches free-form notes for humans and tools.

database.yaml
Resources:
Database:
Type: AWS::RDS::DBInstance
DeletionPolicy: Retain # keep the DB if the STACK is deleted
UpdateReplacePolicy: Retain # keep the OLD DB if an update replaces it
Condition: IsProd # create only when the IsProd condition is true
DependsOn: NatGateway # explicit ordering CloudFormation can't infer
Metadata:
Comment: "primary datastore"
Properties:
Engine: postgres
DBInstanceClass: db.t3.micro
AllocatedStorage: 20
MasterUsername: !Ref DbUser # !Ref pulls the value from a parameter at deploy time

You rarely need DependsOn, because CloudFormation infers most ordering from the template itself. Every time one resource uses !Ref or !GetAtt to read another resource's value, that reference becomes a dependency edge, and the referenced resource is built first. DependsOn is the manual override for cases the graph misses, like a network route that must exist before a server can reach the internet. The two Retain lines are the ones that matter for anyone running production. DeletionPolicy: Retain protects the RDS database (Relational Database Service, Amazon's managed relational-database service) when someone deletes the whole stack. UpdateReplacePolicy: Retain protects it when an update forces the database to be rebuilt. They guard two different accidents, and you want both on anything holding data you cannot afford to lose.

How CloudFormation reads a template
1Read the sections
by key name, not line order
2Resolve inputs
Parameters, Mappings, Conditions
3Build the dependency graph
every Ref and GetAtt is an edge
4Provision along the graph
unrelated resources in parallel
5Return Outputs
export values for other stacks

This is why the order of resources in the file is cosmetic. CloudFormation never walks Resources from top to bottom. It reads every entry, draws the arrows between them from your Ref and GetAtt calls, and provisions along that graph, building unrelated resources at the same time. Your job is to describe the pieces correctly. The order is the engine's job.

Catch a destructive change before it lands

This is where anatomy turns into safety. A change to a template can read as one harmless-looking line in a pull request (a proposed change waiting for review) and still mean 'delete the production database.' You want to see that before you approve it, the way closing paperwork spells out exactly what you are signing before a sale is final. CloudFormation gives you three checks, cheapest first.

The first is structural validation. aws cloudformation validate-template confirms the file is well-formed and structurally valid CloudFormation. It is fast and needs no live stack.

terminal
aws cloudformation validate-template \
--template-body file://bucket.yaml
output
{
"Parameters": [],
"Description": "A single S3 bucket with versioning."
}

Read what that did and did not do. It parsed the file and echoed the description. It did not check whether your property names are real. validate-template will happily pass a template where you wrote Versioning instead of VersioningConfiguration, and you would learn about it only when the deploy failed partway through. For that, you want a linter (a tool that reads your file and flags mistakes before you run it). cfn-lint checks every property name and type against the real resource schemas, on your own machine, before anything ships.

terminal
cfn-lint bucket.yaml
output
E3002 Additional properties are not allowed ('Versioning' was unexpected)
bucket.yaml:10:7

The third check is the one that saves data. A change set is a dry run. You hand CloudFormation the new template and, instead of applying it, it reports exactly what it would add, modify, or remove, and whether any change forces a resource to be replaced. Suppose a teammate adds a hard-coded BucketName to AppBucket, which today runs on a generated name. Create a change set and look before applying.

terminal
aws cloudformation create-change-set \
--stack-name app-storage \
--change-set-name add-bucket-name \
--template-body file://bucket.yaml
output
{
"Id": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/add-bucket-name/8f0a1b2c-3d4e-5f60-7a8b-9c0d1e2f3a4b",
"StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/app-storage/2e1a0c9d-6b5a-4c3d-8e2f-1a0b9c8d7e6f"
}
terminal
aws cloudformation describe-change-set \
--stack-name app-storage \
--change-set-name add-bucket-name \
--query 'Changes[].ResourceChange.{Action:Action,LogicalId:LogicalResourceId,Replacement:Replacement}' \
--output json
output
[
{
"Action": "Modify",
"LogicalId": "AppBucket",
"Replacement": "True"
}
]

Replacement: True is the alarm. It means CloudFormation will build a brand-new bucket under the new name and delete the old one, and every object in the old bucket goes with it. The edit looked like one line. The change set shows it for what it is. Someone reviewing infrastructure changes reads this output the way a code reviewer reads a diff: any Replacement: True, or any resource under Action: Remove, on something that holds state is a stop-and-ask. Wire change-set review into the deploy pipeline so nobody applies a stateful change blind. Create the set, read it, then execute it, rather than deploying straight over a live stack.

A logical ID is not a rename knob
Changing a resource's logical ID is not a rename. CloudFormation keeps no memory that the old ID and the new one are the same thing. On the next update it sees one resource that vanished from the template and one that appeared, so it deletes the old and creates a fresh one. For something stateless like a security group (a virtual firewall for your cloud resources), harmless. For an S3 bucket, an RDS database, or a DynamoDB (Amazon's managed key-value database) table, the physical resource and everything in it is destroyed and rebuilt empty, though all you edited was a label in the YAML. It is sneakier than a forced replacement, too: in a change set it shows as an Add plus a Remove, not Replacement: True, so it can slip past a review that only scans for replacements. If you must rename a live stateful resource, set DeletionPolicy: Retain first so the old resource is orphaned instead of deleted, run the update, then import the orphan back under the new logical ID.
Quick check
01You set DeletionPolicy: Retain on a production database so that deleting the stack can't wipe it. Months later, a teammate changes a property that forces the database to be replaced during an ordinary update. Is the data safe?
Incorrect — DeletionPolicy governs deletion only, not replacement triggered by an update.
Correct — Deletion and replacement are separate events with separate policies; guarding a replacing update needs UpdateReplacePolicy: Retain, which was never set here.
Incorrect — Many property changes do force replacement, which is exactly the risk here.
Incorrect — Retain does not block updates; it only decides the resource's fate on deletion.
02aws cloudformation validate-template returns cleanly on your template and echoes back its description. Does a clean pass guarantee the property names inside your resources are real?
Incorrect — that is what cfn-lint does; validate-template only checks structure, not property names.
Incorrect — structural validity is not the same as a correct, deployable template.
Correct — validate-template parses structure and echoes the description but never checks property names against real schemas; a linter like cfn-lint catches that first.
Incorrect — validate-template reads YAML and JSON equally; the gap is schema checking, not the format.
03A pull request changes only a live, data-holding S3 bucket's logical ID (say from AppBucket to DataBucket). The reviewer scans the change set for Replacement: True, sees none, and approves. What happens on deploy?
Correct — CloudFormation keeps no memory that the two IDs are the same resource, so it removes one and adds the other; scanning only for Replacement: True never catches it.
Incorrect — the logical ID is not a rename knob; CloudFormation treats the new ID as a different resource entirely.
Incorrect — a changed logical ID is a real change: one resource disappears from the template and another appears.
Incorrect — a logical-ID change appears as Add plus Remove, not Replacement: True, which is exactly why it slips past that check.

Build the habit around the one command that pays for itself. Before any update to a stack that holds data, generate a change set and read the Action and Replacement columns. If a resource you care about appears as Remove, or its Replacement reads True, stop and find out why a text edit is about to delete something real, before you press execute.

Try this

Run cfn-lint bucket.yaml on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a logical ID is not a rename knob. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related