AWS CloudFormation Templates
Write templates that stay maintainable. Parameter types, intrinsic functions, conditions, DependsOn, custom resources and linting before deploy.
CloudFormation Templates
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.
Structure
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.
Parameter types do validation for you
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
Intrinsic functions
!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
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 Database
A 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.
Dependencies
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 bootstrap
An 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.
Custom resources
For anything CloudFormation does not support natively:
DnsValidation:
Type: Custom::DnsValidator
Properties:
ServiceToken: !GetAtt ValidatorFunction.Arn
DomainName: !Ref DomainName
The 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.
Lint before you deploy
cfn-lint template.yaml
E3002 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.yaml
cfn-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.
Common errors
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.
Quick reference
| 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 |
Key takeaways
- Use YAML: comments, short functions, readable multi-line strings.
- Use specific parameter types. They validate before anything is created.
AWS::SSM::Parameter::Value<String>keeps secrets and per-environment values out of the template.- Prefer
!Subto!Join. Same result, far more readable. !Ref AWS::NoValueis how you omit a property conditionally.- Conditions let one template serve every environment: until there are too many to follow.
DependsOnonly for ordering CloudFormation cannot infer. It serialises otherwise.- Custom resources must always signal back, including on failure and on delete.
- Run
cfn-lintin CI.validate-templateonly checks syntax.
Check your understanding
An instance whose bootstrap silently fails
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?
Catching a typo before it costs ten minutes
A template has BackupRetension instead of BackupRetentionPeriod. aws cloudformation validate-template reports no problems. Where would this be caught before deploying?
Frequently Asked Questions
Should I write CloudFormation templates in YAML or JSON?
YAML. It supports comments, which JSON does not and which templates badly need; the short-form intrinsic functions (!Ref, !Sub, !GetAtt) are far more readable than {"Fn::Sub": …}; and multi-line strings for user data and policies work properly. JSON is only preferable when a template is machine-generated. Both express exactly the same thing, and cfn-flip converts between them if you inherit the wrong one.
What is the difference between !Ref and !GetAtt?
!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.
Why should I use typed parameters like AWS::EC2::Subnet::Id?
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.
When do I need DependsOn?
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.
What is a custom resource and what usually goes wrong?
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.
How do I validate a CloudFormation template before deploying?
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.