Back to blog
Cloud FormationDevOpsAWS

CloudFormation, Without the Fog: My Journey from Console Clicks to Infrastructure as Code

CloudFormation, Without the Fog

My notes on going from “I can create it in the AWS Console” to “I can describe it as code.”

There is something oddly satisfying about creating an AWS resource from the console.

Click a few buttons. Choose a region. Fill in some fields. Hit Create.

It works.

Then, a few weeks later, you need the same thing in another environment.

So you click everything again.

And again.

And eventually you start wondering:

“What if I could just describe what I want and let AWS build it for me?”

That question is where AWS CloudFormation starts to make sense.

This is my learning journal for CloudFormation — starting from the absolute basics and gradually connecting it to the AWS services I work with, especially Lambda and Amazon Lex.


The idea in one sentence

CloudFormation lets you describe AWS infrastructure as code and then have AWS create, update, and manage that infrastructure for you.

Instead of:

AWS Console

Click
Click
Click
Click
Create

you can have:

template.yaml

CloudFormation

AWS resources

That’s the fundamental idea.


1. Before CloudFormation: the Console

Imagine I want an S3 bucket.

Without Infrastructure as Code, I might do this:

AWS Console

S3

Create bucket

Enter bucket name

Configure settings

Create

Nothing is inherently wrong with this.

The problem starts when infrastructure becomes more complicated.

Imagine an application that needs:

Lambda
DynamoDB
IAM Role
S3
API Gateway
CloudWatch

Now imagine recreating all of that for:

DEV
UAT
PROD

Manual configuration becomes tedious and, more importantly, difficult to reproduce reliably.

CloudFormation gives us another approach.


2. Infrastructure as Code

CloudFormation is an Infrastructure as Code (IaC) service.

The idea is simple:

Infrastructure should be describable in a file, just like application code.

For example:

Resources:

  MyBucket:
    Type: AWS::S3::Bucket

This doesn’t tell CloudFormation how to create the bucket step by step.

It tells CloudFormation:

“I want an S3 bucket to exist.”

That distinction is important.


3. Declarative vs Imperative

This is one of the first concepts that clicked for me.

With something like Boto3, I can write:

import boto3

s3 = boto3.client("s3")

s3.create_bucket(...)

I’m essentially saying:

Perform this operation.

That’s an imperative style.

CloudFormation is different.

I write:

Resources:

  MyBucket:
    Type: AWS::S3::Bucket

I’m saying:

This is the state I want.

That’s declarative.

Think about it like this:

Boto3
────────────────────────
"Create this bucket."

CloudFormation
────────────────────────
"I want this bucket to exist."

CloudFormation then determines what AWS API operations are necessary to make reality match the template.


4. Template vs Stack

This distinction confused me initially, so it is worth making very clear.

Template = Blueprint

A CloudFormation template is a YAML or JSON file describing the infrastructure we want.

For example:

Resources:

  MyBucket:
    Type: AWS::S3::Bucket

Think of the template as an architectural blueprint.

Stack = Deployed infrastructure

When we deploy that template, CloudFormation creates a stack.

template.yaml

     │ deploy

MyApplicationStack

     └── S3 Bucket

So:

Template = Blueprint
Stack    = Deployed collection of resources

A stack is essentially the unit CloudFormation uses to create, update, and manage a collection of AWS resources.


5. Your first CloudFormation template

Let’s start with something tiny.

Create:

template.yaml

and put this inside:

AWSTemplateFormatVersion: "2010-09-09"

Resources:

  MyBucket:
    Type: AWS::S3::Bucket

That’s a valid basic CloudFormation template.

Let’s break it down.

AWSTemplateFormatVersion

AWSTemplateFormatVersion: "2010-09-09"

This identifies the template format.

You will see this in many examples.

Resources

Resources:

This is the main section where we define the AWS resources that CloudFormation should manage.

Logical ID

MyBucket:

MyBucket is the logical ID.

It is an identifier used inside the template.

It doesn’t necessarily mean that the physical S3 bucket will literally be named MyBucket.

Resource type

Type: AWS::S3::Bucket

This tells CloudFormation what we’re creating.

The pattern is generally:

AWS::<Service>::<Resource>

