A template declares what should exist; CloudFormation works out how to get there. This page is about writing ones that are still readable a year later. The mechanics of operating stacks are covered separately.
Use YAML. It supports comments, the short-form functions (!Ref, !Sub) are far more readable than their JSON equivalents, and multi-line strings work properly.
AWSTemplateFormatVersion: '2010-09-09'
Description: Orders API infrastructure
Parameters:
Environment:
Type: String
AllowedValues: [dev, staging, prod]
Description: Deployment environment
DbPassword:
Type: AWS::SSM::Parameter::Value<String>
Default: /orders/db-password
NoEcho: true
VpcId:
Type: AWS::EC2::VPC::Id
Mappings:
EnvConfig:
dev: { InstanceType: t3.small, MinSize: 1, MultiAZ: false }
staging: { InstanceType: t3.medium, MinSize: 2, MultiAZ: false }
prod: { InstanceType: m6i.large, MinSize: 3, MultiAZ: true }
Conditions:
IsProd: !Equals [!Ref Environment, prod]
Resources:
Database:
Type: AWS::RDS::DBInstance
DeletionPolicy: Snapshot
UpdateReplacePolicy: Snapshot
Properties:
DBInstanceClass: !FindInMap [EnvConfig, !Ref Environment, InstanceType]
MultiAZ: !FindInMap [EnvConfig, !Ref Environment, MultiAZ]
MasterUserPassword: !Ref DbPassword
BackupRetentionPeriod: !If [IsProd, 30, 1]
DeletionProtection: !If [IsProd, true, false]
Outputs:
DatabaseEndpoint:
Value: !GetAtt Database.Endpoint.Address
Export:
Name: !Sub '${AWS::StackName}-DbEndpoint'Only Resources is mandatory. Everything else earns its place.
The most under-used feature in CloudFormation. A typed parameter is validated before the stack starts changing anything, rather than failing halfway through:
| Type | Validates |
|---|---|
String | Nothing |
AWS::EC2::VPC::Id | The VPC exists in this account and region |
AWS::EC2::Subnet::Id | The subnet exists |
List<AWS::EC2::Subnet::Id> | Every subnet exists |
AWS::EC2::KeyPair::KeyName | The key pair exists |
AWS::SSM::Parameter::Value<String> | Resolves the value at deploy time |
Typing a subnet as String means a typo fails during creation, after other resources exist. Typing it as AWS::EC2::Subnet::Id means it fails immediately with a clear message, and the console gives a dropdown.
AWS::SSM::Parameter::Value<String> is the one worth adopting as a habit: the template references a parameter path, and the value is fetched at deploy time, so secrets and environment-specific values never appear in the template or in version control.
Add NoEcho: true to anything sensitive so it is masked in the console, events and API responses.
AllowedCidr:
Type: String
AllowedPattern: '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$'
ConstraintDescription: Must be a valid CIDR block!Ref returns the "main" value. For a parameter its value, for most resources the physical ID, though what exactly that means varies by type. Check the documentation rather than assuming.
!GetAtt returns a specific attribute:
!GetAtt Database.Endpoint.Address
!GetAtt LambdaRole.Arn!Sub substitutes variables into a string, and should be your default over !Join:
# Readable
BucketName: !Sub '${AWS::StackName}-artifacts-${AWS::AccountId}'
# The same thing, unreadable
BucketName: !Join ['-', [!Ref 'AWS::StackName', 'artifacts', !Ref 'AWS::AccountId']]!Sub also reaches attributes directly with ${Resource.Attribute}, and a literal ${ is escaped as ${!.
Pseudo-parameters are always available: AWS::Region, AWS::AccountId, AWS::StackName, AWS::Partition, AWS::NoValue. Use AWS::Partition rather than hardcoding aws in ARNs if anything might run in GovCloud or China.
!Ref AWS::NoValue removes a property entirely, which is how you make one conditional:
KmsKeyId: !If [UseCustomKey, !Ref EncryptionKey, !Ref 'AWS::NoValue']Conditions:
IsProd: !Equals [!Ref Environment, prod]
IsNotDev: !Not [!Equals [!Ref Environment, dev]]
NeedsBackup: !Or [!Condition IsProd, !Condition IsNotDev]
Resources:
ReadReplica:
Type: AWS::RDS::DBInstance
Condition: IsProd
Properties:
SourceDBInstanceIdentifier: !Ref DatabaseA resource with a false Condition is not created at all. This keeps one template serving every environment, which is much better than three templates that drift apart.
Beyond a handful of conditions, though, readability collapses. That is usually the point at which CDK or a templating layer is the better answer, because real conditionals in a programming language beat nested !If expressions.
CloudFormation infers ordering from !Ref and !GetAtt, if resource A references B, B is created first. Most of the time you need nothing else.
DependsOn is for ordering CloudFormation cannot see:
Instance:
Type: AWS::EC2::Instance
DependsOn: GatewayAttachment # needs internet access to bootstrapAn EC2 instance downloading packages depends on the internet gateway being attached, but nothing in its properties references it, so without DependsOn the instance may launch first and its user data fails silently.
Overusing DependsOn is its own problem: it serialises creation that could have run in parallel and makes the dependency graph harder to reason about. Add it when you have a real ordering requirement, not defensively.
For anything CloudFormation does not support natively:
DnsValidation:
Type: Custom::DnsValidator
Properties:
ServiceToken: !GetAtt ValidatorFunction.Arn
DomainName: !Ref DomainNameThe Lambda receives Create, Update and Delete events and must signal back to a presigned URL. If it does not, because it timed out, crashed, or returned without responding, CloudFormation waits the full hour before failing.
Two rules make them survivable: always respond, including from your exception handler, and handle Delete as a no-op rather than an error if the thing is already gone, or stack deletion hangs forever.
Where possible, prefer a module or an AWS-provided resource type over writing one.
cfn-lint template.yamlE3002 Invalid Property Resources/Database/Properties/BackupRetension
template.yaml:31:7
W2001 Parameter UnusedParam not used
template.yaml:12:3
cfn-lint catches misspelled properties, invalid values, missing required fields and unreachable conditions. The things that otherwise fail ten minutes into a deploy. Run it in CI.
aws cloudformation validate-template only checks that the template parses, which is much weaker.
cfn-guard validate --rules rules.guard --data template.yamlcfn-guard enforces policy, "every S3 bucket must have encryption", "no security group may allow 0.0.0.0/0 on port 22", as a check rather than a review comment.
Template format error: Unresolved resource dependencies [VpcId] in the
Resources block
A !Ref to something that does not exist. Usually a typo in a logical ID, which are case-sensitive.
Template format error: Every Condition member must be a string
!Condition is required when referencing one condition from another. A plain !Ref does not work there.
Parameter validation failed: parameter value for parameter name Subnets
does not exist
A typed parameter doing its job before anything was created.
Custom Resource failed to stabilize in expected time
The Lambda did not signal back. Check its logs and make sure every path responds.
| Task | Syntax |
|---|---|
| Reference | !Ref LogicalId |
| Attribute | !GetAtt Resource.Attribute |
| Build a string | !Sub '${AWS::StackName}-suffix' |
| Conditional value | !If [Cond, a, b] |
| Omit a property | !Ref 'AWS::NoValue' |
| Lookup table | !FindInMap [Map, !Ref Env, Key] |
| Value from SSM | Type: AWS::SSM::Parameter::Value<String> |
| Explicit ordering | DependsOn: OtherResource |
| Lint | cfn-lint template.yaml |
| Policy check | cfn-guard validate --rules rules.guard |
AWS::SSM::Parameter::Value<String> keeps secrets and per-environment values out of the template.!Sub to !Join. Same result, far more readable.!Ref AWS::NoValue is how you omit a property conditionally.DependsOn only for ordering CloudFormation cannot infer. It serialises otherwise.cfn-lint in CI. validate-template only checks syntax.<MCQExercise exercise={{ id: 'aws-cfn-tmpl-quiz1', title: 'An instance whose bootstrap silently fails', description: 'A template creates a VPC, an internet gateway, a gateway attachment and an EC2 instance whose user data installs packages. The stack succeeds but the instance has no packages. Nothing references the attachment. What is missing?', options: [ { id: 'a', text: 'DependsOn: GatewayAttachment on the instance. CloudFormation cannot infer an ordering that no property expresses, so the instance can boot before internet access exists' }, { id: 'b', text: 'A !Ref to the VPC in the instance properties, which would create the dependency automatically' }, { id: 'c', text: 'A WaitCondition, which is mandatory for any instance running user data' }, { id: 'd', text: 'The user data must be base64 encoded, which CloudFormation does not do automatically' }, ], correctId: 'a', explanation: 'CloudFormation builds its dependency graph from !Ref and !GetAtt, so resources that are genuinely ordered but never reference each other can be created in parallel. An instance can therefore launch and run its user data before the internet gateway is attached, leaving package installs failing while the stack reports success. A silent failure, since user data errors do not fail the stack. DependsOn expresses that ordering explicitly. Use it where a real requirement exists and nowhere else, since it serialises work that could otherwise run concurrently.', wrongExplanations: { b: 'Referencing the VPC orders against the VPC, not against the gateway attachment, which is a separate resource.', c: 'WaitConditions signal bootstrap completion back to CloudFormation. Useful, but not what causes this ordering problem.', d: 'CloudFormation accepts plain-text user data in templates and encodes it itself.', }, }} />
<MCQExercise exercise={{ id: 'aws-cfn-tmpl-quiz2', title: 'Catching a typo before it costs ten minutes', description: 'A template has BackupRetension instead of BackupRetentionPeriod. aws cloudformation validate-template reports no problems. Where would this be caught before deploying?', options: [ { id: 'a', text: 'cfn-lint, which validates property names against the resource specification, validate-template only checks that the template parses' }, { id: 'b', text: 'A change set, which rejects unknown properties before execution' }, { id: 'c', text: 'Drift detection run against the existing stack' }, { id: 'd', text: 'Nowhere. Unknown properties are silently ignored by CloudFormation' }, ], correctId: 'a', explanation: 'validate-template is a syntax check: it confirms the document is well-formed JSON or YAML with a plausible structure, and knows nothing about which properties a resource type actually accepts. cfn-lint validates against the published resource specification, so a misspelled property is reported with a line number in about a second. It also catches invalid enum values, missing required properties, unused parameters and unreachable conditions. Run it in CI alongside cfn-guard for policy rules such as mandatory encryption.', wrongExplanations: { b: 'A change set surfaces the error only when created against the real service, which is slower and happens after you have committed to the attempt.', c: 'Drift detection compares deployed resources against the template and does not validate the template itself.', d: 'Unknown properties are rejected, not ignored: the deploy fails, just later than it needed to.', }, }} />
!Ref returns a resource's default identifier, which varies by type: a bucket name for an S3 bucket, an instance ID for EC2, the value itself for a parameter. !GetAtt returns a named attribute, such as !GetAtt Database.Endpoint.Address or !GetAtt Role.Arn. When you need an ARN, an endpoint or anything other than the primary identifier, it is !GetAtt. Since what !Ref returns differs per resource type, check the documentation rather than assuming.
Because they are validated before CloudFormation changes anything. A subnet declared as String accepts a typo and fails partway through creation, after other resources exist and with a rollback to sit through; declared as AWS::EC2::Subnet::Id it fails immediately with a clear message, and the console offers a dropdown. AWS::SSM::Parameter::Value<String> goes further by resolving a value from Parameter Store at deploy time, keeping secrets and per-environment settings out of the template entirely.
Only when a real ordering requirement exists that no property expresses. CloudFormation derives ordering from !Ref and !GetAtt, so most dependencies are already handled. The classic exception is an EC2 instance whose user data needs internet access, which depends on the internet gateway attachment without referencing it. Adding DependsOn defensively serialises resources that could have been created in parallel, making deployments slower and the dependency graph harder to reason about.
A hook that lets a Lambda function do something CloudFormation does not natively support, receiving Create, Update and Delete events as part of the stack lifecycle. The thing that goes wrong is not signalling back: the function must POST a response to a presigned URL, and if it times out or throws before doing so, CloudFormation waits a full hour before failing. Always respond from the exception handler too, and treat Delete on something already absent as success, or stack deletion hangs.
cfn-lint template.yaml is the real check. It validates property names and values against the resource specification and catches misspellings, missing required fields, bad enum values and unused parameters, with line numbers. aws cloudformation validate-template only confirms the document parses, so it passes templates that will certainly fail. Add cfn-guard if you want policy enforcement, such as requiring encryption on every bucket or forbidding 0.0.0.0/0 on port 22. Run all of it in CI.
Deploy an app with Elastic Beanstalk without managing servers. Compare deployment policies and downtime tradeoffs, blue-green URL swaps and .ebextensions.
Use AWS KMS correctly: why key policies come before IAM, AWS managed vs customer managed keys, envelope encryption, encryption context, rotation and grants.
Store, rotate and read secrets with AWS Secrets Manager. Covers resource policies, automatic rotation, caching, recovery windows, cost and Parameter Store.