← Workshops Β· Part 2 Β· Infrastructure

AWS Cloud Platform Infrastructure

The base layer under every app β€” VPC, storage, the event bus, identities. What to build with Terraform vs CloudFormation/SAM, and how your services plug into it through SSM.

πŸ“ Test yourself ↓
24 min readInfrastructureRead or present

What you'll learn

Before any Lambda or container runs, something has to create the base infrastructure it sits on β€” the network, storage, the event bus, the identities. This part maps that base layer and answers the question every team gets wrong at first: which pieces do you build with Terraform, and which with CloudFormation / SAM?

🧭

Two layers, two owners. A shared platform (VPC, buckets, event bus, database, IAM) that changes rarely and is owned by no single service β€” and the services (Lambdas, APIs, containers) that plug into it and deploy many times a day.

πŸ—οΈ

This is the real CargoNaut / JunctionNet split: a *-platform repo in Terraform owns the shared infra; each service repo's SAM template references it. The examples below are that pattern, distilled.

Two layers: the platform vs the services

Everything downstream (Parts 4–6) plugs into a base that already exists. Keep the two layers β€” and their tools β€” cleanly separated:

β”Œβ”€ PLATFORM  (Terraform β€” shared, stateful, long-lived) ─────────────┐
β”‚  VPC Β· subnets Β· security groups Β· S3 Β· RDS Β· EventBridge bus       β”‚
β”‚  ECR Β· IAM baseline + OIDC Β· Route53 / ACM Β· /platform/* SSM params  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 β”‚  reads ids/ARNs via {{resolve:ssm:/platform/*}}
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  SERVICES  (SAM / CloudFormation β€” per-service, deployed often)      β”‚
β”‚  Lambda Β· API Gateway Β· EventBridge RULES Β· scoped IAM Β· task defs   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
πŸ”‘

The platform is the noun; services are the verbs. The platform is state that must survive β€” deleting it loses data. Services are compute you redeploy freely. Never mix the two in one stack.

The base infrastructure

🌐

VPC & subnets

The private network; public subnets for load balancers, private for compute + data.

πŸ›‘οΈ

Security groups

Virtual firewalls β€” who can talk to the database, the cache, the internet.

πŸ—„οΈ

S3 & RDS

Stateful storage: object buckets and the relational database. Must outlive any deploy.

🚌

EventBridge bus

The cross-service event backbone (Part 7) β€” one per stage, shared.

πŸ“¦

ECR

Private image registry for the container path (Part 6), with lifecycle policies.

πŸ”

IAM & OIDC

Baseline roles and the GitHub OIDC provider that keyless CI deploys assume.

βš™οΈ

SSM & Secrets

Parameter Store + Secrets Manager β€” config and the contract services read.

🌍

Route 53 & ACM

DNS zones and TLS certificates β€” account/domain-level, shared by everything.

Terraform vs CloudFormation / SAM β€” who owns what

The dividing line is simple once you see it: state and sharing. If a resource holds data or is used by more than one service, it belongs to the platform (Terraform). If it's compute that one service owns and redeploys, it belongs to that service's stack (SAM / CloudFormation).

ResourceCreate withWhy
VPC, subnets, NAT, route tablesTerraformShared by everything, changes rarely, owned by no single service.
Security groups (shared)TerraformShared network policy; services reference them by id.
S3 buckets, RDS / AuroraTerraformStateful β€” must survive any stack delete. Never in an app stack.
EventBridge busTerraformCross-service backbone; one per stage.
ECR repos, IAM baseline, OIDC providerTerraformAccount-level, shared, security-sensitive.
Route 53 zones, ACM certsTerraformDomain / account-level, shared.
/platform/* SSM paramsTerraformThe contract every service reads.
Lambda functionsSAM / CFNPer-service compute, deployed often, owned by the service.
API Gateway / routesSAM / CFNPer-service HTTP surface.
EventBridge rules, scoped IAMSAM / CFNEach service owns its subscriptions and least-privilege role.
ECS task defs / servicesCFN / CDK or TFPer-service; either β€” often IaC alongside the service.
πŸ“

The rule of thumb: stateful + shared + long-lived β†’ Terraform; stateless + per-service + deployed-often β†’ SAM / CloudFormation. Terraform owns the platform; SAM owns the services that plug into it. A per-service stack must never create a shared or stateful resource β€” deleting the stack would delete the data.

Terraform

Networking: VPC, subnets, security groups

The network is the definition of "shared and long-lived" β€” build it once in Terraform and publish its ids for services to consume:

# infra/network.tf β€” the VPC everything runs in (Terraform)
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "private" {
  count      = 2
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.${count.index}.0/24"
}
resource "aws_security_group" "app" {
  vpc_id = aws_vpc.main.id
  # ingress/egress rules…
}
# publish ids so service stacks can read them β€” the contract
resource "aws_ssm_parameter" "vpc_id" {
  name = "/platform/vpc_id"
  type = "String"
  value = aws_vpc.main.id
}
Terraform

Shared data & messaging: S3, RDS, EventBridge

Stateful and cross-service resources live in the platform too. If a service stack owned them, tearing that stack down would take the data with it:

# infra/platform.tf β€” shared, stateful, long-lived (Terraform)
resource "aws_s3_bucket" "documents"      { bucket = "acme-documents-${var.stage}" }
resource "aws_cloudwatch_event_bus" "main" { name  = "acme-events-${var.stage}" }
resource "aws_db_instance" "postgres"     { /* … RDS … */ }