Examples include:

AWS::S3::Bucket
AWS::Lambda::Function
AWS::DynamoDB::Table
AWS::IAM::Role

6. Creating the stack

There are several ways to deploy a CloudFormation template.

The easiest way to understand it initially is through the AWS Console.

Go to:

AWS Console

CloudFormation

Create stack

Upload template

Select template.yaml

Choose stack name

Create stack

CloudFormation will then create the resource described by the template.

The result looks conceptually like:

CloudFormation
└── MyApplicationStack
      └── MyBucket
            └── Actual S3 Bucket

7. Deploying from the CLI

Once the concept makes sense, the CLI becomes much more interesting.

For example:

aws cloudformation deploy \
  --template-file template.yaml \
  --stack-name my-first-stack

Now the workflow can become:

template.yaml

Git

AWS CLI / CI/CD

CloudFormation

AWS infrastructure

This is where CloudFormation starts becoming useful in real engineering workflows.


8. Adding properties

Resources can have properties.

For example, let’s enable versioning on our S3 bucket:

AWSTemplateFormatVersion: "2010-09-09"

Resources:

  MyBucket:
    Type: AWS::S3::Bucket
    Properties:
      VersioningConfiguration:
        Status: Enabled

The structure is:

Resource
├── Type
└── Properties
      └── VersioningConfiguration

Different AWS resource types have different properties.

You learn those properties from the AWS CloudFormation resource documentation.


9. Multiple resources in one stack

Now things get more interesting.

A real application rarely consists of a single resource.

For example:

Resources:

  MyBucket:
    Type: AWS::S3::Bucket

  MyTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH

One stack can now contain:

MyApplicationStack

├── S3 Bucket
└── DynamoDB Table

This is where the word stack starts to make more sense.

CloudFormation can manage a collection of related resources as one unit.


10. Resources can depend on each other

Applications often have relationships between resources.

A Lambda function might need an IAM role.

Lambda

   └── needs → IAM Role

CloudFormation can express this relationship.

For example:

Resources:

  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole

  MyFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt LambdaRole.Arn
      Code:
        ZipFile: |
          def lambda_handler(event, context):
              return {
                  "statusCode": 200,
                  "body": "Hello from Lambda!"
              }

The interesting part is:

Role: !GetAtt LambdaRole.Arn

We’re telling CloudFormation:

Get the ARN of the LambdaRole resource and use it here.

This brings us to intrinsic functions.


11. Intrinsic functions

CloudFormation has built-in functions that help resources reference and construct values.

Some of the most useful ones to learn first are:

!Ref
!GetAtt
!Sub

Don’t try to memorize every CloudFormation function immediately.

Start with these three.


12. !Ref

Suppose we have:

Resources:

  MyBucket:
    Type: AWS::S3::Bucket

We can reference that resource with:

!Ref MyBucket

For many resources, Ref returns the resource’s primary identifier.

For example, for an S3 bucket it can resolve to the bucket name.


13. !GetAtt

Sometimes we need a specific attribute of a resource.

For example:

Role: !GetAtt LambdaRole.Arn

This means:

LambdaRole

Get its Arn attribute

Use that value

This is extremely common when connecting AWS resources.


14. !Sub

!Sub is useful for building strings using variables.

For example:

Parameters:

  Environment:
    Type: String
    Default: dev

Resources:

  MyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "my-company-${Environment}-bucket"

If:

Environment = dev

the resulting name could be:

my-company-dev-bucket

If we deploy with:

Environment = prod

we get:

my-company-prod-bucket

This is one of the ways the same template can be reused across environments.


15. Parameters

Hardcoding values is rarely ideal.

CloudFormation gives us Parameters.

For example:

Parameters:

  Environment:
    Type: String
    Default: dev

Now the template can receive a value when the stack is deployed.

A more complete example:

AWSTemplateFormatVersion: "2010-09-09"

Parameters:

  Environment:
    Type: String
    Default: dev
    AllowedValues:
      - dev
      - uat
      - prod

Resources:

  MyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "my-company-${Environment}-bucket"

Now the same template can be used for:

DEV
UAT
PROD

without maintaining three completely different templates.


16. Outputs

