Cloud 18 min read

AWS CLI First #16: Infrastructure as Code: Deploying Multi-Tier Stacks with CloudFormation & SAM CLI

Transition from imperative scripts to declarative Infrastructure as Code: author production CloudFormation YAML templates, validate change sets, automate rollbacks, manage stack drifts, and build serverless apps with AWS SAM CLI.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide โ€ข Part 16 of 17

AWS with CLI: The Terminal-First Mastery Path

Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.

Across the first 15 parts of this series, we created our cloud infrastructure imperatively using individual AWS CLI commands. While the imperative CLI is peerless for fast experimentation, diagnostic troubleshooting, and automation scripting, it introduces substantial risks when managing complex enterprise systems:

  1. State Tracking Overhead: Bash scripts do not know if an infrastructure update partially failed halfway through. If a command fails at step 7 of 12, your AWS account is left in an inconsistent, fractured state.
  2. Non-Declarative Mutability: Writing scripts that check whether a resource exists before creating or updating it requires hundreds of lines of defensive bash boilerplate.
  3. Auditability & Drift: When multiple cloud engineers modify security groups or route tables ad-hoc, there is no single source of truth recording what the intended architecture should be.

AWS CloudFormation and the AWS Serverless Application Model (SAM CLI) solve this through Declarative Infrastructure as Code (IaC):

  • You declare the desired end-state of your infrastructure in version-controlled YAML or JSON templates.
  • The CloudFormation engine calculates dependency graphs, provisions resources in parallel, executes atomic rollbacks upon failure, and detects configuration drift.

In this guide, we master enterprise Infrastructure as Code directly from the terminal. We will author a production Multi-Tier CloudFormation template, validate syntax, preview mutations using Change Sets, track live deployment events, detect Stack Drift, and test serverless APIs locally with SAM CLI.


๐Ÿ’ฐ Estimated Lab Cost & Resource Architecture

1. Cost Breakdown for this Lab

All resources in this lab are provisioned on-demand in the Asia Pacific (Singapore) ap-southeast-1 region:

ResourceQuantityUnit Cost (Hourly)Estimated Lab Cost (1 Hour)
AWS CloudFormation Management1 StackFree AWS Service$0.000
VPC & Subnets (Multi-AZ)1 VPC (2 Subnets)$0.00 / hour$0.000
Security Groups & Route Tables2 SGs + 1 RT$0.00 / hour$0.000
AWS SAM CLI Local EmulationLocal Docker EngineLocal Workstation Compute$0.000
Total Estimated Lab Cost~$0.000 USD (100% Free)

2. Architecture Diagram

+----------------------------------------------------------------+
|             Declarative Infrastructure as Code Workflow         |
+-------------------------------+--------------------------------+
                                |
                                v
+-------------------------------+--------------------------------+
|        Declarative YAML Template (vpc-infrastructure.yaml)      |
|  - Parameters: Environment, VPCCIDR, SubnetCIDRs               |
|  - Resources: VPC, Multi-AZ Subnets, Route Tables, IGW, SGs    |
|  - Outputs: VPCId, SubnetIds (Exported for Cross-Stack Sharing)|
+-------------------------------+--------------------------------+
                                |
                                v (aws cloudformation create-change-set)
+-------------------------------+--------------------------------+
|                 CloudFormation Change Set Preview              |
|  - Dry-run validation of additions, modifications & deletions  |
+-------------------------------+--------------------------------+
                                |
                                v (aws cloudformation execute-change-set)
+-------------------------------+--------------------------------+
|                 CloudFormation Deployment Engine               |
|  - Dependency Graph Resolution & Parallel Resource Provisioning|
|  - Automatic Rollback on Failure (Zero Broken States)          |
+-------------------------------+--------------------------------+
                                |
                                v
+----------------------------------------------------------------+
|  Operational Capabilities:                                     |
|  - Drift Detection: Detect unauthorized manual out-of-band edits|
|  - SAM CLI: Local API emulation & packaging inside Docker      |
+----------------------------------------------------------------+

๐Ÿ“ Phase 1: Authoring a Production CloudFormation Template

A production CloudFormation template contains five primary sections:

  1. AWSTemplateFormatVersion: Standard engine specification (2010-09-09).
  2. Description: Human-readable summary of the stack.
  3. Parameters: Dynamic variables passed at deploy time.
  4. Resources: The declarative AWS objects to provision.
  5. Outputs: Values exported for use by other stacks.

1. Creating infrastructure-stack.yaml

