Lab 05 — Infrastructure as Code: CloudFormation


Series: Le Café ☕ — AWS Hands-On Labs with LocalStack
Level: Intermediate → Advanced | Duration: ~100 min
Prerequisites: Labs 00–04 complete, LocalStack running, awslocal configured


🎯 Learning Objectives

By the end of this lab you will be able to:

  • Explain what Infrastructure as Code is and why it is a cornerstone of modern DevOps
  • Understand the structure of a CloudFormation template and how its sections relate to each other
  • Write a complete CloudFormation template that defines IAM, S3, SQS, and SNS resources
  • Deploy, update, and delete a CloudFormation stack using the CLI
  • Use parameters, outputs, and intrinsic functions to write reusable, dynamic templates
  • Understand how CloudFormation manages dependencies between resources automatically
  • Recognise the operational advantages of IaC over manual provisioning

🏪 Scenario — Le Café Grows Up

Over the course of this lab series, you have built Le Café’s cloud infrastructure entirely by hand — typing CLI commands one at a time, copy-pasting resource IDs between steps, and carefully remembering the correct deletion order during cleanup. This approach works for learning, but it has serious problems in a real engineering team.

What happens when a new developer joins and needs to set up the same environment? They follow the lab, make a mistake on Step 7, and end up with a slightly different configuration from everyone else. What happens when Le Café opens a second location and needs to replicate the infrastructure? Someone repeats every command again, hoping nothing is different. What happens after a disaster and the infrastructure needs to be rebuilt? Nobody is quite sure what was configured or in what order.

Infrastructure as Code solves all of these problems at once. In this final lab, you will take everything you have built manually across Labs 00–04 and express it as a single CloudFormation template — a precise, version-controllable, repeatable description of the entire Le Café stack. Once this template exists, deploying a complete environment takes one command and two minutes.


🧠 Concept — What Infrastructure as Code Really Means

The phrase “Infrastructure as Code” can sound like marketing jargon, so let’s ground it in something concrete. The core idea is that your infrastructure — the servers, queues, buckets, roles, and networking — should be described in text files that live in a Git repository alongside your application code, go through code review, have a history of changes, and can be deployed or rolled back just like software.

This shifts infrastructure from a manual craft (someone types commands) to an engineering discipline (someone writes and reviews a specification). The shift has four profound consequences.

Reproducibility means that running the template twice produces identical results. There is no “it works on my machine” problem because the template is the machine’s definition. Whether you deploy to LocalStack on a laptop or to real AWS in a production account, the same template produces the same infrastructure.

Version control means that every change to your infrastructure is a commit. You can see who changed what, when, and why. You can diff two versions of your infrastructure the same way you diff application code. If a configuration change causes an outage, you can pinpoint exactly which commit introduced it.

Automation means that your CI/CD pipeline can deploy infrastructure changes automatically after a code review is approved, exactly the same way it deploys application code changes. Infrastructure updates no longer require someone to log into a console or run commands manually.

Documentation is a hidden benefit that practitioners often cite as the most practically valuable. A well-written CloudFormation template is simultaneously the definition of your infrastructure and its documentation. When a new engineer joins the team, they read the template and immediately understand the entire architecture — something that would otherwise require hours of discovery, reading documentation, and asking colleagues.

💡 Before going further, pause and think about this: In Labs 01 through 04, the cleanup sections required careful attention to deletion order — you had to remove role policies before roles, unsubscribe before deleting topics, empty buckets before removing them. This is because the resources have dependencies on each other. When you write a CloudFormation template, you declare those dependencies once, and CloudFormation handles both creation order and deletion order automatically, every time. This alone saves significant operational effort.

CloudFormation’s Declarative Model

CloudFormation uses a declarative approach, which is conceptually different from the imperative approach you have used throughout this lab series. Understanding this distinction is important.

When you type awslocal sqs create-queue --queue-name lecafe-kitchen-orders, you are giving an imperative instruction: “create this queue right now.” The CLI executes your command and moves on. It does not know or care what other resources you have, what state things are in, or whether this queue already exists.

When you write a CloudFormation template that describes an SQS queue and deploy it, you are making a declarative statement: “I want a queue with these properties to exist.” CloudFormation compares that statement against the current state of your AWS account. If the queue does not exist, it creates it. If it exists with different properties, it updates it. If you remove the queue from the template and redeploy, CloudFormation deletes it. You describe the desired end state; CloudFormation figures out the sequence of actions needed to reach it.

This is the same philosophical model used by Kubernetes (pod specifications), Terraform (resource definitions), and Ansible (playbooks). Once you understand declarative infrastructure in CloudFormation, you will recognise the pattern everywhere in modern DevOps tooling.


🏗️ Architecture — The Complete Le Café Stack

This template will define every resource from the previous four labs in a single, unified specification.