Sometimes a stack creates something that other people or systems need to know about.

For example, after creating an S3 bucket, we might want to expose its name.

Outputs:

  BucketName:
    Description: Name of the S3 bucket
    Value: !Ref MyBucket

After deployment, CloudFormation can show:

BucketName
my-company-dev-bucket

Outputs are useful for things like:

S3 bucket name
Lambda ARN
API Gateway URL
DynamoDB table name
Lex bot ID
Lex alias ARN

17. A small complete example

Let’s put several concepts together.

AWSTemplateFormatVersion: "2010-09-09"

Parameters:

  Environment:
    Type: String
    Default: dev
    AllowedValues:
      - dev
      - uat
      - prod

Resources:

  ApplicationBucket:
    Type: AWS::S3::Bucket
    Properties:
      VersioningConfiguration:
        Status: Enabled

  ApplicationTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH

Outputs:

  BucketName:
    Description: Application S3 bucket
    Value: !Ref ApplicationBucket

  TableName:
    Description: Application DynamoDB table
    Value: !Ref ApplicationTable

This template defines:

ApplicationStack

├── S3 Bucket
│   └── Versioning enabled

└── DynamoDB Table
    └── PAY_PER_REQUEST

and exposes both resource names as outputs.


18. What happens when we update the template?

This is where CloudFormation becomes much more powerful than a simple deployment script.

Suppose our current stack is:

MyApplicationStack

├── S3 Bucket
└── DynamoDB Table

Then we modify the template:

MyApplicationStack

├── S3 Bucket
├── DynamoDB Table
└── Lambda Function   ← new

We deploy the updated template.

CloudFormation compares the desired configuration with the current stack and determines what needs to happen.

Conceptually:

Current state

      │ compare

Desired state


CloudFormation

      ├── Create
      ├── Update
      └── Delete

You don’t have to manually tell it:

create this
then create that
then update this

You describe the desired state.


19. CloudFormation is state-aware

This is an important mental model.

Suppose the template says:

Resources:

  MyBucket:
    Type: AWS::S3::Bucket

and the bucket already exists as part of the stack.

Deploying the same template again doesn’t mean:

“Create another bucket.”

CloudFormation knows the resource belongs to the stack and manages its lifecycle.

That is one of the fundamental differences between a CloudFormation deployment and simply running a collection of AWS CLI commands.


20. Change Sets

When working with important environments, especially production, blindly applying changes isn’t a great idea.

CloudFormation has Change Sets.

They let you preview what CloudFormation plans to change.

Conceptually:

New template

CloudFormation

Change Set

"What will change?"

Review

Execute

For example:

Change Set

MyLambda
    Update

MyDynamoDB
    No change

MyBucket
    No change

That gives you an opportunity to catch unintended changes before applying them.


21. Rollbacks

What if an update fails?

CloudFormation can roll back changes depending on the stack operation and configuration.

Conceptually:

Current stack

Update

Something fails

Rollback

Previous stable state

This is particularly valuable when a stack contains several related resources.


22. Drift

Here’s a problem Infrastructure as Code tries to prevent.

Suppose CloudFormation creates:

Lambda Function

Then six months later, someone manually changes its configuration through the AWS Console.

Now we have:

CloudFormation template

Actual AWS configuration

This is called configuration drift.

CloudFormation provides drift detection so you can identify resources whose actual configuration differs from what the stack expects.

This is one reason teams often prefer:

Git

CloudFormation

AWS

instead of:

Everyone

AWS Console

Random changes

23. CloudFormation and Boto3

Since I work with Boto3, this comparison helped me understand CloudFormation.

With Boto3:

import boto3

s3 = boto3.client("s3")

s3.create_bucket(...)

I’m directly interacting with an AWS API.

With CloudFormation:

Resources:

  MyBucket:
    Type: AWS::S3::Bucket

I’m declaring infrastructure.

The conceptual relationship is:

Boto3

AWS API

AWS Service

while:

CloudFormation Template

CloudFormation

AWS APIs

AWS Services

CloudFormation is therefore another layer of abstraction around the infrastructure lifecycle.


24. CloudFormation and the AWS Console

The console isn’t fundamentally a different universe.

