Skip to content
Codeloom
AWS

AWS CloudFormation: Infrastructure as Code Guide

Learn AWS CloudFormation from scratch. Build, update, and manage AWS infrastructure with YAML templates, parameters, outputs, and nested stacks.

·8 min read · By Codeloom
Intermediate 19 min read

What you'll learn

  • Write CloudFormation templates with parameters, mappings, and outputs
  • Deploy and update stacks safely with change sets
  • Organize large infrastructures with nested stacks and cross-stack references
  • Handle errors with rollback triggers and drift detection

Prerequisites

  • AWS account with admin access
  • Basic understanding of at least one AWS service like EC2 or S3

AWS CloudFormation lets you define your entire AWS infrastructure in text files called templates. Instead of clicking through the console or running CLI commands, you declare what resources you want, and CloudFormation creates, configures, and connects them for you. When you need to make changes, you update the template, and CloudFormation figures out what to modify, replace, or delete.

This approach, called Infrastructure as Code (IaC), gives you version control, repeatability, and consistency across environments. This guide covers everything you need to go from your first template to production-ready stacks.

Template Anatomy

A CloudFormation template is a YAML or JSON file with several sections. Here is the structure with the most important sections:

AWSTemplateFormatVersion: "2010-09-09"
Description: "A description of what this stack creates"

Parameters:
  # Input values provided at stack creation

Mappings:
  # Static lookup tables

Conditions:
  # Conditional logic for resource creation

Resources:
  # The AWS resources to create (required)

Outputs:
  # Values to export or display after creation

Only Resources is required. Everything else is optional but becomes essential as templates grow.

Your First Template: S3 Bucket with Versioning

Start with something simple to understand the workflow:

AWSTemplateFormatVersion: "2010-09-09"
Description: "S3 bucket with versioning and encryption"

Parameters:
  Environment:
    Type: String
    AllowedValues:
      - dev
      - staging
      - prod
    Default: dev
    Description: "Deployment environment"

  BucketPrefix:
    Type: String
    Default: myapp
    Description: "Prefix for the bucket name"

Resources:
  AppBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "${BucketPrefix}-${Environment}-${AWS::AccountId}"
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      Tags:
        - Key: Environment
          Value: !Ref Environment

  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref AppBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: DenyUnencryptedUploads
            Effect: Deny
            Principal: "*"
            Action: s3:PutObject
            Resource: !Sub "${AppBucket.Arn}/*"
            Condition:
              StringNotEquals:
                s3:x-amz-server-side-encryption: AES256

Outputs:
  BucketName:
    Description: "Name of the created bucket"
    Value: !Ref AppBucket
  BucketArn:
    Description: "ARN of the created bucket"
    Value: !GetAtt AppBucket.Arn
    Export:
      Name: !Sub "${AWS::StackName}-BucketArn"

Deploy this template with the CLI:

# Create the stack
aws cloudformation create-stack \
  --stack-name my-s3-stack \
  --template-body file://s3-bucket.yaml \
  --parameters ParameterKey=Environment,ParameterValue=dev \
               ParameterKey=BucketPrefix,ParameterValue=myapp

# Wait for completion
aws cloudformation wait stack-create-complete --stack-name my-s3-stack

# Check outputs
aws cloudformation describe-stacks \
  --stack-name my-s3-stack \
  --query 'Stacks[0].Outputs'

Intrinsic Functions You Will Use Constantly

CloudFormation provides built-in functions for dynamic values:

Resources:
  Example:
    Type: AWS::EC2::Instance
    Properties:
      # !Ref returns the resource ID or parameter value
      SubnetId: !Ref PrivateSubnet

      # !Sub performs string substitution
      UserData:
        Fn::Base64: !Sub |
          #!/bin/bash
          echo "Deploying to ${Environment}"
          aws s3 cp s3://${AppBucket}/config.yml /etc/app/

      # !GetAtt gets a resource attribute
      IamInstanceProfile: !GetAtt InstanceProfile.Arn

      # !Select picks an item from a list
      AvailabilityZone: !Select [0, !GetAZs '']

      # !If for conditional values
      InstanceType: !If [IsProduction, m5.large, t3.micro]

      # !Join concatenates strings
      Tags:
        - Key: Name
          Value: !Join ['-', [!Ref Environment, 'web', 'server']]

The !Sub function is the most versatile. It handles variable substitution inline and supports both parameter references and resource attributes.

Parameters and Conditions for Multi-Environment Templates

Instead of maintaining separate templates for dev and prod, use parameters and conditions:

