AWS
IaC
CloudFormation Best Practices - Templates, CI/CD & Production Patterns
Template structure, intrinsic functions, nested stacks, change sets, Guard policies, and the 10 mistakes that break production stacks.
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
| Function | What It Does | Example |
|---|---|---|
!Ref | Returns resource ID or parameter value | !Ref MyBucket → bucket name |
!GetAtt | Returns resource attribute | !GetAtt MyBucket.Arn |
!Sub | String interpolation | !Sub "arn:aws:s3:::${Bucket}/*" |
!Join | Join array with delimiter | !Join [",", [a, b, c]] |
!Select | Pick item from array | !Select [0, !GetAZs ""] |
!Split | Split string into array | !Split [",", "a,b,c"] |
!FindInMap | Lookup from Mappings | !FindInMap [Config, !Ref Env, Type] |
!If | Conditional value | !If [IsProd, true, false] |
Fn::ImportValue | Cross-stack reference | Fn::ImportValue: network-VpcId |
Fn::ForEach | Loop (new, requires transform) | Create N resources from a list |
Fn::ToJsonString | Object → JSON string | Inline JSON policies |
Fn::Length | Array length | Dynamic 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
| Pattern | When to Use | Limit |
|---|---|---|
| Nested Stacks | Break monolithic templates into layers (network, data, compute) | Each gets its own 500-resource limit |
| Cross-Stack Exports | Share values between independent stacks (VPC ID, subnet IDs) | Max 200 exports per account/region |
| StackSets | Deploy same template across multiple accounts/regions | Multi-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
| # | Mistake | Fix |
|---|---|---|
| 1 | Circular dependencies (SG A refs B, B refs A) | Use standalone AWS::EC2::SecurityGroupIngress resources |
| 2 | Hard-coded values (AMI IDs, subnet IDs) | Use SSM parameters, Mappings, pseudo parameters |
| 3 | Missing DeletionPolicy on RDS, S3, DynamoDB | DeletionPolicy: Snapshot/Retain on all stateful resources |
| 4 | Not using Conditions (separate templates per env) | Single template with !If [IsProd, ...] |
| 5 | Monolithic templates (1000+ lines) | Split into layered nested stacks (<500 lines each) |
| 6 | Secrets in templates | {{resolve:secretsmanager:...}} dynamic references |
| 7 | Direct update-stack | Always use change sets - review before executing |
| 8 | Missing UpdateReplacePolicy | Set on every stateful resource to prevent data loss on replacement |
| 9 | Using !Ref when !GetAtt needed | !Ref ALB = ARN, !GetAtt ALB.DNSName = DNS |
| 10 | Not tagging resources | Tag everything: Name, Environment, ManagedBy, CostCenter |
What's New (2025-2026)
| Feature | Impact |
|---|---|
| Optimistic Stabilization | 40% faster stack creation - no opt-in needed |
| Git Sync | GitOps for CFN - push to branch, stack auto-deploys |
| IaC Generator | Generate templates from existing unmanaged resources |
| Drift-Aware Change Sets | Three-way diff: new template vs deployed vs actual state |
| Stack Refactoring | Move resources between stacks without recreation |
| Guard Hooks | Policy-as-code enforcement at deploy time |
| Fn::ForEach | Loop over lists to create N resources (requires LanguageExtensions transform) |
| Fn::ToJsonString / Fn::Length | Convert objects to JSON, get array length |
| Infrastructure Composer | Visual 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.