Skip to main content

Command Palette

Search for a command to run...

Infrastructure as Code with CloudFormation

Updated
•14 min read•View as Markdown
 Infrastructure as Code with CloudFormation
U

DevOps/Cloud Engineer who loves learning, sharing knowledge and enjoys engaging with others on various topics.

Welcome to my Universe of Learning, where I transform complex ideas into simple forms.

My passion for sharing knowledge fuels my writing, making it accessible and fun.

Introduction

Before I began building CloudFormation templates for this project, I had never used it. I knew Terraform, but CloudFormation was new to me. So, before we get into the templates, I'd like to walk you through everything I learned about CloudFormation. If you're new to IaC, this section is for you.
If you're already familiar with CloudFormation, continue forward to the templates section. If not, stay here. This will save you lots of misunderstanding later.

What is CloudFormation?

CloudFormation is AWS's native infrastructure as code service. It allows you to describe your infrastructure in a file rather than clicking through the AWS Console.
Instead of:

Go to AWS Console → VPC → Create VPC → Fill out forms → Click Create.

You generate a file that says "create a VPC with these settings," and AWS will provision everything for you. That file is called a template and is written in YAML.

The Basic Structure

Every CloudFormation template follows this format

AWSTemplateFormatVersion: "2010-09-09"

Description: What this template does

Parameters:
  # Variables you can pass in

Resources:
  # The actual AWS resources you want to create
  # This is the only required section

Outputs:
  # Values you want to export after the stack is created

Resources: The Heart of Every Template

Every resource follows this exact pattern:

Resources:
  LogicalName:
    Type: AWS::Service::Resource
    Properties:
      PropertyOne: value
      PropertyTwo: value
  • LogicalName: a name you make up to reference this resource inside the template

  • Type: tells AWS what to create

  • Properties: the configuration for that resource

For example, when creating a VPC, you can write this:

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

That is it. Three lines of properties, and AWS creates your VPC.

Parameters: Avoiding Hardcoded Values

Instead of hardcoding values like environment names or instance sizes, you use parameters:

Parameters:
  Environment:
    Type: String
    Default: dev
    AllowedValues:
      - dev
      - staging
      - prod

Resources:
  MyVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-vpc"

!Sub Substitutes the parameter value into the string. If the environment is dev, the tag becomes dev-vpc. If it is prod, it becomes prod-vpc. Same template, different environments.

The Four Functions You Will Use in Every Template

Function What it does Example
!Ref References a resource or parameter !Ref MyVPC
!GetAtt Gets a specific attribute of a resource !GetAtt MyALB.DNSName
!Sub Substitutes variables into a string !Sub "${Environment}-vpc"
!ImportValue Imports an output from another stack

Connecting Resources

This is the most powerful feature of CloudFormation. Resources reference each other using !Ref and !GetAtt. This is how AWS knows to create them in the right order.

Resources:
  MyVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16

  MySubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref MyVPC      # CloudFormation creates VPC first, then the subnet
      CidrBlock: 10.0.1.0/24

When CloudFormation sees !Refer MyVPC inside the subnet, it will automatically create the VPC before the subnet. You do not need to worry about the order.

Outputs: Sharing Values Between Stacks

Because this project divides infrastructure into many stacks, outputs represent how the stacks communicate with one another.

Outputs:
  VPCId:
    Description: The VPC ID
    Value: !Ref MyVPC
    Export:
      Name: !Sub "${Environment}-vpc-id"

Then in another stack:

Properties:
  VpcId: !ImportValue MyProject-vpc-id

Our Stack Structure for This Project

We are splitting the infrastructure into focused stacks that build on each other:

Stack Stack Name Template File Name What it creates
1 microservice-network network.yaml VPC, subnets, internet gateway, NAT gateway, route table
2 microservice-sg sg.yaml Security Groups for the application load balancer, ECS service, and RDS instance.
3 microservice-ecr ecr.yaml ECR repositories for all 4services
4 microservice-rds rds.yaml RDS PostgreSQL instances for 3 services
5 microservice-sqs sqs.yaml SQS queue
6 microservice-iam iam.yaml IAM policies for ECS tasks
7 microservice-ecs ecs.yaml ECS cluster, ECS task definition, ECS Service
8 microservice-lb lb.yaml ALB, API Gateway, WAF
9 microservice-loggings logging.yaml CloudWatch,
10 microservice-alarms alarms.yaml Alarms
11 microservice-insights insights.yaml Container Insights
12 microservice-xrays xrays.yaml X-ray