CloudFormation Stack: lecafe-stack
│
├── IAM
│   ├── Role:            LeCafeAppRole        (EC2 can assume it)
│   ├── InstanceProfile: LeCafeAppProfile     (wraps the role for EC2)
│   └── Policy (inline): App permissions      (S3 read + SQS write)
│
├── S3
│   ├── Bucket:  LeCafeAssets                 (versioning enabled)
│   └── Bucket:  LeCafeLogs                   (lifecycle: expire after 30 days)
│
├── SQS
│   ├── Queue:   LeCafeKitchenOrdersDLQ       (dead letter queue)
│   ├── Queue:   LeCafeKitchenOrders          (standard, DLQ attached)
│   ├── Queue:   LeCafeInventoryUpdates       (SNS subscriber)
│   ├── Queue:   LeCafeLoyaltyPoints          (SNS subscriber)
│   └── Queue:   LeCafeManagerAlerts          (SNS subscriber, filtered)
│
└── SNS
    ├── Topic:         LeCafeOrdersTopic
    ├── Subscription:  → LeCafeInventoryUpdates  (all orders)
    ├── Subscription:  → LeCafeLoyaltyPoints      (all orders)
    └── Subscription:  → LeCafeManagerAlerts      (high-priority only)

📄 Concept — Anatomy of a CloudFormation Template

A CloudFormation template is a YAML (or JSON) document with up to seven top-level sections. You do not need all of them in every template, but understanding what each one does is essential before you start writing.

The AWSTemplateFormatVersion field declares which version of the template language you are using. It is always "2010-09-09" — like the Version field in IAM policy documents, this is a language version, not a date you change.

The Description field is a human-readable string describing what the template does. It appears in the CloudFormation console and is the first thing a colleague reads when they open the template. Write it as if explaining the template to someone who has never seen it before.

The Parameters section defines inputs that you can provide at deployment time to customise the template without editing it. For example, an EnvironmentName parameter (with values like development, staging, production) lets you use the same template to deploy multiple isolated environments.

The Resources section is the only mandatory section. It is where you declare every AWS resource the template should create. Each resource has a logical ID (a name you choose, used to reference the resource elsewhere in the template), a Type (the AWS resource type, like AWS::SQS::Queue), and a Properties block (the configuration for that resource).

The Outputs section declares values that CloudFormation should display after deployment — things like queue URLs, bucket names, and role ARNs. Outputs are how you pass information from one stack to another, and they are invaluable for connecting your infrastructure to your application deployment pipeline.

The Mappings and Conditions sections allow advanced logic — for example, using different instance types in different regions, or conditionally creating resources only in production environments. We will not use these in this lab, but knowing they exist helps you understand the full power of the template language.


⚙️ Part 1 — Build the Template Section by Section

Rather than presenting the full template at once, we will build it incrementally — one section at a time — so you understand every decision before moving on. This mirrors how experienced engineers actually write CloudFormation: section by section, validating as they go.

Step 1 — Start LocalStack and Prepare Your Workspace

localstack start -d
localstack status services
export AWS_PROFILE=localstack

# Create a working directory for the template
mkdir -p ~/lecafe-iac && cd ~/lecafe-iac

Step 2 — Write the Template Header and Parameters

Create the file and begin with the header and parameters section.

cat > lecafe-stack.yaml << 'YAML'
AWSTemplateFormatVersion: "2010-09-09"

Description: >
  Le Café complete infrastructure stack.
  Defines IAM roles, S3 buckets, SQS queues, and SNS topics
  for the Le Café ordering and notification platform.
  Designed for deployment with LocalStack (development) and
  real AWS (staging/production) using the same template.

# ---------------------------------------------------------------------------
# Parameters let you customise the stack at deploy time without editing the
# template itself. The same template can deploy a "development" stack and a
# "production" stack with different names, retention settings, and budgets.
# ---------------------------------------------------------------------------
Parameters:

  EnvironmentName:
    Type: String
    Default: development
    AllowedValues:
      - development
      - staging
      - production
    Description: >
      The environment this stack is being deployed into.
      Used to suffix resource names and adjust retention settings.

  LogRetentionDays:
    Type: Number
    Default: 30
    MinValue: 1
    MaxValue: 365
    Description: >
      Number of days before log objects in the logs bucket are
      automatically expired. Shorter in development, longer in production.

  OrderQueueVisibilityTimeout:
    Type: Number
    Default: 30
    Description: >
      Seconds a received message is hidden from other consumers.
      Set this to slightly longer than your worst-case processing time.

YAML
echo "Header and parameters written."

Notice the AllowedValues constraint on EnvironmentName. CloudFormation validates parameter values before deployment begins — if you pass EnvironmentName=typo, the deployment fails immediately with a clear error rather than creating resources with incorrect names. This is your first layer of defence against misconfigured deployments.

Step 3 — Add the IAM Resources

Append the Resources section, starting with the IAM role and instance profile.

cat >> lecafe-stack.yaml << 'YAML'

