Skip to content
Cloud infrastructure representing AWS CloudFormation

CloudFormation is free - you only pay for the resources it creates

Last updated: April 2026 - Covers optimistic stabilization (40% faster), Git Sync, IaC Generator, drift-aware change sets, stack refactoring, Guard Hooks, and Fn::ForEach.

Template Structure

AWSTemplateFormatVersion: "2010-09-09"
Description: "Production-ready template structure"

# Optional: metadata for UI grouping, linting config
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: { default: "Network" }
        Parameters: [VpcCidr, SubnetCidr]
      - Label: { default: "Application" }
        Parameters: [Environment, InstanceType]

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

Mappings:
  EnvironmentConfig:
    dev:   { InstanceType: t4g.micro, MultiAZ: false }
    staging: { InstanceType: t4g.small, MultiAZ: false }
    prod:  { InstanceType: m7g.large, MultiAZ: true }

Conditions:
  IsProd: !Equals [!Ref Environment, prod]

Resources:
  # ... your resources here

Outputs:
  VpcId:
    Value: !Ref VPC
    Export:
      Name: !Sub "${AWS::StackName}-VpcId"

Parameters & Dynamic References

SSM Parameter Store References (No Hardcoded Values)

Parameters:
  # Resolve latest AMI from SSM at deploy time
  LatestAmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64

  # Reference your own SSM parameters
  VpcId:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /infrastructure/vpc-id

Resources:
  Database:
    Type: AWS::RDS::DBInstance
    Properties:
      # Secrets Manager dynamic reference - never in template
      MasterUserPassword: !Sub "{{resolve:secretsmanager:${DBSecret}:SecretString:password}}"
      
      # SSM SecureString reference
      ApiKey: "{{resolve:ssm-secure:/app/api-key}}"

Pseudo Parameters

# Available in every template - no declaration needed
Resources:
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "${AWS::StackName}-${AWS::AccountId}-${AWS::Region}-data"
      # Produces: my-stack-123456789012-us-east-1-data

Intrinsic Functions Cheat Sheet

FunctionWhat It DoesExample
!RefReturns resource ID or parameter value!Ref MyBucket → bucket name
!GetAttReturns resource attribute!GetAtt MyBucket.Arn
!SubString interpolation!Sub "arn:aws:s3:::${Bucket}/*"
!JoinJoin array with delimiter!Join [",", [a, b, c]]
!SelectPick item from array!Select [0, !GetAZs ""]
!SplitSplit string into array!Split [",", "a,b,c"]
!FindInMapLookup from Mappings!FindInMap [Config, !Ref Env, Type]
!IfConditional value!If [IsProd, true, false]
Fn::ImportValueCross-stack referenceFn::ImportValue: network-VpcId
Fn::ForEachLoop (new, requires transform)Create N resources from a list
Fn::ToJsonStringObject → JSON stringInline JSON policies
Fn::LengthArray lengthDynamic resource counts

Resource Patterns

Protect Stateful Resources

Database:
  Type: AWS::RDS::DBInstance
  DeletionPolicy: Snapshot        # Take snapshot before delete
  UpdateReplacePolicy: Snapshot   # Take snapshot before replacement
  Properties:
    Engine: postgres
    DBInstanceClass: !FindInMap [EnvironmentConfig, !Ref Environment, InstanceType]
    MultiAZ: !If [IsProd, true, false]

UserUploads:
  Type: AWS::S3::Bucket
  DeletionPolicy: Retain          # Never delete this bucket
  UpdateReplacePolicy: Retain

Custom Resources (Lambda-Backed)

# For anything CloudFormation doesn't natively support
CustomDNSRecord:
  Type: Custom::DNSRecord
  Properties:
    ServiceToken: !GetAtt CustomResourceFunction.Arn
    Domain: app.example.com
    RecordType: CNAME
    Value: !GetAtt ALB.DNSName

Nested Stacks & StackSets

PatternWhen to UseLimit
Nested StacksBreak monolithic templates into layers (network, data, compute)Each gets its own 500-resource limit
Cross-Stack ExportsShare values between independent stacks (VPC ID, subnet IDs)Max 200 exports per account/region
StackSetsDeploy same template across multiple accounts/regionsMulti-account governance, landing zones
Rule of thumb: Keep stacks under 200 resources and ~500 lines. Split by lifecycle - resources that change together stay together. Use SSM parameters over exports for loose coupling.

CI/CD Pipeline

# .github/workflows/cloudformation.yml
name: CloudFormation Deploy
on:
  push:
    branches: [main]
    paths: ['infra/**']
  pull_request:
    branches: [main]
    paths: ['infra/**']

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-cfn
          aws-region: us-east-1

      - name: Lint
        run: pip install cfn-lint && cfn-lint infra/template.yaml

      - name: Guard Policy Check
        run: |
          pip install cfn-guard
          cfn-guard validate -d infra/template.yaml -r infra/rules/

      - name: Create Change Set
        run: |
          aws cloudformation create-change-set \
            --stack-name my-app-prod \
            --template-body file://infra/template.yaml \
            --change-set-name "gh-${{ github.sha }}" \
            --parameters ParameterKey=Environment,ParameterValue=prod

      - name: Review Change Set
        run: |
          aws cloudformation describe-change-set \
            --stack-name my-app-prod \
            --change-set-name "gh-${{ github.sha }}" \
            --query 'Changes[].ResourceChange.{Action:Action,Resource:LogicalResourceId,Type:ResourceType}'

      - name: Execute (main only)
        if: github.ref == 'refs/heads/main'
        run: |
          aws cloudformation execute-change-set \
            --stack-name my-app-prod \
            --change-set-name "gh-${{ github.sha }}"
          aws cloudformation wait stack-update-complete \
            --stack-name my-app-prod
