A CloudFormation template is a YAML (or JSON) description of AWS resources. You hand it to the service, which creates the resources in the right order as a stack, tracks them, updates them when you change the template and removes them when you delete the stack. Learning the anatomy through one realistic example, a small VPC, covers most of what you will write later.
Sections of a template
flowchart TB
T[Template] --> V[AWSTemplateFormatVersion<br/>always 2010-09-09]
T --> P[Parameters<br/>inputs with types, defaults, validation]
T --> MP[Mappings<br/>static lookup tables]
T --> C[Conditions<br/>true or false from parameters]
T --> R[Resources<br/>required - the things to create]
T --> O[Outputs<br/>values to show or export]
P --> C
P --> R
MP --> R
C -->|if| R
R --> O
O -->|Export and Fn ImportValue| OTHER[Other stacks]
Only Resources is required. Parameters feed conditions and resources; conditions switch resources or properties on and off; outputs publish results.
A working template: VPC with public and private subnets
AWSTemplateFormatVersion: "2010-09-09"
Description: VPC with two public and two private subnets, optional NAT gateway
Parameters:
Env:
Type: String
AllowedValues: [dev, prod]
Default: dev
VpcCidr:
Type: String
Default: 10.20.0.0/16
AllowedPattern: '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$'
ConstraintDescription: Must be a CIDR block such as 10.20.0.0/16
Conditions:
IsProd: !Equals [!Ref Env, prod]
Resources:
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref VpcCidr
EnableDnsSupport: true
EnableDnsHostnames: true
Tags: [{ Key: Name, Value: !Sub "${Env}-vpc" }]
Igw:
Type: AWS::EC2::InternetGateway
IgwAttach:
Type: AWS::EC2::VPCGatewayAttachment
Properties: { VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw }
PublicA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: !Select [0, !Cidr [!Ref VpcCidr, 8, 8]]
AvailabilityZone: !Select [0, !GetAZs ""]
MapPublicIpOnLaunch: true
Tags: [{ Key: Name, Value: !Sub "${Env}-public-a" }]
PublicB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: !Select [1, !Cidr [!Ref VpcCidr, 8, 8]]
AvailabilityZone: !Select [1, !GetAZs ""]
MapPublicIpOnLaunch: true
Tags: [{ Key: Name, Value: !Sub "${Env}-public-b" }]
PrivateA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: !Select [2, !Cidr [!Ref VpcCidr, 8, 8]]
AvailabilityZone: !Select [0, !GetAZs ""]
Tags: [{ Key: Name, Value: !Sub "${Env}-private-a" }]
PrivateB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: !Select [3, !Cidr [!Ref VpcCidr, 8, 8]]
AvailabilityZone: !Select [1, !GetAZs ""]
Tags: [{ Key: Name, Value: !Sub "${Env}-private-b" }]
PublicRt:
Type: AWS::EC2::RouteTable
Properties: { VpcId: !Ref Vpc }
PublicDefaultRoute:
Type: AWS::EC2::Route
DependsOn: IgwAttach
Properties: { RouteTableId: !Ref PublicRt, DestinationCidrBlock: 0.0.0.0/0, GatewayId: !Ref Igw }
PublicARt:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PublicA, RouteTableId: !Ref PublicRt }
PublicBRt:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PublicB, RouteTableId: !Ref PublicRt }
NatEip:
Type: AWS::EC2::EIP
Condition: IsProd
Properties: { Domain: vpc }
Nat:
Type: AWS::EC2::NatGateway
Condition: IsProd
Properties: { AllocationId: !GetAtt NatEip.AllocationId, SubnetId: !Ref PublicA }
PrivateRt:
Type: AWS::EC2::RouteTable
Properties: { VpcId: !Ref Vpc }
PrivateNatRoute:
Type: AWS::EC2::Route
Condition: IsProd
Properties: { RouteTableId: !Ref PrivateRt, DestinationCidrBlock: 0.0.0.0/0, NatGatewayId: !Ref Nat }
PrivateARt:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PrivateA, RouteTableId: !Ref PrivateRt }
PrivateBRt:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PrivateB, RouteTableId: !Ref PrivateRt }
S3Endpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref Vpc
ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
VpcEndpointType: Gateway
RouteTableIds: [!Ref PrivateRt, !Ref PublicRt]
Outputs:
VpcId:
Value: !Ref Vpc
Export: { Name: !Sub "${Env}-vpc-id" }
PrivateSubnetIds:
Value: !Join [",", [!Ref PrivateA, !Ref PrivateB]]
Export: { Name: !Sub "${Env}-private-subnets" }
Choices worth noticing: a NAT gateway costs money every hour plus data processing, so the template creates it only for prod (dev private subnets use the S3 gateway endpoint, which is free, but have no general internet egress). For high availability in production you would create one NAT gateway per AZ.
Intrinsic functions you will use constantly
| Function | Use |
|---|---|
!Ref |
Parameter value, or the main ID of a resource (VPC ID, subnet ID) |
!GetAtt Res.Attr |
Another attribute, such as NatEip.AllocationId or Bucket.Arn |
!Sub "${Env}-vpc" |
String substitution including pseudo parameters like ${AWS::Region} |
!Select [i, list], !GetAZs "" |
Pick an item; list the region’s Availability Zones |
!Cidr [block, count, bits] |
Carve subnets out of a CIDR automatically |
!Join, !Split |
Combine or split strings |
!If, !Equals, !Not, Condition: |
Conditional resources and properties |
!ImportValue |
Read another stack’s exported output |
Lint, validate, deploy
pip install cfn-lint
cfn-lint template.yaml # catches wrong property names, types and many mistakes
aws cloudformation validate-template --template-body file://template.yaml
aws cloudformation deploy --template-file template.yaml --stack-name dev-network \
--parameter-overrides Env=dev --region ap-south-1
aws cloudformation describe-stacks --stack-name dev-network --query 'Stacks[0].Outputs'
aws cloudformation describe-stack-events --stack-name dev-network --max-items 8
cfn-lint runs offline and finds more problems than validate-template, so put it in CI (and pin its version for stable results). deploy creates a change set and executes it; add --no-execute-changeset to review first (next post). Tag the stack (--tags Project=shop Owner=platform) so costs are attributable.
Practical advice
- Exports create coupling: you cannot delete or change an exported value while another stack imports it. For loose coupling, store values in SSM Parameter Store and read them with
{{resolve:ssm:/shop/dev/vpc-id}}. - Name resources through tags, not fixed physical names, so replacement updates do not fail with “already exists”.
- Add
DeletionPolicy: RetainorSnapshoton stateful resources. - Keep templates small and one responsibility each (network, data, application) rather than one giant file.
- When creation fails, read Events for the first
CREATE_FAILED; the stack rolls back, and later errors are just consequences. - Parameters of type
AWS::SSM::Parameter::Value<String>read the latest AMI IDs from public SSM parameters instead of hard-coding them.