# ---------------------------------------------------------------------------
# Resources is the only mandatory section. Every AWS resource the stack
# manages is declared here. The logical IDs (like "LeCafeAppRole") are
# names you choose — they must be unique within the template and are used
# to reference resources from other parts of the template.
# ---------------------------------------------------------------------------
Resources:

  # ── IAM ──────────────────────────────────────────────────────────────────

  # The application role allows EC2 instances to authenticate to AWS
  # without storing long-lived credentials on disk.
  LeCafeAppRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "lecafe-app-role-${EnvironmentName}"
      Description: Assumed by EC2 instances running the Le Cafe ordering app
      # The AssumeRolePolicyDocument is the trust policy — it defines WHO
      # is allowed to assume this role. Here, the EC2 service can assume it.
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: sts:AssumeRole
      # Inline policies are embedded directly in the role. Use them for
      # permissions that are tightly coupled to this specific role and
      # will never need to be reused by another role or user.
      Policies:
        - PolicyName: LeCafeAppPermissions
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Allow reading assets from the S3 bucket.
              # Note the !GetAtt intrinsic function — it retrieves the ARN
              # of the LeCafeAssetsBucket resource defined later in this
              # template. CloudFormation resolves this reference at deploy
              # time, automatically establishing the dependency so the
              # bucket is created before this policy is evaluated.
              - Sid: AllowS3AssetRead
                Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:ListBucket
                Resource:
                  - !GetAtt LeCafeAssetsBucket.Arn
                  - !Sub "${LeCafeAssetsBucket.Arn}/*"
              # Allow the app to send orders to the kitchen SQS queue.
              - Sid: AllowSQSOrderWrite
                Effect: Allow
                Action:
                  - sqs:SendMessage
                  - sqs:GetQueueUrl
                  - sqs:GetQueueAttributes
                Resource: !GetAtt LeCafeKitchenOrders.Arn
      Tags:
        - Key: Project
          Value: LeCafe
        - Key: Environment
          Value: !Ref EnvironmentName

  # EC2 requires an instance profile as the bridge between an instance and
  # an IAM role. The profile wraps the role — without it, you cannot attach
  # a role to an EC2 instance via the CLI or CloudFormation.
  LeCafeAppInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: !Sub "lecafe-app-profile-${EnvironmentName}"
      Roles:
        - !Ref LeCafeAppRole  # !Ref on a role returns the role's name

YAML
echo "IAM resources written."

This is a good moment to pause and appreciate what !GetAtt and !Ref are doing. These are intrinsic functions — CloudFormation’s built-in tools for making your template dynamic and self-referential. !Ref returns the primary identifier of a resource (usually its name or ID). !GetAtt retrieves a specific attribute of a resource (like its ARN, URL, or any other property). When you write !GetAtt LeCafeAssetsBucket.Arn inside the IAM role policy, you are saying “use the ARN of whichever bucket this template creates, whatever it turns out to be.” CloudFormation reads this reference, understands that the bucket must be created before the role policy can be finalised, and automatically sequences the creation in the correct order. You never manually manage creation order in CloudFormation — references do it for you.

Step 4 — Add the S3 Resources

cat >> lecafe-stack.yaml << 'YAML'

  # ── S3 ───────────────────────────────────────────────────────────────────

  # The assets bucket stores application files the ordering app reads.
  # Versioning is enabled so accidental overwrites can be recovered.
  LeCafeAssetsBucket:
    Type: AWS::S3::Bucket
    # DeletionPolicy controls what happens to this bucket when the stack
    # is deleted. "Retain" means the bucket and its contents survive stack
    # deletion — useful for production data you never want accidentally
    # removed. For development, "Delete" is more convenient.
    DeletionPolicy: Retain
    Properties:
      # !Sub substitutes variable values into a string.
      # ${EnvironmentName} is replaced with the parameter value at deploy time.
      BucketName: !Sub "lecafe-assets-${EnvironmentName}"
      VersioningConfiguration:
        Status: Enabled
      # Server-side encryption protects data at rest. In real AWS this is
      # non-negotiable for any bucket containing application data.
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      Tags:
        - Key: Project
          Value: LeCafe
        - Key: Environment
          Value: !Ref EnvironmentName

  # The logs bucket receives application log files.
  # A lifecycle rule automatically deletes objects after LogRetentionDays,
  # controlling storage costs without manual cleanup.
  LeCafeLogsBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      BucketName: !Sub "lecafe-logs-${EnvironmentName}"
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - Id: DeleteOldLogs
            Status: Enabled
            # An empty filter applies the rule to every object in the bucket
            Prefix: ""
            ExpirationInDays: !Ref LogRetentionDays
            # Also expire old non-current versions to prevent version
            # accumulation if versioning is ever enabled on this bucket
            NoncurrentVersionExpiration:
              NoncurrentDays: 7
      Tags:
        - Key: Project
          Value: LeCafe
        - Key: Environment
          Value: !Ref EnvironmentName

YAML
echo "S3 resources written."

Step 5 — Add the SQS Resources

