Skip to content

CloudFormation examples

Worked template fragments. Everything below is written for copying, so the defaults are the safe ones: no open SSH, encryption on by default, AMI IDs resolved rather than pinned, and current instance and storage generations.

Mappings declares a lookup table of fixed values keyed by something known at deploy time — usually Region or environment — and !FindInMap reads from it.

Mappings:
EnvironmentConfig:
dev:
InstanceType: t3.micro
DbInstanceClass: db.t4g.micro
MultiAZ: false
prod:
InstanceType: t3.small
DbInstanceClass: db.t4g.medium
MultiAZ: true
Resources:
WebServerLaunchTemplate:
Type: AWS::EC2::LaunchTemplate
Properties:
LaunchTemplateData:
InstanceType: !FindInMap [EnvironmentConfig, !Ref EnvironmentName, InstanceType]

Use Mappings for values that genuinely vary by Region or environment and have no authoritative source to look up: instance sizing per environment, CIDR allocations, a Region-to-partner-account table.

Do not use it for AMI IDs. A RegionMap of hardcoded AMIs was the standard pattern for years and is now an anti-pattern: the IDs go stale within weeks and the map has to be edited every time a Region is added. Resolve them from the SSM public parameters instead:

LaunchTemplateData:
ImageId: '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64}}'

or, if the ID should be resolved as a template parameter so the change set shows it:

Parameters:
LatestAmiId:
Type: 'AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>'
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64

The template below deploys a VPC with public and private subnets across two Availability Zones, an internet-facing Application Load Balancer terminating TLS, an Auto Scaling group of private instances reachable only from the load balancer, and an encrypted Multi-AZ database. It is a single template; it is split here only for readability.

AWSTemplateFormatVersion: '2010-09-09'
Description: Example web tier - VPC, ALB, Auto Scaling group and database
Parameters:
EnvironmentName:
Type: String
Default: dev
AllowedValues: [dev, prod]
CertificateArn:
Type: String
Description: ACM certificate ARN for the load balancer HTTPS listener
Resources:
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: !Sub '${EnvironmentName}-vpc'
InternetGateway:
Type: AWS::EC2::InternetGateway
AttachGateway:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref Vpc
InternetGatewayId: !Ref InternetGateway
PublicSubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.0.0.0/24
AvailabilityZone: !Select [0, !GetAZs '']
MapPublicIpOnLaunch: true
PublicSubnetB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.0.1.0/24
AvailabilityZone: !Select [1, !GetAZs '']
MapPublicIpOnLaunch: true
PrivateSubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.0.10.0/24
AvailabilityZone: !Select [0, !GetAZs '']
PrivateSubnetB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.0.11.0/24
AvailabilityZone: !Select [1, !GetAZs '']
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
DefaultPublicRoute:
Type: AWS::EC2::Route
DependsOn: AttachGateway
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicSubnetARouteAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnetA
PublicSubnetBRouteAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnetB

Each tier accepts traffic only from the tier in front of it. There is no inbound SSH rule anywhere in this template — administrative access goes through Systems Manager Session Manager, which needs no inbound port at all. An ingress rule of 0.0.0.0/0 on port 22 is flagged as a finding by AWS Config, Security Hub and Trusted Advisor, and it is the first thing an automated scanner looks for.

LoadBalancerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Public HTTPS to the load balancer
VpcId: !Ref Vpc
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
Description: HTTPS from the internet
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 0.0.0.0/0
Description: HTTP, redirected to HTTPS by the listener
WebServerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Application traffic from the load balancer only
VpcId: !Ref Vpc
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 8080
ToPort: 8080
SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup
Description: From the load balancer
DatabaseSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Database access from the application tier only
VpcId: !Ref Vpc
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 3306
ToPort: 3306
SourceSecurityGroupId: !Ref WebServerSecurityGroup
Description: MySQL from the application tier

Instance role, launch template and Auto Scaling group

Section titled “Instance role, launch template and Auto Scaling group”

The instance role carries AmazonSSMManagedInstanceCore, which is what makes Session Manager, Run Command and Patch Manager work on the instance.

InstanceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles:
- !Ref InstanceRole
WebServerLaunchTemplate:
Type: AWS::EC2::LaunchTemplate
Properties:
LaunchTemplateData:
ImageId: '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64}}'
InstanceType: !FindInMap [EnvironmentConfig, !Ref EnvironmentName, InstanceType]
IamInstanceProfile:
Arn: !GetAtt InstanceProfile.Arn
SecurityGroupIds:
- !Ref WebServerSecurityGroup
MetadataOptions:
HttpTokens: required
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeType: gp3
VolumeSize: 20
Encrypted: true
WebServerAsg:
Type: AWS::AutoScaling::AutoScalingGroup
Properties:
VPCZoneIdentifier:
- !Ref PrivateSubnetA
- !Ref PrivateSubnetB
LaunchTemplate:
LaunchTemplateId: !Ref WebServerLaunchTemplate
Version: !GetAtt WebServerLaunchTemplate.LatestVersionNumber
MinSize: 2
MaxSize: 6
DesiredCapacity: 2
HealthCheckType: ELB
HealthCheckGracePeriod: 120
TargetGroupARNs:
- !Ref AlbTargetGroup

MetadataOptions: HttpTokens: required enforces IMDSv2, which closes the SSRF-to-credentials path that IMDSv1 leaves open.

ApplicationLoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Scheme: internet-facing
Subnets:
- !Ref PublicSubnetA
- !Ref PublicSubnetB
SecurityGroups:
- !Ref LoadBalancerSecurityGroup
AlbTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
VpcId: !Ref Vpc
Port: 8080
Protocol: HTTP
TargetType: instance
HealthCheckPath: /health
HealthCheckIntervalSeconds: 15
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3
HttpsListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref ApplicationLoadBalancer
Port: 443
Protocol: HTTPS
SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
Certificates:
- CertificateArn: !Ref CertificateArn
DefaultActions:
- Type: forward
TargetGroupArn: !Ref AlbTargetGroup
HttpRedirectListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref ApplicationLoadBalancer
Port: 80
Protocol: HTTP
DefaultActions:
- Type: redirect
RedirectConfig:
Protocol: HTTPS
Port: '443'
StatusCode: HTTP_301
DbSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: Private subnets for the database
SubnetIds:
- !Ref PrivateSubnetA
- !Ref PrivateSubnetB
DatabaseInstance:
Type: AWS::RDS::DBInstance
DeletionPolicy: Snapshot
UpdateReplacePolicy: Snapshot
Properties:
Engine: mysql
DBInstanceClass: !FindInMap [EnvironmentConfig, !Ref EnvironmentName, DbInstanceClass]
MultiAZ: !FindInMap [EnvironmentConfig, !Ref EnvironmentName, MultiAZ]
AllocatedStorage: 20
StorageType: gp3
StorageEncrypted: true
BackupRetentionPeriod: 7
MasterUsername: dbadmin
ManageMasterUserPassword: true
DBSubnetGroupName: !Ref DbSubnetGroup
VPCSecurityGroups:
- !Ref DatabaseSecurityGroup
PubliclyAccessible: false

ManageMasterUserPassword: true has RDS generate the password and store it in AWS Secrets Manager, with rotation available. This is preferable to a NoEcho parameter, because a NoEcho parameter still has to be supplied by whoever runs the deployment and therefore exists somewhere in plaintext.

DeletionPolicy: Snapshot means deleting the stack takes a final snapshot rather than destroying the data. On any stateful resource this line is worth more than the rest of the template.

DataBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub '${EnvironmentName}-${AWS::AccountId}-data'
VersioningConfiguration:
Status: Enabled
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: aws:kms
LifecycleConfiguration:
Rules:
- Id: TransitionToInfrequentAccess
Status: Enabled
Transitions:
- TransitionInDays: 90
StorageClass: STANDARD_IA

S3 bucket names are globally unique, so naming a bucket after a bare environment string (dev) collides on the first attempt. Including the account ID makes the name unique without making it unpredictable.

Outputs:
VpcId:
Description: VPC ID
Value: !Ref Vpc
Export:
Name: !Sub '${AWS::StackName}-VpcId'
LoadBalancerDns:
Description: DNS name of the Application Load Balancer
Value: !GetAtt ApplicationLoadBalancer.DNSName
DatabaseEndpoint:
Description: Database endpoint address
Value: !GetAtt DatabaseInstance.Endpoint.Address

Run cfn-lint and aws cloudformation validate-template over the assembled template, and generate a change set rather than updating a stack directly. Undefined logical IDs and properties that do not exist on a resource type are the two most common defects in a template copied from documentation, and both are caught in seconds.