Each stack exports values that the next stack imports.

Network Stack

This documentation can also be very useful in understanding how to create a VPC with the CloudFormation template.

Step 1: The Top Section

AWSTemplateFormatVersion: "2010-09-09"

Description: Network stack for microservices project - VPC, subnets, internet gateway, NAT gateway and security groups

Parameters:
  Environment:
    Type: String
    Default: dev
    AllowedValues:
      - dev
      - prod
    Description: Environment name used to tag and name all resources

What this does:

  • AWSTemplateFormatVersion : It is always the exact value; it never changes.

  • Description: It describes what the stack does.

  • Parameters: It is used to define one parameter called Environment , so you can reuse this template for dev and prod.

Step 2: The VPC

Resources:

  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-vpc"

What this does:

  • Creates a VPC with IP range 10.0.0.0/16 .

  • EnableDnsSupport and EnableDnsHostnames.

  • !Sub "${Environment}-vpc" — if Environment is dev the VPC is named dev-vpc

Another way to write this is:

Resources:

  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCidr
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-vpc"

This is because it is better not to hardcode values. The CIDR block was referenced here. The CIDR block was stated in the parameter section; this means the parameter will become:

Parameters:
  Environment:
    Type: String
    Default: prod
    AllowedValues:
      - dev
      - prod
    Description: Environment name used to tag and name all resources

  VpcCidr:
  Type: String
  Default: 10.0.0.0/16
  Description: CIDR block for the VPC

Step 3: Internet Gateway

InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-igw"

  AttachInternetGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

What this does:

  • Creates an Internet Gateway: This is what connects your VPC to the internet

  • VPCGatewayAttachment : It attaches the gateway to your VPC. In the console, you do this manually; here, CloudFormation does it automatically

  • Notice !Ref VPC and !Ref InternetGateway: This is how resources reference each other

Step 4: Public Subnets Creation

PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: !Select [ 0, !GetAZs "" ]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-public-subnet-1"

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.2.0/24
      AvailabilityZone: !Select [ 1, !GetAZs "" ]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-public-subnet-2"

What this does:

  • Creates two public subnets in two different availability zones. This is required for the ALB.

  • !Select [ 0, !GetAZs "" ]: It automatically picks the first available AZ in your region. You do not hardcode eu-central-1a. This makes the template work in any region

  • MapPublicIpOnLaunch: true : It makes sure anything launched in this subnet gets a public IP automatically.

Step 5: Private Subnets creation

PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.3.0/24
      AvailabilityZone: !Select [ 0, !GetAZs "" ]
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-private-subnet-1"

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.4.0/24
      AvailabilityZone: !Select [ 1, !GetAZs "" ]
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-private-subnet-2"

What this does:

  • Same as public subnets but no MapPublicIpOnLaunch . These resources here have no public IP.

  • Your ECS tasks and RDS databases will live here because they should never be directly accessible from the internet

Step 6: NAT Gateway

NATGatewayEIP1:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  NATGateway1:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NATGatewayEIP.AllocationId
      SubnetId: !Ref PublicSubnet1
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-nat-gateway"

What this does:

  • EIP: The elastic IP address, a fixed public IP that gets attached to the NAT Gateway

  • NATGateway: It sits in the public subnet and allows private subnet resources to reach the internet without being directly exposed. Your ECS tasks need this to pull Docker images from ECR

  • !GetAtt NATGatewayEIP.AllocationId: It gets the allocation ID of the EIP we just created

Step 7: Route Tables Creation

PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-public-rt"

  PublicRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  PublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicRouteTable

  PublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicRouteTable

  PrivateRouteTable1:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-private-rt"

  PrivateRoute1:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NATGateway

  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable

What this does:

  • The public route table sends all traffic (0.0.0.0/0) through the Internet Gateway.

  • The private route table sends all traffic through the NAT Gateway instead.

  • Associations connect each subnet to its route table.

Step 8: Output Section


Outputs:
  VPCId:
    Value: !Ref VPC
    Export:
      Name: !Sub "${Environment}-vpc-id"

  PublicSubnet1Id:
    Value: !Ref PublicSubnet1
    Export:
      Name: !Sub "${Environment}-public-subnet-1-id"

  PublicSubnet2Id:
    Value: !Ref PublicSubnet2
    Export:
      Name: !Sub "${Environment}-public-subnet-2-id"

  PrivateSubnet1Id:
    Value: !Ref PrivateSubnet1
    Export:
      Name: !Sub "${Environment}-private-subnet-1-id"

  PrivateSubnet2Id:
    Value: !Ref PrivateSubnet2
    Export:
      Name: !Sub "${Environment}-private-subnet-2-id"

What this does:

  • Exports all the IDs that every other stack will need.

  • Every subsequent stack — ECS, RDS, ALB — will import these values using !ImportValue

To deploy, run this command

aws cloudformation deploy \
  --template-file network.yaml \
  --stack-name microservice-network \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides Environment=dev
  • network.yaml is the name of the template file. You can give yours any name.

  • microservice-network is the name of the network stack.

Security Group Stack

AWSTemplateFormatVersion: "2010-09-09"

Description: Security Group stack for microservices project - SG for load balancer, ECS tasks and RDS instance

Parameters:
  Environment:
    Type: String
    Default: dev
    AllowedValues:
      - staging
      - dev
      - prod
    Description: Environment name used to tag and name all resources

  loadBalancerPort:
    Type: Number
    Default: 80
    Description: Port for load balancer to listen on

  RDSPort:
    Type: Number
    Default: 5432
    Description: Port for RDS instance to listen on

  ECSTaskFromPort:
    Type: Number
    Default: 3001
    Description: Port for ECS tasks to listen on

  ECSTaskToPort:
    Type: Number
    Default: 3004
    Description: Port for ECS tasks to listen on

Resources:
  LoadBalancerSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for load balancer
      VpcId: !ImportValue
        Fn::Sub: "${Environment}-vpc-id"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: !Ref loadBalancerPort
          ToPort: !Ref loadBalancerPort 
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-lb-sg"

  ECSTaskSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for ECS tasks
      VpcId: !ImportValue
        Fn::Sub: "${Environment}-vpc-id"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: !Ref ECSTaskFromPort
          ToPort: !Ref ECSTaskToPort
          SourceSecurityGroupId: !Ref LoadBalancerSG
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-ecs-sg"

  RDSSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for RDS instance
      VpcId: !ImportValue
        Fn::Sub: "${Environment}-vpc-id"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: !Ref RDSPort
          ToPort: !Ref RDSPort
          SourceSecurityGroupId: !Ref ECSTaskSG
      Tags:
        - Key: Name
          Value: !Sub "${Environment}-rds-sg" 

This stack follows the same method as the network stack. The only difference is that it receives imported values from the network stack.

These imported values can be written in two ways:

If the output was written like this:


Outputs:
  VPCId:
    Value: !Ref VPC
    Export:
      Name: "VpcID"

It would have been referenced like this VpcId: !ImportValue VPCId in the sg.yaml template. This is because the export name is known.

In the outputs section in the network.yaml template, the export name is not known.


Outputs:
  VPCId:
    Value: !Ref VPC
    Export:
      Name: !Sub "${Environment}-vpc-id"

This is why it was referenced this way in sg.yaml

      VpcId: !ImportValue
        Fn::Sub: "${Environment}-vpc-id"

The sg.yaml template was deployed by running this command:

aws cloudformation deploy \
  --template-file sg.yaml \
  --stack-name microservice-sg \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides Environment=dev

Note: Only the template-file and stack-name changed when running the command to deploy sg.yaml script.

To find other resource documentation, browse CloudFormation documentation on your favourite search engine.