cat >> lecafe-stack.yaml << 'YAML'

  # ── SQS ──────────────────────────────────────────────────────────────────

  # The Dead Letter Queue must be created BEFORE the main kitchen queue
  # because the kitchen queue's RedrivePolicy references the DLQ's ARN.
  # CloudFormation infers this dependency automatically from the !GetAtt
  # reference — you do not need to declare it manually.
  LeCafeKitchenOrdersDLQ:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: !Sub "lecafe-kitchen-orders-dlq-${EnvironmentName}"
      # Retain messages in the DLQ for 14 days (maximum) to give the
      # engineering team time to investigate and replay failed messages.
      MessageRetentionPeriod: 1209600
      Tags:
        - Key: Project
          Value: LeCafe
        - Key: Environment
          Value: !Ref EnvironmentName

  # The main kitchen orders queue receives individual order messages from
  # the ordering application. The kitchen display polls this queue.
  LeCafeKitchenOrders:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: !Sub "lecafe-kitchen-orders-${EnvironmentName}"
      VisibilityTimeout: !Ref OrderQueueVisibilityTimeout
      # Retain messages for 24 hours — orders older than a day are stale
      MessageRetentionPeriod: 86400
      # Long polling: wait up to 20s for a message before returning empty.
      # This reduces unnecessary API calls when the queue is idle.
      ReceiveMessageWaitTimeSeconds: 20
      # The RedrivePolicy wires this queue to its DLQ. After 3 failed
      # receive attempts, SQS moves the message to the DLQ automatically.
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt LeCafeKitchenOrdersDLQ.Arn
        maxReceiveCount: 3
      Tags:
        - Key: Project
          Value: LeCafe
        - Key: Environment
          Value: !Ref EnvironmentName

  # Downstream queues for the SNS fan-out pipeline.
  # Each system gets its own isolated queue so failures are contained.
  LeCafeInventoryUpdates:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: !Sub "lecafe-inventory-updates-${EnvironmentName}"
      MessageRetentionPeriod: 86400
      ReceiveMessageWaitTimeSeconds: 20
      Tags:
        - Key: Project
          Value: LeCafe
        - Key: Environment
          Value: !Ref EnvironmentName

  LeCafeLoyaltyPoints:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: !Sub "lecafe-loyalty-points-${EnvironmentName}"
      MessageRetentionPeriod: 86400
      ReceiveMessageWaitTimeSeconds: 20
      Tags:
        - Key: Project
          Value: LeCafe
        - Key: Environment
          Value: !Ref EnvironmentName

  LeCafeManagerAlerts:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: !Sub "lecafe-manager-alerts-${EnvironmentName}"
      MessageRetentionPeriod: 86400
      ReceiveMessageWaitTimeSeconds: 20
      Tags:
        - Key: Project
          Value: LeCafe
        - Key: Environment
          Value: !Ref EnvironmentName

  # ── SQS Queue Policies ───────────────────────────────────────────────────

  # Each downstream queue needs a resource policy that grants the SNS topic
  # permission to call sqs:SendMessage on it. Without this policy, SNS
  # will silently fail to deliver messages — one of the most common
  # debugging headaches in SNS/SQS integration work.
  # We define a single policy resource that covers all three queues.
  LeCafeSNSToSQSPolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref LeCafeInventoryUpdates
        - !Ref LeCafeLoyaltyPoints
        - !Ref LeCafeManagerAlerts
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowSNSPublishToQueues
            Effect: Allow
            Principal:
              Service: sns.amazonaws.com
            Action: sqs:SendMessage
            # The Resource wildcard here applies to all queues listed above.
            Resource: "*"
            Condition:
              ArnEquals:
                # Only this specific SNS topic may send to these queues —
                # not any other topic, and not any other service.
                aws:SourceArn: !Ref LeCafeOrdersTopic

YAML
echo "SQS resources written."

Step 6 — Add the SNS Resources

cat >> lecafe-stack.yaml << 'YAML'

  # ── SNS ──────────────────────────────────────────────────────────────────

  # The central orders topic is the single publish point for the ordering
  # application. It fans out to all downstream subscribers automatically.
  LeCafeOrdersTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Sub "lecafe-orders-topic-${EnvironmentName}"
      # A display name appears as the sender name in email notifications
      DisplayName: Le Cafe Orders
      Tags:
        - Key: Project
          Value: LeCafe
        - Key: Environment
          Value: !Ref EnvironmentName

  # ── SNS Subscriptions ─────────────────────────────────────────────────────

  # Each subscription wires one SQS queue to the SNS topic.
  # CloudFormation automatically confirms SQS subscriptions —
  # no manual confirmation step is needed.
  InventorySubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref LeCafeOrdersTopic
      Protocol: sqs
      Endpoint: !GetAtt LeCafeInventoryUpdates.Arn
      # RawMessageDelivery: true sends the raw message body without the
      # SNS envelope wrapper, which simplifies consumer parsing.
      RawMessageDelivery: false

  LoyaltySubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref LeCafeOrdersTopic
      Protocol: sqs
      Endpoint: !GetAtt LeCafeLoyaltyPoints.Arn
      RawMessageDelivery: false

  # The manager subscription includes a filter policy so only high-priority
  # orders are delivered. The ordering app publishes all orders to the topic;
  # SNS handles the routing without the app needing to know about it.
  ManagerAlertSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref LeCafeOrdersTopic
      Protocol: sqs
      Endpoint: !GetAtt LeCafeManagerAlerts.Arn
      RawMessageDelivery: false
      # FilterPolicy is a JSON string that instructs SNS to only deliver
      # messages whose attributes match these criteria.
      FilterPolicy: '{"Priority": ["high"]}'

YAML
echo "SNS resources written."

Step 7 — Add the Outputs Section

Outputs are how CloudFormation communicates useful information back to you — and to other stacks — after deployment. Think of them as the “what did we just create and how do I use it?” section of the template.

cat >> lecafe-stack.yaml << 'YAML'