Parameters:
  Environment:
    Type: String
    AllowedValues: [dev, staging, prod]

  InstanceType:
    Type: String
    Default: t3.micro
    AllowedValues: [t3.micro, t3.small, m5.large, m5.xlarge]

Conditions:
  IsProduction: !Equals [!Ref Environment, prod]
  CreateReadReplica: !Equals [!Ref Environment, prod]

Resources:
  WebServer:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref InstanceType
      Monitoring: !If [IsProduction, true, false]

  DatabasePrimary:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceClass: !If [IsProduction, db.r5.large, db.t3.medium]
      MultiAZ: !If [IsProduction, true, false]
      BackupRetentionPeriod: !If [IsProduction, 30, 7]
      DeletionProtection: !If [IsProduction, true, false]

  DatabaseReplica:
    Type: AWS::RDS::DBInstance
    Condition: CreateReadReplica
    Properties:
      SourceDBInstanceIdentifier: !Ref DatabasePrimary
      DBInstanceClass: db.r5.large

The Condition key on DatabaseReplica means the read replica is only created in production. This single template serves all environments while keeping costs low in dev.

Mappings for Static Configuration

Mappings provide lookup tables that avoid long lists of conditions:

Mappings:
  RegionConfig:
    us-east-1:
      AMI: ami-0abcdef1234567890
      AZCount: "3"
    us-west-2:
      AMI: ami-0fedcba9876543210
      AZCount: "3"
    eu-west-1:
      AMI: ami-0123456789abcdef0
      AZCount: "3"

  EnvironmentConfig:
    dev:
      InstanceType: t3.micro
      MinCapacity: "1"
      MaxCapacity: "2"
    prod:
      InstanceType: m5.large
      MinCapacity: "2"
      MaxCapacity: "10"

Resources:
  LaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateData:
        ImageId: !FindInMap [RegionConfig, !Ref "AWS::Region", AMI]
        InstanceType: !FindInMap [EnvironmentConfig, !Ref Environment, InstanceType]

Change Sets: Safe Updates

Never update production stacks directly. Use change sets to preview what CloudFormation will do before it does it:

# Create a change set
aws cloudformation create-change-set \
  --stack-name my-stack \
  --template-body file://updated-template.yaml \
  --change-set-name update-instance-type \
  --parameters ParameterKey=InstanceType,ParameterValue=m5.xlarge

# Review the changes
aws cloudformation describe-change-set \
  --stack-name my-stack \
  --change-set-name update-instance-type \
  --query 'Changes[].ResourceChange.{Action:Action,Resource:LogicalResourceId,Replacement:Replacement}'

# Execute if changes look correct
aws cloudformation execute-change-set \
  --stack-name my-stack \
  --change-set-name update-instance-type

The output shows whether each resource will be Added, Modified, or Removed, and critically, whether a modification requires Replacement. A replacement means the resource is destroyed and recreated, which can cause downtime or data loss.

Nested Stacks for Large Infrastructures

When templates exceed 200-300 lines, split them into nested stacks. Each nested stack manages one concern:

# root-stack.yaml
Resources:
  NetworkStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.amazonaws.com/my-templates/network.yaml
      Parameters:
        Environment: !Ref Environment
        VpcCidr: "10.0.0.0/16"

  DatabaseStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.amazonaws.com/my-templates/database.yaml
      Parameters:
        Environment: !Ref Environment
        VpcId: !GetAtt NetworkStack.Outputs.VpcId
        PrivateSubnetIds: !GetAtt NetworkStack.Outputs.PrivateSubnetIds

  ApplicationStack:
    Type: AWS::CloudFormation::Stack
    DependsOn: DatabaseStack
    Properties:
      TemplateURL: https://s3.amazonaws.com/my-templates/application.yaml
      Parameters:
        Environment: !Ref Environment
        VpcId: !GetAtt NetworkStack.Outputs.VpcId
        DatabaseEndpoint: !GetAtt DatabaseStack.Outputs.DatabaseEndpoint

Upload child templates to S3 before deploying:

# Upload all templates
aws s3 sync ./templates/ s3://my-templates/ --delete

# Deploy the root stack
aws cloudformation create-stack \
  --stack-name production \
  --template-body file://root-stack.yaml \
  --parameters ParameterKey=Environment,ParameterValue=prod \
  --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND

Cross-Stack References with Exports

An alternative to nested stacks is cross-stack references. One stack exports values, and another stack imports them:

# network-stack.yaml
Outputs:
  VpcId:
    Value: !Ref VPC
    Export:
      Name: !Sub "${AWS::StackName}-VpcId"
  PrivateSubnetA:
    Value: !Ref PrivateSubnetA
    Export:
      Name: !Sub "${AWS::StackName}-PrivateSubnetA"