We author a complete networking and security foundation with built-in parameter validation:

mkdir -p ./cloudformation
cat <<'EOF' > ./cloudformation/infrastructure-stack.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: "Production Multi-AZ VPC and Security Architecture via AWS CLI"

Parameters:
  EnvironmentName:
    Type: String
    Default: "Production"
    AllowedValues: ["Production", "Staging", "Development"]
    Description: "Deployment environment target"

  VpcCIDR:
    Type: String
    Default: "10.100.0.0/16"
    AllowedPattern: '(^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$)'
    Description: "Primary CIDR block for the custom VPC"

  PublicSubnetACIDR:
    Type: String
    Default: "10.100.1.0/24"
    Description: "Subnet CIDR for Availability Zone A"

  PublicSubnetBCIDR:
    Type: String
    Default: "10.100.2.0/24"
    Description: "Subnet CIDR for Availability Zone B"

Resources:
  # 1. Custom VPC
  ProductionVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCIDR
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags:
        - Key: Name
          Value: !Sub "${EnvironmentName}-IaC-VPC"
        - Key: ManagedBy
          Value: "CloudFormation"

  # 2. Internet Gateway
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub "${EnvironmentName}-IaC-IGW"

  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref ProductionVPC
      InternetGatewayId: !Ref InternetGateway

  # 3. Route Table & Default Route
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref ProductionVPC
      Tags:
        - Key: Name
          Value: !Sub "${EnvironmentName}-IaC-Public-RT"

  PublicDefaultRoute:
    Type: AWS::EC2::Route
    DependsOn: AttachGateway
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref InternetGateway

  # 4. Multi-AZ Subnets
  PublicSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref ProductionVPC
      CidrBlock: !Ref PublicSubnetACIDR
      AvailabilityZone: !Select [0, !GetAZs ""]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub "${EnvironmentName}-IaC-Subnet-A"

  SubnetRouteTableAssociationA:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetA
      RouteTableId: !Ref PublicRouteTable

  PublicSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref ProductionVPC
      CidrBlock: !Ref PublicSubnetBCIDR
      AvailabilityZone: !Select [1, !GetAZs ""]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub "${EnvironmentName}-IaC-Subnet-B"

  SubnetRouteTableAssociationB:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetB
      RouteTableId: !Ref PublicRouteTable

  # 5. Security Group
  ApplicationSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "Inbound HTTP access for web workloads"
      VpcId: !Ref ProductionVPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: "0.0.0.0/0"
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: "0.0.0.0/0"
      Tags:
        - Key: Name
          Value: !Sub "${EnvironmentName}-IaC-App-SG"

Outputs:
  VpcId:
    Description: "The ID of the provisioned VPC"
    Value: !Ref ProductionVPC
    Export:
      Name: !Sub "${EnvironmentName}-VpcId"

  SubnetAId:
    Description: "Subnet ID in Availability Zone A"
    Value: !Ref PublicSubnetA
    Export:
      Name: !Sub "${EnvironmentName}-SubnetA"

  SubnetBId:
    Description: "Subnet ID in Availability Zone B"
    Value: !Ref PublicSubnetB
    Export:
      Name: !Sub "${EnvironmentName}-SubnetB"

  SecurityGroupId:
    Description: "Application Security Group ID"
    Value: !Ref ApplicationSecurityGroup
    Export:
      Name: !Sub "${EnvironmentName}-AppSecurityGroup"
EOF

๐Ÿ” Phase 2: Template Validation via AWS CLI

Before submitting a template to the CloudFormation engine, execute a static lint check:

REGION="ap-southeast-1"
PROFILE="cloud-dev-admin"

aws cloudformation validate-template \
  --template-body file://cloudformation/infrastructure-stack.yaml \
  --region "$REGION" \
  --profile "$PROFILE"

If valid, CloudFormation prints the recognized parameters and template capabilities. If a YAML syntax error or invalid property name is detected, the CLI returns a descriptive parse error immediately.


๐Ÿ“‹ Phase 3: CloudFormation Change Sets (Pre-Flight Dry Runs)

In production, never execute blind deployments. A Change Set allows you to inspect precisely what resources CloudFormation will add, modify, or destroy before applying the changes:

1. Creating the Change Set

STACK_NAME="Enterprise-IaC-Core-Stack"
CHANGE_SET_NAME="Initial-VPC-Provisioning"