To see other templates for the different parts of the architecture, check my GitHub.

Using a Bash Script as a Command Centre

When I was done writing CloudFormation templates for this project, I quickly realized I had twelve separate template files to deploy. Running 12 individual commands every time I wanted to set up the infrastructure was impractical. I needed one command that could handle everything.

How It Works

The script holds a simple list that maps each template file to its stack name:

STACKS=(
  "network.yaml:microservice-network"
  "sg.yaml:microservice-sg"
  "sqs.yaml:microservice-sqs"
  "iam.yaml:microservice-iam"
  # ...and so on
)

Then it loops through the list and runs the CloudFormation deploy command automatically, replacing the template file and stack name each time:

for entry in "${STACKS[@]}"; do
  TEMPLATE=\((echo \)entry | cut -d: -f1)
  STACK=\((echo \)entry | cut -d: -f2)

  aws cloudformation deploy \
    --template-file $TEMPLATE \
    --stack-name $STACK \
    --capabilities CAPABILITY_NAMED_IAM \
    --parameter-overrides Environment=$ENV
done

The order of the list is intentional. The stack that other stacks depend on always appears higher up. If any stack fails, the script stops immediately, so you never deploy on top of a broken foundation.

One Command to Rule Them All

To deploy the entire infrastructure to a dev environment, run:

./deploy.sh dev

To deploy to production:

./deploy.sh prod

That single command deploys all thirteen stacks in the correct order to the correct environment.

Also, to destroy the entire architecture, as this is a project, run:

./destroy.sh dev

or

./destroy.sh prod

Other Commands You Should Know That Can Be Helpful

When you are deploying CloudFormation stacks, things will not always go smoothly. These two tools became essential parts of my debugging process throughout this project.

1. describe-stack-events

When a stack deployment fails, the first command you should run is:

aws cloudformation describe-stack-events \
  --stack-name your-stack-name \
  --query 'StackEvents[?ResourceStatus==`CREATE_FAILED`].[LogicalResourceId,ResourceStatusReason]' \
  --output table or --output text 

Note: I found --output text easier to scan through

This filters through all the deployment events and shows you only the resources that failed and the exact reason they failed. Without the --query filter, every single event from the deployment is returned. The filter narrows it down to just what matters.

If the filtered command returns nothing, which happened to me several times, run the full output instead:

aws cloudformation describe-stack-events \
  --stack-name your-stack-name \
  --output text

This happens when the failure occurs before any resource is created, which brings me to the second tool.

2. cfn-lint

Some CloudFormation errors happen so early in the deployment process that describe-stack-events returns nothing at all. I experienced this with my RDS stack. I kept getting this error:

microservice-rds FAILED
The following hook(s)/validation failed: [AWS::EarlyValidation::PropertyValidation].
To troubleshoot Early Validation errors, use the DescribeEvents API for detailed failure information.

I ran every AWS debugging command I knew. It either returned nothing or repeated the same message. I spent an entire day going in circles, deleting the stack, fixing what I thought was wrong, redeploying, and hitting the same wall.

Then I came across cfn-lint. This is a tool that validates your CloudFormation templates locally before you even touch AWS. I installed it and ran it against my template:

# Validate a single template
cfn-lint rds.yaml

# Validate all templates at once
cfn-lint *.yaml

The problem showed up immediately. What had taken me a full day of back and forth with AWS commands was identified in seconds by running one local command.

I now run cfn-lint on every template before deploying. It is the infrastructure equivalent of running a spell checker before sending an important email, and it costs nothing and saves you hours.

The difference between describe-stack-events, and cfn-lint is this:

describe-stack-events  → tells you something went wrong during deployment
cfn-lint               → catches problems before deployment even starts

Use both. Run cfn-lint first to catch template errors locally, then use describe-stack-events to investigate anything that fails at runtime.

Screenshots of stacks on CloudFormation

Conclusion

Infrastructure as code is not just about automating deployments. It is about making your infrastructure readable, reviewable, and reproducible. A CloudFormation template is a document that describes exactly what exists in your AWS account and why. Anyone who reads it should be able to understand your architecture without logging into the console.