# ---------------------------------------------------------------------------
# Outputs declare values that CloudFormation displays after a successful
# deployment. They appear in the console and are queryable via the CLI.
# Outputs with an Export block can be imported by other stacks — this is
# how large architectures are split across multiple templates.
# ---------------------------------------------------------------------------
Outputs:

  StackEnvironment:
    Description: The environment this stack was deployed into
    Value: !Ref EnvironmentName

  AssetsBucketName:
    Description: Name of the S3 bucket storing application assets
    Value: !Ref LeCafeAssetsBucket
    Export:
      Name: !Sub "${AWS::StackName}-AssetsBucket"

  AssetsBucketArn:
    Description: ARN of the assets S3 bucket
    Value: !GetAtt LeCafeAssetsBucket.Arn

  LogsBucketName:
    Description: Name of the S3 bucket storing application logs
    Value: !Ref LeCafeLogsBucket

  KitchenQueueUrl:
    Description: URL of the kitchen orders SQS queue (used by the ordering app)
    Value: !Ref LeCafeKitchenOrders
    Export:
      Name: !Sub "${AWS::StackName}-KitchenQueueUrl"

  KitchenQueueArn:
    Description: ARN of the kitchen orders queue
    Value: !GetAtt LeCafeKitchenOrders.Arn

  DLQUrl:
    Description: URL of the kitchen orders dead letter queue (monitor this for failures)
    Value: !Ref LeCafeKitchenOrdersDLQ

  OrdersTopicArn:
    Description: ARN of the central SNS orders topic (publish all orders here)
    Value: !Ref LeCafeOrdersTopic
    Export:
      Name: !Sub "${AWS::StackName}-OrdersTopicArn"

  AppRoleArn:
    Description: ARN of the IAM role for EC2 instances (attach via instance profile)
    Value: !GetAtt LeCafeAppRole.Arn

  AppInstanceProfileName:
    Description: Name of the instance profile to attach to EC2 instances
    Value: !Ref LeCafeAppInstanceProfile

YAML
echo "Outputs written. Template is complete."

🚀 Part 2 — Deploy and Inspect the Stack

Step 8 — Validate the Template

Before deploying, always validate your template. Validation catches syntax errors and many logical mistakes without making any changes to your infrastructure.

awslocal cloudformation validate-template \
  --template-body file://lecafe-stack.yaml

A successful response returns the list of parameters the template accepts. If you see an error, read it carefully — CloudFormation’s validation errors are specific and point directly to the problem. Common issues include mismatched indentation (YAML is whitespace-sensitive), an !Ref pointing to a logical ID that does not exist, or a resource type name misspelled.

Step 9 — Deploy the Stack

The create-stack command sends the entire template to CloudFormation in one operation. CloudFormation reads the template, builds a dependency graph of all resources, and creates them in the correct order.

awslocal cloudformation create-stack \
  --stack-name lecafe-stack \
  --template-body file://lecafe-stack.yaml \
  --parameters \
    ParameterKey=EnvironmentName,ParameterValue=development \
    ParameterKey=LogRetentionDays,ParameterValue=30 \
    ParameterKey=OrderQueueVisibilityTimeout,ParameterValue=30 \
  --capabilities CAPABILITY_NAMED_IAM

echo "Stack creation initiated."

The --capabilities CAPABILITY_NAMED_IAM flag is required whenever your template creates IAM resources with custom names. It is CloudFormation’s way of making you explicitly acknowledge that you are creating identity and access resources — a deliberate friction point to prevent accidental privilege escalation. If you omit it, the deployment fails immediately with a clear error asking you to add it.

Step 10 — Monitor the Deployment Progress

CloudFormation creates resources asynchronously. The create-stack command returns almost immediately, but the actual resource creation happens in the background. Use describe-stack-events to follow the progress.

# Watch the stack creation events in real time
awslocal cloudformation describe-stack-events \
  --stack-name lecafe-stack \
  --query 'StackEvents[*].{Time:Timestamp,Resource:LogicalResourceId,Status:ResourceStatus,Reason:ResourceStatusReason}' \
  --output table

Each row in the output represents one event in the lifecycle of one resource. You will see events like CREATE_IN_PROGRESS, CREATE_COMPLETE, and occasionally CREATE_FAILED. Reading this output teaches you how CloudFormation thinks: it creates resources in dependency order, moves to the next resource as soon as the previous one completes, and parallelises where there are no dependencies. If any resource fails, CloudFormation automatically rolls back all previously created resources, leaving your account in a clean state.

# Check the overall stack status — wait for CREATE_COMPLETE
awslocal cloudformation describe-stacks \
  --stack-name lecafe-stack \
  --query 'Stacks[0].StackStatus' \
  --output text

Step 11 — Inspect the Stack Outputs

Once the stack reaches CREATE_COMPLETE, retrieve the outputs to see all the resource identifiers your applications need.

awslocal cloudformation describe-stacks \
  --stack-name lecafe-stack \
  --query 'Stacks[0].Outputs[*].{Key:OutputKey,Value:OutputValue,Description:Description}' \
  --output table

This output is the contract between your infrastructure and your application. A CI/CD pipeline deploying the Le Café application would query these outputs to discover where to send orders, which bucket to read assets from, and which instance profile to attach to new EC2 instances — without hardcoding any of those values in application configuration. The infrastructure and the application are decoupled through this outputs interface.