aws cloudformation create-change-set \
  --stack-name "$STACK_NAME" \
  --change-set-name "$CHANGE_SET_NAME" \
  --template-body file://cloudformation/infrastructure-stack.yaml \
  --parameters ParameterKey=EnvironmentName,ParameterValue=Production \
  --change-set-type "CREATE" \
  --region "$REGION" \
  --profile "$PROFILE"

echo "Change Set submitted. Waiting for calculation..."
aws cloudformation wait change-set-create-complete \
  --stack-name "$STACK_NAME" \
  --change-set-name "$CHANGE_SET_NAME" \
  --region "$REGION" \
  --profile "$PROFILE"

2. Inspecting Proposed Mutations

Query the planned actions:

aws cloudformation describe-change-set \
  --stack-name "$STACK_NAME" \
  --change-set-name "$CHANGE_SET_NAME" \
  --region "$REGION" \
  --profile "$PROFILE" \
  --query "Changes[*].ResourceChange.[Action,LogicalResourceId,ResourceType,Replacement]" \
  --output table

Output:

---------------------------------------------------------------------------
|                           DescribeChangeSet                             |
+--------+---------------------------------+-------------------------+----+
|  Add   |  ApplicationSecurityGroup       |  AWS::EC2::SecurityGroup|    |
|  Add   |  AttachGateway                  |  AWS::EC2::VPCGateway...|    |
|  Add   |  InternetGateway                |  AWS::EC2::InternetGa...|    |
|  Add   |  ProductionVPC                  |  AWS::EC2::VPC          |    |
|  Add   |  PublicDefaultRoute             |  AWS::EC2::Route        |    |
|  Add   |  PublicRouteTable               |  AWS::EC2::RouteTable   |    |
|  Add   |  PublicSubnetA                  |  AWS::EC2::Subnet       |    |
|  Add   |  PublicSubnetB                  |  AWS::EC2::Subnet       |    |
+--------+---------------------------------+-------------------------+----+

Notice Action: Add across all resources. If modifying an existing stack, this command explicitly flags dangerous actions like Replacement: True (which would destroy and recreate a database or storage volume!).


๐Ÿš€ Phase 4: Deploying & Tracking Stack Events

Once verified, execute the Change Set and watch the real-time event stream:

1. Executing the Change Set

aws cloudformation execute-change-set \
  --stack-name "$STACK_NAME" \
  --change-set-name "$CHANGE_SET_NAME" \
  --region "$REGION" \
  --profile "$PROFILE"

echo "Deployment initiated. Tracking live events..."

2. Streaming Real-Time Stack Events

Monitor resource provisioning events as they occur:

# Stream the last 10 stack events
aws cloudformation describe-stack-events \
  --stack-name "$STACK_NAME" \
  --region "$REGION" \
  --profile "$PROFILE" \
  --query "StackEvents[0:8].[LogicalResourceId,ResourceStatus,ResourceStatusReason]" \
  --output table

Wait until the stack reaches CREATE_COMPLETE:

aws cloudformation wait stack-create-complete \
  --stack-name "$STACK_NAME" \
  --region "$REGION" \
  --profile "$PROFILE"

echo "Stack $STACK_NAME successfully created!"

3. Reviewing Stack Outputs & Exported Variables

Query the exported values:

aws cloudformation describe-stacks \
  --stack-name "$STACK_NAME" \
  --region "$REGION" \
  --profile "$PROFILE" \
  --query "Stacks[0].Outputs[*].[OutputKey,OutputValue,ExportName]" \
  --output table

These exported values can now be referenced by completely separate stacks (e.g. your database stack or compute cluster stack) using the Fn::ImportValue intrinsic function!


๐Ÿ”Ž Phase 5: Detecting Stack Drift (Catching ClickOps)

One of the most powerful features of CloudFormation is Drift Detection. If a developer manually modifies a security group in the AWS Console or deletes a subnet via CLI outside of CloudFormation, the real-world infrastructure โ€œdriftsโ€ from the code.

1. Triggering Drift Detection via CLI

DRIFT_ID=$(aws cloudformation detect-stack-drift \
  --stack-name "$STACK_NAME" \
  --region "$REGION" \
  --profile "$PROFILE" \
  --query "StackDriftDetectionId" \
  --output text)

echo "Drift Detection ID: $DRIFT_ID"

# Wait for drift scan to complete
sleep 10

2. Inspecting Drift Status