Never use update-stack directly. Always create a change set, review it, then execute. This is the single most important operational practice.

Security & Compliance

Stack Policy - Prevent Accidental Destruction

{
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "Update:*",
      "Principal": "*",
      "Resource": "*"
    },
    {
      "Effect": "Deny",
      "Action": ["Update:Replace", "Update:Delete"],
      "Principal": "*",
      "Resource": "LogicalResourceId/Database"
    }
  ]
}

CloudFormation Guard - Policy as Code

# rules/security.guard
let s3_buckets = Resources.*[ Type == 'AWS::S3::Bucket' ]
let rds_instances = Resources.*[ Type == 'AWS::RDS::DBInstance' ]

rule S3_ENCRYPTION when %s3_buckets !empty {
  %s3_buckets.Properties.BucketEncryption exists
}

rule S3_NO_PUBLIC when %s3_buckets !empty {
  %s3_buckets.Properties.PublicAccessBlockConfiguration {
    BlockPublicAcls == true
    BlockPublicPolicy == true
  }
}

rule RDS_ENCRYPTED when %rds_instances !empty {
  %rds_instances.Properties.StorageEncrypted == true
}

rule RDS_NO_PUBLIC when %rds_instances !empty {
  %rds_instances.Properties.PubliclyAccessible == false
}

Production Template - S3 + CloudFront Static Site

AWSTemplateFormatVersion: "2010-09-09"
Description: "Static site with S3 + CloudFront + OAC"

Parameters:
  DomainName:
    Type: String
  CertificateArn:
    Type: String

Resources:
  SiteBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

  OAC:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Name: !Sub "${AWS::StackName}-oac"
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4

  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        DefaultRootObject: index.html
        Aliases: [!Ref DomainName]
        ViewerCertificate:
          AcmCertificateArn: !Ref CertificateArn
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2021
        Origins:
          - Id: S3Origin
            DomainName: !GetAtt SiteBucket.RegionalDomainName
            OriginAccessControlId: !GetAtt OAC.Id
            S3OriginConfig:
              OriginAccessIdentity: ""
        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: redirect-to-https
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
          Compress: true

  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref SiteBucket
      PolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: cloudfront.amazonaws.com
            Action: s3:GetObject
            Resource: !Sub "${SiteBucket.Arn}/*"
            Condition:
              StringEquals:
                AWS:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}"

Outputs:
  DistributionDomain:
    Value: !GetAtt Distribution.DomainName
  BucketName:
    Value: !Ref SiteBucket

10 Common Mistakes

#MistakeFix
1Circular dependencies (SG A refs B, B refs A)Use standalone AWS::EC2::SecurityGroupIngress resources
2Hard-coded values (AMI IDs, subnet IDs)Use SSM parameters, Mappings, pseudo parameters
3Missing DeletionPolicy on RDS, S3, DynamoDBDeletionPolicy: Snapshot/Retain on all stateful resources
4Not using Conditions (separate templates per env)Single template with !If [IsProd, ...]
5Monolithic templates (1000+ lines)Split into layered nested stacks (<500 lines each)
6Secrets in templates{{resolve:secretsmanager:...}} dynamic references
7Direct update-stackAlways use change sets - review before executing
8Missing UpdateReplacePolicySet on every stateful resource to prevent data loss on replacement
9Using !Ref when !GetAtt needed!Ref ALB = ARN, !GetAtt ALB.DNSName = DNS
10Not tagging resourcesTag everything: Name, Environment, ManagedBy, CostCenter

What's New (2025-2026)

FeatureImpact
Optimistic Stabilization40% faster stack creation - no opt-in needed
Git SyncGitOps for CFN - push to branch, stack auto-deploys
IaC GeneratorGenerate templates from existing unmanaged resources
Drift-Aware Change SetsThree-way diff: new template vs deployed vs actual state
Stack RefactoringMove resources between stacks without recreation
Guard HooksPolicy-as-code enforcement at deploy time
Fn::ForEachLoop over lists to create N resources (requires LanguageExtensions transform)
Fn::ToJsonString / Fn::LengthConvert objects to JSON, get array length
Infrastructure ComposerVisual drag-and-drop template designer

The Bottom Line

CloudFormation is free, has zero state files to manage, automatic rollback on failure, and native drift detection. The 2025-2026 features (optimistic stabilization, Git Sync, IaC Generator, stack refactoring) have closed most of the gaps with Terraform. For AWS-only shops, it's the simplest path to production IaC. Start with change sets, protect stateful resources, lint everything, and keep stacks under 500 lines.