Template anatomy
Resources, and the sections around them.
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.
AWSTemplateFormatVersion: "2010-09-09" # only valid value; optional but conventionalDescription: "A single S3 bucket with versioning." # free text, max 1024 charsMetadata: {} # arbitrary data about the template itselfParameters: {} # inputs supplied at deploy timeRules: {} # validate parameter combinations before deployMappings: {} # static lookup tablesConditions: {} # named booleans that gate resource creationTransform: [] # macros, e.g. AWS::ServerlessResources: # THE ONLY REQUIRED SECTIONMyBucket:Type: AWS::S3::BucketOutputs: {} # 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.
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::TypeProperties:# BucketName is left out on purpose so AWS generates a unique name.VersioningConfiguration:Status: EnabledTags:- Key: envValue: 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.
aws cloudformation describe-stack-resources \--stack-name app-storage \--query 'StackResources[].{Logical:LogicalResourceId,Physical:PhysicalResourceId,Type:ResourceType}' \--output table
------------------------------------------------------------------------------| 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.
Resources:Database:Type: AWS::RDS::DBInstanceDeletionPolicy: Retain # keep the DB if the STACK is deletedUpdateReplacePolicy: Retain # keep the OLD DB if an update replaces itCondition: IsProd # create only when the IsProd condition is trueDependsOn: NatGateway # explicit ordering CloudFormation can't inferMetadata:Comment: "primary datastore"Properties:Engine: postgresDBInstanceClass: db.t3.microAllocatedStorage: 20MasterUsername: !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.
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.
aws cloudformation validate-template \--template-body file://bucket.yaml
{"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.
cfn-lint bucket.yaml
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.
aws cloudformation create-change-set \--stack-name app-storage \--change-set-name add-bucket-name \--template-body file://bucket.yaml
{"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"}
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
[{"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.
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.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?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.