When you change something through the AWS Console, the console is also interacting with AWS APIs.

So you can think of:

AWS Console ───────┐

Boto3 ─────────────┼──→ AWS APIs → AWS Services

CloudFormation ────┘

The difference is what each tool is optimized for.

Console
→ Manual interaction

Boto3
→ Programmatic API calls

CloudFormation
→ Declarative infrastructure management

25. CloudFormation vs Terraform

This was another question I had while learning IaC.

Do I need Terraform if I’m using CloudFormation?

Not necessarily.

Both are Infrastructure as Code tools.

For example:

CloudFormation

      AWS

and:

Terraform

      AWS

Both can manage AWS infrastructure.

If your organization already uses CloudFormation, learning CloudFormation first makes a lot of sense.

Terraform becomes particularly interesting when an organization standardizes on it or when infrastructure spans multiple providers.

The important thing is:

Don’t have two IaC tools independently managing the same resource.

For example, avoid:

Terraform ───────┐
                 ├──→ Same Lex Bot
CloudFormation ──┘

That creates ownership and state problems.


26. Why this matters for Amazon Lex

This is where CloudFormation becomes directly relevant to my work.

Today, a Lex workflow might look like:

AWS Console

Lex

Open Bot

Modify Intent

Add Utterance

Configure Slot

Configure Lambda

Build

Version

Update Alias

With Infrastructure as Code, the goal can become:

Git


CloudFormation template


DEV


UAT


PROD

The configuration becomes something we can:

For example, instead of an utterance existing only inside the console:

"I want to check my balance"

it can be represented as configuration in a template.

A simplified Lex configuration might look conceptually like:

BotLocales:
  - LocaleId: en_US

    Intents:

      - Name: BalanceIntent

        SampleUtterances:
          - Utterance: "I want to check my balance"
          - Utterance: "What's my balance?"
          - Utterance: "Show me my balance"

The exact CloudFormation schema and supported properties depend on the Lex V2 resource you’re configuring, but the idea is the same:

The bot configuration becomes part of your source-controlled infrastructure.


27. A realistic AWS architecture

Eventually, a stack could represent something like:

                    CloudFormation Stack

          ┌────────────────┼────────────────┐
          │                │                │
          ▼                ▼                ▼
       Lex Bot          Lambda          DynamoDB
          │                │                │
          │                │                │
          └──────────┬─────┘                │
                     │                      │
                     ▼                      │
               Amazon Connect              │
                     │                      │
                     └──────────────────────┘

And the whole thing can be represented as code.

That is the real promise of Infrastructure as Code.


28. What I actually need to learn

I don’t need to memorize hundreds of CloudFormation properties.

I’d learn these concepts first:

Fundamentals

Template
Stack
Resource
Type
Properties

Connecting resources

Ref
GetAtt
Sub

Reusability

Parameters
Mappings
Conditions

Communication

Outputs

Production management

Change Sets
Rollback
Drift Detection
DeletionPolicy
UpdateReplacePolicy

Advanced

Nested Stacks
StackSets
CloudFormation Modules
CI/CD

I would learn the first group thoroughly before worrying about the advanced features.


29. The mental model I want to remember

After learning the basics, this is the picture I want in my head:

                    TEMPLATE



                 CLOUDFORMATION


                    STACK

          ┌────────────┼────────────┐
          │            │            │
          ▼            ▼            ▼
       Lambda        Lex          DynamoDB
          │            │            │
          └────────────┴────────────┘


                  AWS Services

And the lifecycle:

Write template

Create stack

CloudFormation creates resources

Change template

Create/update stack

CloudFormation calculates changes

Review / Change Set

Apply

Monitor / detect drift

30. Final takeaway

The biggest shift in thinking is this:

Don’t think of CloudFormation as another way to click the AWS Console.

Think of it as a way to turn infrastructure into something that can live alongside your application code.

Instead of saying:

“Someone created this Lex bot three years ago and I don’t know exactly how it was configured.”

you can eventually have:

lex-bot.yaml

in Git, where the configuration is visible, reviewable, reproducible, and deployable.

That’s the part of CloudFormation I find most powerful.

And that is the point where AWS infrastructure starts feeling less like a collection of console settings and more like software engineering.