Happy Dussehra! May good always win. Happy Diwali! Wishing you a bright year ahead. Merry Christmas! Warm wishes from Vizag. Happy New Year! Here’s to a great year ahead.

Archive · AWS

CloudFormation: Template Anatomy and a VPC Example

Learn the sections of a CloudFormation template through a working VPC with public and private subnets, route tables, a conditional NAT gateway, intrinsic functions, outputs and linting.

5 min read

  • AWS CloudFormation
  • cfn-lint 0.x
  • Amazon VPC
  • YAML

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

  1. 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}}.
  2. Name resources through tags, not fixed physical names, so replacement updates do not fail with “already exists”.
  3. Add DeletionPolicy: Retain or Snapshot on stateful resources.
  4. Keep templates small and one responsibility each (network, data, application) rather than one giant file.
  5. When creation fails, read Events for the first CREATE_FAILED; the stack rolls back, and later errors are just consequences.
  6. Parameters of type AWS::SSM::Parameter::Value<String> read the latest AMI IDs from public SSM parameters instead of hard-coding them.