🔄 Part 3 — Update the Stack

One of CloudFormation’s most powerful features is the ability to update a deployed stack by modifying the template and redeploying. CloudFormation computes a change set — a diff between the current and desired state — and applies only the necessary changes.

Step 12 — Create a Change Set Before Applying

Rather than applying updates blindly, the professional practice is to create a change set first, review what will change, and only then execute it. This is the equivalent of a git diff before a git commit.

# Add a new parameter to the template — a maximum receive count for the DLQ redrive policy
# (We will simulate a simple change: updating the stack description)
awslocal cloudformation create-change-set \
  --stack-name lecafe-stack \
  --change-set-name update-retention \
  --template-body file://lecafe-stack.yaml \
  --parameters \
    ParameterKey=EnvironmentName,ParameterValue=development \
    ParameterKey=LogRetentionDays,ParameterValue=14 \
    ParameterKey=OrderQueueVisibilityTimeout,ParameterValue=30 \
  --capabilities CAPABILITY_NAMED_IAM

echo "Change set created — review before executing."
# Inspect what the change set will do before committing
awslocal cloudformation describe-change-set \
  --stack-name lecafe-stack \
  --change-set-name update-retention \
  --query 'Changes[*].{Action:ResourceChange.Action,Resource:ResourceChange.LogicalResourceId,Replace:ResourceChange.Replacement}' \
  --output table

The output tells you exactly which resources will be added, modified, or removed, and whether a modification requires replacement (the resource will be deleted and recreated, which may cause downtime) or just an update (properties are changed in place). Knowing whether a change requires replacement is critical in production — a replacement of a database or a queue means data loss unless you have planned for it.

# Execute the change set — apply the update
awslocal cloudformation execute-change-set \
  --stack-name lecafe-stack \
  --change-set-name update-retention

# Monitor the update
awslocal cloudformation describe-stacks \
  --stack-name lecafe-stack \
  --query 'Stacks[0].StackStatus' \
  --output text

Step 13 — Verify the Update Took Effect

# Confirm the logs bucket lifecycle rule now reflects the updated retention period
awslocal s3api get-bucket-lifecycle-configuration \
  --bucket lecafe-logs-development \
  --query 'Rules[0].ExpirationInDays'

🔍 Part 4 — Explore What CloudFormation Built

Step 14 — Verify All Resources Were Created Correctly

Let’s confirm that every resource in the stack is present and properly configured.

# List all resources in the stack
awslocal cloudformation list-stack-resources \
  --stack-name lecafe-stack \
  --query 'StackResourceSummaries[*].{Type:ResourceType,LogicalId:LogicalResourceId,PhysicalId:PhysicalResourceId,Status:ResourceStatus}' \
  --output table

The PhysicalResourceId column shows the actual AWS resource identifier for each logical resource in the template — the real bucket name, the real queue URL, the real role ARN. This mapping between logical IDs (what you named them in the template) and physical IDs (what AWS actually created) is fundamental to understanding how CloudFormation manages resources over time.

# Verify the SNS topic has all three subscriptions
TOPIC_ARN=$(awslocal cloudformation describe-stacks \
  --stack-name lecafe-stack \
  --query "Stacks[0].Outputs[?OutputKey=='OrdersTopicArn'].OutputValue" \
  --output text)

awslocal sns list-subscriptions-by-topic \
  --topic-arn $TOPIC_ARN \
  --query 'Subscriptions[*].{Protocol:Protocol,Endpoint:Endpoint,Status:SubscriptionArn}' \
  --output table
# Verify the kitchen queue has its DLQ configured
KITCHEN_QUEUE_URL=$(awslocal cloudformation describe-stacks \
  --stack-name lecafe-stack \
  --query "Stacks[0].Outputs[?OutputKey=='KitchenQueueUrl'].OutputValue" \
  --output text)

awslocal sqs get-queue-attributes \
  --queue-url $KITCHEN_QUEUE_URL \
  --attribute-names RedrivePolicy VisibilityTimeout MessageRetentionPeriod

Step 15 — Test the Pipeline End to End

With the entire stack deployed from a single template, let’s run one final end-to-end test to confirm everything is wired correctly.

# Publish a high-priority order to the SNS topic
awslocal sns publish \
  --topic-arn $TOPIC_ARN \
  --message '{"orderId":"ORD-200","table":5,"items":[{"product":"Cold Brew","quantity":8,"price":4.00}],"totalAmount":32.00}' \
  --message-attributes '{"Priority":{"DataType":"String","StringValue":"high"}}' \
  --subject "New Le Cafe Order"

# Give SNS a moment to deliver, then check each queue
sleep 2

echo "Checking queue depths after fan-out..."

for QUEUE_NAME in \
  "lecafe-inventory-updates-development" \
  "lecafe-loyalty-points-development" \
  "lecafe-manager-alerts-development"; do
    COUNT=$(awslocal sqs get-queue-attributes \
      --queue-url "http://localhost:4566/000000000000/${QUEUE_NAME}" \
      --attribute-names ApproximateNumberOfMessages \
      --query 'Attributes.ApproximateNumberOfMessages' \
      --output text)
    echo "  ${QUEUE_NAME}: ${COUNT} message(s)"
  done