# the contract: publish names/ARNs to SSM for services to read
resource "aws_ssm_parameter" "bucket"    { name="/platform/documents_bucket" type="String" value=aws_s3_bucket.documents.id }
resource "aws_ssm_parameter" "event_bus" { name="/platform/event_bus_name"   type="String" value=aws_cloudwatch_event_bus.main.name }
⚠️

Never put a bucket or database in a service's SAM stack. CloudFormation deletes a stack's resources when the stack is deleted or replaced β€” an accidental teardown would wipe production data. Stateful resources belong in Terraform, with deletion protection on.

SAM / CloudFormation

Per-service compute: Lambda, API Gateway, rules

The service stack owns only its compute, and reads the platform by reference β€” never hardcoding an id:

# services/documents/template.yaml β€” per-service compute (SAM)
Resources:
  DocumentsFn:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.handler
      Runtime: python3.13
      Environment:
        Variables:
          # read what Terraform published β€” the contract, resolved at deploy
          EVENT_BUS: '{{resolve:ssm:/platform/event_bus_name}}'
          BUCKET:    '{{resolve:ssm:/platform/documents_bucket}}'
      VpcConfig:
        SecurityGroupIds: [ '{{resolve:ssm:/platform/app_sg_id}}' ]
        SubnetIds:        [ '{{resolve:ssm:/platform/private_subnet_ids}}' ]
      Policies:                                    # least-privilege, scoped to shared resources
        - S3CrudPolicy: { BucketName: '{{resolve:ssm:/platform/documents_bucket}}' }
        - EventBridgePutEventsPolicy: { EventBusName: '{{resolve:ssm:/platform/event_bus_name}}' }

How they connect: SSM Parameter Store is the contract

Terraform and SAM never touch each other's state. They meet at SSM Parameter Store: Terraform writes the shared ids under /platform/*; every service template reads them with {{resolve:ssm:…}} at deploy. Change the VPC and nothing in the services needs editing.

Terraform  (platform repo)                    SAM  (service repos)
  writes  /platform/vpc_id            ─────▢    {{resolve:ssm:/platform/vpc_id}}
          /platform/private_subnet_ids          {{resolve:ssm:/platform/private_subnet_ids}}
          /platform/app_sg_id                   {{resolve:ssm:/platform/app_sg_id}}
          /platform/documents_bucket            {{resolve:ssm:/platform/documents_bucket}}
          /platform/event_bus_name              {{resolve:ssm:/platform/event_bus_name}}
          /platform/lambda_role_arn             {{resolve:ssm:/platform/lambda_role_arn}}
πŸ”—