aws cloudformation describe-stack-drift-detection-status \
  --stack-drift-detection-id "$DRIFT_ID" \
  --region "$REGION" \
  --profile "$PROFILE" \
  --query "[StackDriftStatus,DriftedStackResourceCount]" \
  --output table

If someone manually added an unauthorized inbound rule to ApplicationSecurityGroup, CloudFormation marks the stack DRIFTED and displays the exact JSON diff between expected and actual configuration!


โšก Phase 6: Serverless Application Model (SAM CLI)

While pure CloudFormation is exceptional for foundational infrastructure, building serverless applications (Lambda, API Gateway, DynamoDB) with pure CloudFormation requires verbose, repetitive YAML.

The AWS Serverless Application Model (SAM) provides an abstraction layer that condenses 100 lines of CloudFormation into 20 lines of SAM syntax:

       [ SAM Template (template.yaml) ]
                     |
                     v (sam build)
       [ Expanded Standard CloudFormation Template ]
                     |
                     v (sam deploy)
       [ Deployed CloudFormation Stack ]

1. Authoring a SAM Template (template.yaml)

cat <<'EOF' > ./cloudformation/sam-template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: "Serverless Microservice with SAM"

Globals:
  Function:
    Timeout: 5
    MemorySize: 128
    Runtime: python3.12
    Architectures:
      - arm64

Resources:
  OrdersApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      InlineCode: |
        def handler(event, context):
            return {
                "statusCode": 200,
                "headers": {"Content-Type": "application/json"},
                "body": "{\"message\": \"Hello from SAM CLI Serverless!\"}"
            }
      Handler: index.handler
      Events:
        GetOrders:
          Type: HttpApi
          Properties:
            Path: /orders
            Method: GET
EOF

Notice Transform: AWS::Serverless-2016-10-31. When deployed, CloudFormation automatically expands this short template into:

  • The Lambda function
  • The IAM execution role
  • The HTTP API Gateway v2
  • The Lambda Proxy integration
  • The route and stage permissions

๐Ÿงน Phase 7: 1-Click Hardened Stack Deletion

With Infrastructure as Code, teardown is completely automated. You do not need complex bash scripts to delete subnets, security groups, and route tables in reverse order. CloudFormation reads the dependency graph and purges everything in parallel:

cat <<'EOF' > destroy-iac-stack.sh
#!/usr/bin/env bash
set -euo pipefail

REGION="ap-southeast-1"
PROFILE="cloud-dev-admin"
STACK_NAME="Enterprise-IaC-Core-Stack"

echo "Deleting CloudFormation Stack: $STACK_NAME..."
aws cloudformation delete-stack \
  --stack-name "$STACK_NAME" \
  --region "$REGION" \
  --profile "$PROFILE"

echo "Waiting for stack deletion to finalize..."
aws cloudformation wait stack-delete-complete \
  --stack-name "$STACK_NAME" \
  --region "$REGION" \
  --profile "$PROFILE"

rm -rf ./cloudformation destroy-iac-stack.sh
echo "=== Part 16 Teardown Complete! Zero Resources Remaining. ==="
EOF

chmod +x destroy-iac-stack.sh
./destroy-iac-stack.sh

๐Ÿ† Key Takeaways & Enterprise Architecture Patterns

  1. Declarative vs. Imperative: Shift from imperative bash scripts to declarative CloudFormation templates. Let AWS resolve dependency trees, handle retries, and manage state.
  2. Pre-Flight Change Sets: Never blindly update production stacks. Always generate and inspect a ChangeSet to prevent unintended resource replacements.
  3. Cross-Stack Exports: Use the Outputs and Export: Name pattern to decouple foundational networking stacks from ephemeral application compute stacks.
  4. Drift Detection: Periodically audit stacks for configuration drift to enforce compliance and eliminate unauthorized out-of-band ClickOps mutations.
  5. SAM for Serverless: Use AWS::Serverless transforms to eliminate CloudFormation boilerplate when orchestrating Lambda functions and API Gateways.

๐Ÿ”ฎ Whatโ€™s Next in Part 17 (Grand Finale)?

How do you deploy these CloudFormation stacks and application updates automatically every time a developer merges code to GitHub?

In Part 17: Automated CI/CD Workflows: GitHub Actions & AWS CLI Integration, we conclude our 17-part series with the pinnacle of modern enterprise engineering: Zero-Secret CI/CD. We will configure OpenID Connect (OIDC) between GitHub Actions and AWS, eliminate static long-lived IAM keys forever, run automated linting pipelines, and execute hands-free production cloud deployments directly on git push!

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.