All three queues should show 1 message. The manager queue receiving the message confirms the SNS filter policy is working — only high-priority orders trigger the alert.


🛡️ Defender’s Perspective

Infrastructure as Code introduces a different category of security considerations compared to manually managed infrastructure, and they are worth understanding deeply.

Your template is now the source of truth — protect it accordingly. A CloudFormation template that defines IAM roles, bucket policies, and security groups is effectively a description of your security posture. If an attacker can modify the template in your Git repository and trigger an automated deployment, they can change your security configuration without touching the AWS console. This is why protecting your IaC repository is as important as protecting your AWS account. Require code review for all template changes, protect the main branch, and ensure your CI/CD pipeline authenticates to AWS using short-lived role credentials — not long-lived access keys committed to the repository.

Drift detection is your integrity check. CloudFormation tracks the state it expects your resources to be in. If someone manually modifies a resource outside of CloudFormation — changing a security group rule in the console, for example — the stack enters a “drifted” state. CloudFormation’s drift detection feature (detect-stack-drift) compares the actual resource configuration against the template definition and reports discrepancies. In a security-conscious environment, drift detection should run automatically on a schedule, and any drift should trigger an alert. Drift is either an unauthorised change (a security incident) or an approved change that was not reflected in the template (a process failure) — either way, it needs investigation.

The principle of least privilege applies to CloudFormation itself. When CloudFormation creates resources on your behalf, it uses a service role. If you do not specify a service role, CloudFormation uses the credentials of the user or role that initiated the deployment — which means a developer with broad IAM permissions could deploy a template that creates resources they would not normally be allowed to create directly. Defining a dedicated CloudFormation service role with only the permissions needed to manage your specific stack limits what a compromised deployment pipeline can do.

DeletionPolicy: Retain is a data protection control. The Retain policy we set on both S3 buckets means that even if someone runs delete-stack, the buckets and their contents survive. This is intentional for production data — you do not want a mistaken stack deletion to wipe customer records or application logs. The trade-off is that you must clean up retained resources manually. In development, Delete is more convenient; in production, Retain is the safer default for any resource containing data.


🧩 Challenge Tasks

Challenge 1 — Add a FIFO Queue. Add a LeCafePayments FIFO queue resource to the template with ContentBasedDeduplication enabled and a visibility timeout of 60 seconds. Deploy the change using a change set, verify the change set shows only an addition (no modifications or replacements), execute it, and confirm the queue exists with awslocal sqs get-queue-attributes. Remember the .fifo suffix requirement.

Challenge 2 — Conditional Resource Creation. Add a Conditions section to the template with a condition called IsProduction that evaluates to true when EnvironmentName equals production. Then add a second SNS topic called LeCafeAlarmsTopic that is only created when IsProduction is true, using the Condition property on the resource. Research CloudFormation’s !Equals and !If intrinsic functions to implement this. Deploy with EnvironmentName=development and verify the topic is not created, then deploy with EnvironmentName=production and verify it is.

Challenge 3 — Nested Stacks. Large CloudFormation templates become difficult to manage as the number of resources grows. AWS recommends splitting large stacks into nested stacks — a parent template that references child templates using the AWS::CloudFormation::Stack resource type. Split the current template into two child templates — one for IAM and one for messaging (SQS + SNS) — and write a parent template that deploys both, passing the SNS topic ARN from the messaging stack to the IAM stack as a parameter. This pattern is how enterprise AWS environments are structured.


🤔 Reflection Questions

  1. CloudFormation uses a declarative model where you describe desired state and it figures out the actions needed to get there. Terraform, another popular IaC tool, also uses a declarative model but stores its own state file separately from the cloud provider. What are the implications of CloudFormation storing state internally in AWS versus Terraform storing it externally in a file or a backend like S3? Think about what happens when the state is lost or becomes out of sync with reality.

  2. The DeletionPolicy: Retain attribute protects data from accidental deletion when a stack is deleted. But it creates an operational problem: if you delete and recreate the stack (as you might during disaster recovery), the new stack cannot create a bucket with the same name because the retained bucket still exists. How would you design your naming convention and your recovery runbook to handle this tension between data protection and clean recreation?

  3. Throughout this lab series, you have built the same Le Café infrastructure twice — once manually with CLI commands, and once declaratively with CloudFormation. Reflect on the experience of both approaches. In what specific situations would you still prefer the manual CLI approach, and in what situations is the CloudFormation approach clearly superior? Is there a type of infrastructure work where neither approach alone is sufficient?


🧹 Cleanup

Deleting a CloudFormation stack removes all resources it manages — in the correct order, automatically. This single command replaces the entire manual cleanup section from every previous lab.

# Delete the stack — CloudFormation handles all dependencies and ordering
awslocal cloudformation delete-stack --stack-name lecafe-stack

# Monitor the deletion progress
awslocal cloudformation describe-stack-events \
  --stack-name lecafe-stack \
  --query 'StackEvents[*].{Resource:LogicalResourceId,Status:ResourceStatus}' \
  --output table

# Confirm the stack is gone
awslocal cloudformation describe-stacks \
  --stack-name lecafe-stack 2>&1 | grep -i "does not exist" && \
  echo "Stack deleted successfully." || \
  echo "Stack still exists — check events for errors."