Decoupled by the contract. Services depend on parameter names, not on Terraform. You can rebuild the whole network, and as long as the same /platform/* names hold the new ids, every service redeploys unchanged.

IAM & least privilege

Operate

Reading the logs when a deploy fails

When a provision or deploy step goes red, work from the outside in: the CI run tells you which step failed, then the cloud tells you why. Three places, in order:

1. CloudFormation stack events β€” the "why" of a failed SAM / CFN deploy

A SAM / CloudFormation deploy fails as a stack event. The first event that failed carries the real reason β€” the rest are the rollback cascade:

# find the root-cause failures on a stack
aws cloudformation describe-stack-events --stack-name notes-api-dev \
  --query "StackEvents[?contains(ResourceStatus,'FAILED')].[LogicalResourceId,ResourceStatusReason]" \
  --output table
πŸ”Ž

Read bottom-up. CloudFormation lists newest-first, so the root cause is the earliest *_FAILED event β€” usually "access denied", "already exists", or a resource's own error. Everything above it is just the rollback unwinding.

2. CloudWatch Logs β€” the application's own errors

# Lambda (SAM): tail a function's logs
sam logs -n DocumentsFn --stack-name notes-api-dev --tail
aws logs tail /aws/lambda/notes-api-dev-DocumentsFn --follow --since 10m

# ECS / Fargate: tail the service's log group
aws logs tail /ecs/notes-api --follow --since 10m

3. The pipeline + Terraform output

The GitHub Actions run shows the failing job and step; the infra job's terraform plan / apply output shows resource-level errors inline. Open the failed run, expand the red step β€” the AWS error is usually right there before you ever touch the CLI.

Unsticking CloudFormation & a stuck pipeline

A stack's status decides what you can do next. When the pipeline seems to "hang" on a deploy, it's almost always because the stack is in a state that can't be updated β€” SAM errors "stack … is in ROLLBACK_COMPLETE state and can not be updated". Match the state to the fix:

Stack statusWhat happenedFix
ROLLBACK_COMPLETEThe first CREATE failed and rolled back β€” the stack exists but is unusable.You can't update it. Delete the stack, fix the cause, redeploy.
UPDATE_ROLLBACK_FAILEDAn update failed, and the rollback itself failed (a resource wouldn't revert).continue-update-rollback, skipping the stuck resource; then fix + redeploy.
UPDATE_ROLLBACK_COMPLETEThe update failed but rolled back cleanly.Fix the template / cause and redeploy β€” no cleanup needed.
*_IN_PROGRESS (wedged)An op is stuck (an ENI/VPC cleanup, a custom resource that never signalled).Find the stuck resource in events; wait for the timeout, or cancel-update-stack.
# ROLLBACK_COMPLETE β†’ delete, then let the pipeline redeploy fresh
aws cloudformation delete-stack --stack-name notes-api-dev
aws cloudformation wait stack-delete-complete --stack-name notes-api-dev

# UPDATE_ROLLBACK_FAILED β†’ continue the rollback, skipping what won't revert
aws cloudformation continue-update-rollback --stack-name notes-api-dev \
  --resources-to-skip StuckLogicalId
πŸ”’

Terraform "Acquiring state lock" hang. A cancelled or crashed apply can leave a lock in the state backend (the DynamoDB lock table), so the next infra run blocks forever. Release it deliberately β€” terraform force-unlock <LOCK_ID> (that's exactly what the terraform-unlock workflow is for). Only unlock once you're sure no apply is actually running.

🐳

ECS deploy that never finishes. A wait-for-service-stability step hangs when new tasks won't turn healthy β€” a bad image tag, a failing health check, or a crash on boot. Check aws ecs describe-services events and the task logs; a "stuck" deploy is almost always a crash-looping container, not slow AWS.

⚠️

Don't fix a rollback by hand-editing resources in the console. That drifts the stack from its template and the next deploy fails harder. Resolve the state (delete / continue-rollback / unlock), fix the cause in the template or params, and let the pipeline redeploy. For a wedged ticket env, the fastest path is usually to tear it down (delete-stack or the remove-env workflow) and let the PR spin a clean one.

A CLAUDE.md for the platform repo

# CLAUDE.md β€” Platform Infrastructure

## What this is
Shared AWS platform for all services. Terraform owns long-lived/shared/stateful infra;
per-service SAM/CloudFormation stacks own compute and reference the platform via SSM.

## Ownership
- Terraform (this repo): VPC, subnets, security groups, S3, RDS, EventBridge bus,
  ECR, IAM baseline + OIDC, Route53/ACM, and the /platform/* SSM parameters.
- SAM/CFN (service repos): Lambda, API Gateway, per-service EventBridge rules,
  scoped IAM. They READ /platform/* via {{resolve:ssm:...}} β€” never hardcode ids.

## Rules
- Stateful or shared β†’ Terraform. A service stack must NEVER create a bucket/DB/bus.
- Publish every shared id/ARN/name to /platform/* so services stay decoupled.
- Least privilege: OIDC deploy roles per repo; scoped execution roles per function.
- Deletion protection on stateful resources (RDS, prod buckets).

## Commands
- terraform plan (review!) β†’ terraform apply, per stage.

## Verify
- terraform plan is clean; /platform/* params resolve; a service that reads them deploys.

Let Claude Code stand up the platform

Brief it like an engineer β€” name the pieces, the tool boundary, and the contract, and insist on a plan before apply:

> Stand up the base AWS platform for the dev stage in Terraform:
  a VPC (2 private + 2 public subnets, 1 NAT), a shared app security
  group, an S3 documents bucket, an EventBridge bus, an ECR repo, and
  a GitHub OIDC deploy role. Publish vpc id, subnet ids, sg id, bucket
  name, bus name, and role ARN to /platform/* SSM parameters.
  Keep IAM least-privilege and put deletion protection on the bucket.
  Do NOT create any Lambda or API Gateway β€” those live in the service
  SAM templates and read these params. Show me `terraform plan` first.
🧱

Reusable shape: name the shared pieces β†’ Terraform owns them β†’ publish ids to /platform/* β†’ keep IAM least-privilege β†’ don't create per-service compute here β†’ verify with terraform plan.

Check yourself

Quiz β€” 14 questions

Answer every question, then submit to see your score and the correct answers.

Building your AWS platform?

JunctionNet designs and runs production AWS platforms every day β€” Terraform networks, shared data & messaging, SAM services, least-privilege IAM. Reach out for help or a team workshop.

βœ‰οΈ Get in touch ← Back to workshops