# app-stack.yaml
Resources:
  AppServer:
    Type: AWS::EC2::Instance
    Properties:
      SubnetId: !ImportValue network-stack-PrivateSubnetA
      VpcId: !ImportValue network-stack-VpcId

Cross-stack references create a dependency. You cannot delete the exporting stack while another stack imports its values.

Handling Failures and Rollbacks

CloudFormation rolls back automatically when resource creation fails, but you can add more control:

Resources:
  Database:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Snapshot
    UpdateReplacePolicy: Snapshot
    Properties:
      # ...

  S3Bucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      # ...
  • DeletionPolicy: Snapshot creates a final snapshot before deleting the resource
  • DeletionPolicy: Retain keeps the resource even when the stack is deleted
  • UpdateReplacePolicy: Snapshot creates a snapshot when a resource must be replaced during an update

For stack-level protection:

# Enable termination protection
aws cloudformation update-termination-protection \
  --enable-termination-protection \
  --stack-name production

# Set a stack policy to prevent updates to critical resources
aws cloudformation set-stack-policy \
  --stack-name production \
  --stack-policy-body '{
    "Statement": [
      {
        "Effect": "Deny",
        "Action": "Update:Replace",
        "Principal": "*",
        "Resource": "LogicalResourceId/Database"
      },
      {
        "Effect": "Allow",
        "Action": "Update:*",
        "Principal": "*",
        "Resource": "*"
      }
    ]
  }'

Drift Detection

Over time, manual changes can cause resources to drift from their template-defined configuration. CloudFormation can detect this:

# Start drift detection
aws cloudformation detect-stack-drift --stack-name my-stack

# Check drift status
aws cloudformation describe-stack-drift-detection-status \
  --stack-drift-detection-id <detection-id>

# View drifted resources
aws cloudformation describe-stack-resource-drifts \
  --stack-name my-stack \
  --stack-resource-drift-status-filters MODIFIED DELETED

Run drift detection regularly, especially before stack updates. Updating a drifted stack can produce unexpected results.

Template Validation and Linting

Catch errors before deploying with these tools:

# Built-in validation (checks syntax only)
aws cloudformation validate-template --template-body file://template.yaml

# cfn-lint checks for common mistakes and best practices
pip install cfn-lint
cfn-lint template.yaml

# cfn-nag checks for security issues
gem install cfn-nag
cfn_nag_scan --input-path template.yaml

Add these to your CI/CD pipeline so every template change is validated before it reaches AWS.

A Complete Production Example

Here is a template that creates a VPC, Application Load Balancer, and Auto Scaling Group:

AWSTemplateFormatVersion: "2010-09-09"
Description: "Production web application infrastructure"

Parameters:
  Environment:
    Type: String
    AllowedValues: [dev, staging, prod]
  AMIId:
    Type: AWS::EC2::Image::Id
    Description: "AMI ID for the web servers"

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true

  ALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Subnets:
        - !Ref PublicSubnetA
        - !Ref PublicSubnetB
      SecurityGroups:
        - !Ref ALBSecurityGroup

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Port: 80
      Protocol: HTTP
      VpcId: !Ref VPC
      HealthCheckPath: /health
      HealthCheckIntervalSeconds: 30

  ASG:
    Type: AWS::AutoScaling::AutoScalingGroup
    UpdatePolicy:
      AutoScalingRollingUpdate:
        MinInstancesInService: 1
        MaxBatchSize: 1
        PauseTime: PT5M
        WaitOnResourceSignals: true
    Properties:
      LaunchTemplate:
        LaunchTemplateId: !Ref LaunchTemplate
        Version: !GetAtt LaunchTemplate.LatestVersionNumber
      MinSize: 2
      MaxSize: 6
      TargetGroupARNs:
        - !Ref TargetGroup
      VPCZoneIdentifier:
        - !Ref PrivateSubnetA
        - !Ref PrivateSubnetB

Outputs:
  LoadBalancerDNS:
    Value: !GetAtt ALB.DNSName
    Description: "ALB DNS name"

The UpdatePolicy on the ASG ensures rolling deployments update one instance at a time, waiting for health checks to pass before proceeding.

Wrapping Up

CloudFormation turns your AWS infrastructure into versioned, reviewable, repeatable code. Start small with individual resources, add parameters and conditions to support multiple environments, and use nested stacks or cross-stack references as your infrastructure grows. Always use change sets for production updates, enable termination protection on critical stacks, and run drift detection regularly to catch manual changes before they cause problems.