Note that the two S3 buckets will not be deleted because of their DeletionPolicy: Retain setting. You must remove them manually if you want a completely clean environment.

# Manually remove the retained S3 buckets
awslocal s3 rm s3://lecafe-assets-development --recursive
awslocal s3 rb s3://lecafe-assets-development

awslocal s3 rm s3://lecafe-logs-development --recursive
awslocal s3 rb s3://lecafe-logs-development

# Stop LocalStack
localstack stop
echo "Environment fully cleaned up."

📋 Quick Reference

Task Command
Validate template awslocal cloudformation validate-template --template-body file://template.yaml
Create stack awslocal cloudformation create-stack --stack-name NAME --template-body file://template.yaml --parameters ... --capabilities CAPABILITY_NAMED_IAM
Check stack status awslocal cloudformation describe-stacks --stack-name NAME --query 'Stacks[0].StackStatus' --output text
View stack events awslocal cloudformation describe-stack-events --stack-name NAME
List stack resources awslocal cloudformation list-stack-resources --stack-name NAME
View stack outputs awslocal cloudformation describe-stacks --stack-name NAME --query 'Stacks[0].Outputs'
Create change set awslocal cloudformation create-change-set --stack-name NAME --change-set-name NAME --template-body file://template.yaml ...
Review change set awslocal cloudformation describe-change-set --stack-name NAME --change-set-name NAME
Execute change set awslocal cloudformation execute-change-set --stack-name NAME --change-set-name NAME
Delete stack awslocal cloudformation delete-stack --stack-name NAME
Key intrinsic functions !Ref (primary ID), !GetAtt (attribute), !Sub (string substitution), !If (conditional)

🎓 Series Wrap-Up — What You Have Built

Over five labs, you have gone from installing LocalStack for the first time to writing production-quality Infrastructure as Code. Look back at the journey through the lens of the skills you now have.

In Lab 00 you discovered that the entire AWS API surface can be emulated locally, and that awslocal talks to LocalStack exactly as aws talks to real AWS. That foundational understanding is what made every subsequent lab possible.

In Lab 01 you built a complete IAM structure — users, groups, custom policies scoped to specific resources, and a service role with temporary credentials. You now understand why the principle of least privilege matters, how trust policies control role assumption, and why access keys are inferior to roles for application workloads.

In Lab 02 you went deep on S3 — the flat namespace model, versioning and delete markers, bucket policies versus identity policies, lifecycle rules for automated cost control, and static website hosting. You understand why S3 is involved in more breaches than any other AWS service, and you know the configurations that prevent that.

In Lab 03 you launched compute infrastructure — key pairs using public-key cryptography, security groups as stateful virtual firewalls, EC2 instance profiles bridging compute to identity, and user data scripts for bootstrapping. You understand the instance lifecycle and why terminated instances cannot be recovered.

In Lab 04 you built an event-driven messaging pipeline — SQS standard and FIFO queues, visibility timeouts and dead letter queues, SNS pub/sub topics, subscription filter policies, and the fan-out pattern that decouples producers from consumers. You understand the architectural problem that messaging solves and the operational practices that keep it running reliably.

And in Lab 05 you expressed all of it as Infrastructure as Code — a version-controllable, repeatable, reviewable specification of your entire infrastructure that deploys in one command and cleans up in one command. You understand the declarative model, intrinsic functions, change sets, and deletion policies.

This is the foundation of professional cloud engineering. The specific services will evolve, but the principles — least privilege, loose coupling, declarative infrastructure, defence in depth — are durable. They apply whether you are working with AWS, Azure, or GCP, whether you are using CloudFormation, Terraform, or Pulumi, and whether you are deploying to LocalStack on a laptop or to a multi-region production environment serving millions of users.


⚠️ Ethical Reminder: Infrastructure as Code templates are powerful artefacts. A CloudFormation template in the wrong hands can provision expensive resources, grant excessive permissions, or expose sensitive data. Treat your templates with the same access controls and code review discipline as your application source code. Version them in Git, require approvals for changes that affect IAM or networking, and never commit AWS credentials alongside them.


Le Café Lab Series — Powered by LocalStack | Lab 05 of 05 — Series Complete


📚 Suggested Next Steps

Now that you have completed the full series, here are natural directions to deepen your learning further.

Go deeper on IaC by exploring Terraform alongside CloudFormation. Terraform’s HCL language and external state model offer a different perspective on the same declarative philosophy, and comparing the two tools sharpens your understanding of both.

Add a compute layer by extending the CloudFormation template with an EC2 Auto Scaling group that launches instances using the LeCafeAppInstanceProfile already defined in the template. This introduces Launch Templates, Auto Scaling policies, and Application Load Balancers.

Explore containers by replacing the EC2 instance with an ECS Fargate service. Fargate runs containers without you managing EC2 instances at all, and LocalStack supports ECS. The IAM concepts from Lab 01 apply directly — ECS tasks use task roles, which work identically to instance profiles.

Practise the real thing by creating an AWS Free Tier account and deploying the lecafe-stack.yaml template to real AWS. The only change required is the endpoint — remove awslocal and use aws, and the template works identically. This is the ultimate validation of the IaC approach.