Laptop to Production

Project 4: Everything as code

Projects 1 to 3 rebuilt from empty by Terraform, in two regions.

Time
8 to 10 hours, over two or three sittings
What you buildProjects 1 to 3 rebuilt with Terraform modules, remote state with locking, two environments that differ only by variables, and a second region.
What it provesDestroy and rebuild from empty with no manual step, and the next plan is clean.

By now QuickCart’s platform exists because people clicked it into being. Priya knows how the network was built. Sam remembers the target group’s drain setting. Nobody remembers everything, and the notes are already out of date.

Then someone asks the question every team dreads: “If we lost this account tonight, how long to rebuild it?” The honest answer is “days, and it would come back different”.

In this project you make that answer “about thirty minutes, and identical”. You rebuild Projects 1 to 3 as Terraform code: modules for each piece, remote state with locking, two environments that differ only in their values, and a copy in a second region. Then you prove it: destroy everything, build it again from empty with no clicks, and show that the next plan has nothing left to do.

What you’ll have at the end

Environment

The code

identical for all three
  • modules/: 8 modules
  • stack/: wires them together
  • envs/dev/main.tf: a copy of the same file

Same code

The values

envs/dev/terraform.tfvars
region
us-east-1
vpc_cidr
10.20.0.0/16
single_nat_gateway
true
desired_count
2
db_multi_az
false
deletion_protection
false
enable_eks
true

State: dev/terraform.tfstate

What gets built

decided by the values
NAT gateways
1, shared by both zones
Order service
2 tasks on ECS, 2 pods on EKS
Database
one copy, no standby
Destroy
allowed
dev, us-east-1dev is built to be thrown away: one NAT gateway, no database standby, no deletion protection. EKS is on here so you can prove both runtimes rebuild from code.

What to notice: switch between the three environments and watch the code column. It never changes. Only the values and the state file’s key do, and those few values decide how many NAT gateways, tasks and database copies get built.

The repository looks like this:

quickcart-infra/
quickcart-infra/
├── .gitignore
├── shared/                 built once per account: the image repositories
│   └── main.tf
├── modules/                reusable pieces that never mention an environment
│   ├── network/main.tf     VPC, three tiers of subnets, NAT, routes, S3 endpoint
│   ├── security/main.tf    the load balancer → app → database chain
│   ├── certificate/main.tf self-signed, or trusted if you own a domain
│   ├── alb/main.tf         load balancer, stable and canary target groups
│   ├── ecs-service/main.tf one ECS service with its task definition and logs
│   ├── data/main.tf        MySQL on RDS, and a private S3 bucket
│   ├── alarms/main.tf      the three alarms from Project 2
│   └── eks/main.tf         cluster, nodes, and the controller's permissions
├── stack/                  one whole environment: calls every module
│   ├── variables.tf
│   ├── main.tf
│   ├── eks.tf
│   └── outputs.tf
└── envs/                   one folder per environment, each with its own state
    ├── dev/                main.tf, backend.tf, terraform.tfvars
    ├── prod/               the same main.tf; its own backend.tf and values
    └── dev-usw2/           the same main.tf; the second region
FolderIts jobRule
shared/things one account needs once: ECR repositories and replicationapplied once, before any environment
modules/one piece of infrastructure eachnever mentions dev or prod, so it can be reused
stack/one complete environment, built from the modulestakes every difference as a variable
envs/<name>/where you run Terraformmain.tf is an identical copy everywhere; only backend.tf and terraform.tfvars differ
ProjectWhat it becomes in code
Project 1: the networkmodules/network and modules/security, plus least-privilege roles in stack/ and modules/eks
Project 2: the self-healing environmentmodules/alb, modules/data, modules/alarms, and the database secret. The order service now runs as containers, so the ECS service replaces the EC2 auto scaling group and does the same job: it replaces failed copies across two zones
Project 3: one service, two runtimesmodules/ecs-service twice (stable and canary), modules/eks, and the Kubernetes objects in stack/eks.tf

Unlike Project 3, the containers now run in private app subnets behind NAT gateways, as in Project 1. The canary service and target group are built now, idle, so Project 5’s pipeline has somewhere to send a canary.

Before you start

This project uses every lesson in Infrastructure as code. Read them first; the tables below link back to them rather than repeat them.

ReadYou’ll use it for
Your first resourcereading a plan
Variables, locals and outputsevery module’s inputs and outputs
Loops and lifecyclefor_each, count and ignore_changes
Moduleshow the folders call each other
Terraform statethe backend block and the state bucket
Environments and regionsfolders per environment, and what doesn’t move between regions
Operating Terraformdrift, and recovering a half-finished apply

Finish Projects 1 to 3 first, and clean them up. This project builds everything again, and names would clash.

ToolCheck it withYou need
Terraformterraform version1.10 or later, for use_lockfile
AWS CLIaws sts get-caller-identitya login that is not the root user, with admin rights in a practice account
Dockerdocker versionto push the order service image once
Gitgit --versionto keep the code

You also need the state bucket from Terraform state: quickcart-tfstate-111122223333 in us-east-1, with versioning on. If you deleted it, create it again as that lesson shows. It’s the one thing in this project you create by hand, because the code that needs it can’t create it.

Cost

WhatPrice in us-east-1 (prices change; check the pricing pages)Per environment, per day
NAT gatewayabout $0.045 an hour, plus $0.045 per GB through itabout $1.10 in dev (one); $2.20 in prod (two)
Application Load Balancerabout $0.0225 an hour plus traffic unitsabout $0.55
Fargate tasks, 0.25 vCPU and 0.5 GBabout $0.012 an hour eachabout $0.60 for two
RDS MySQL db.t4g.micro, 20 GBabout $0.016 an hour, plus storageabout $0.45. Multi-AZ doubles it
Public IPv4 addresses (NAT, load balancer)$0.005 an hour eachabout $0.36
EKS control plane, when on$0.10 an hour$2.40
EKS nodes, 2 t3.small, and their load balancerabout $0.021 an hour each, plus $0.0225about $1.55
Secrets Manager secret, alarms, logs, ECR, S3$0.40 a secret a month; $0.10 an alarm a month; the rest penniespennies

Set up the repository

  1. Make the folders

    Terminal
    mkdir -p quickcart-infra/shared quickcart-infra/stack quickcart-infra/envs/dev quickcart-infra/envs/prod quickcart-infra/envs/dev-usw2
    PartWhat it means
    mkdir -pmake folders, including any missing parent folders, with no error if they exist
    the listthe top-level folders from the layout above
    Terminal
    mkdir -p quickcart-infra/modules/network quickcart-infra/modules/security quickcart-infra/modules/certificate quickcart-infra/modules/alb quickcart-infra/modules/ecs-service quickcart-infra/modules/data quickcart-infra/modules/alarms quickcart-infra/modules/eks
    PartWhat it means
    mkdir -pthe same, for the eight module folders
    Terminal
    cd quickcart-infra
    PartWhat it means
    cd quickcart-inframove into the repository. Every path on this page starts here
    Terminal
    git init
    PartWhat it means
    git initstart a git repository in this folder
    You should see
    Initialized empty Git repository in /home/you/quickcart-infra/.git/
  2. Keep state and secrets out of git

    Create .gitignore at the top of the repository:

    .gitignore
    .terraform/
    *.tfstate
    *.tfstate.*
    *.tfplan
    tfplan
    crash.log
    *.pem
    LineKeeps out of git
    .terraform/downloaded providers and modules. Large, and rebuilt by terraform init
    *.tfstate and *.tfstate.*any local state file or backup. State lives in S3, and it holds secrets
    *.tfplan and tfplansaved plans. They can contain secret values too
    crash.logTerraform’s crash reports
    *.pemcertificate keys

    Keep .terraform.lock.hcl in git. It records the exact provider versions, so everyone, and later the pipeline, gets the same ones.

You should see

Checkpoint: git status lists only .gitignore. The empty folders appear once they hold files.

The shared layer

Images are build artifacts, not infrastructure: they come from a build, not from Terraform. But the repositories that hold them are infrastructure, and every environment needs them before it starts. So they live in their own small layer, applied once, with their own state.

  1. Write shared/main.tf

    shared/main.tf
    terraform {
      required_version = ">= 1.10.0"
    
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 6.0"
        }
      }
    
      backend "s3" {
        bucket       = "quickcart-tfstate-111122223333"
        key          = "shared/terraform.tfstate"
        region       = "us-east-1"
        encrypt      = true
        use_lockfile = true
      }
    }
    
    provider "aws" {
      region = "us-east-1"
    
      default_tags {
        tags = {
          Project   = "quickcart"
          ManagedBy = "terraform"
        }
      }
    }
    
    locals {
      repositories  = toset(["quickcart-orders", "quickcart-ops"])
      second_region = "us-west-2"
    }
    
    data "aws_caller_identity" "current" {}
    
    resource "aws_ecr_repository" "main" {
      for_each = local.repositories
    
      name                 = each.key
      image_tag_mutability = "IMMUTABLE"
      force_delete         = true
    
      image_scanning_configuration {
        scan_on_push = true
      }
    }
    
    resource "aws_ecr_repository" "second_region" {
      for_each = local.repositories
    
      region               = local.second_region
      name                 = each.key
      image_tag_mutability = "IMMUTABLE"
      force_delete         = true
    
      image_scanning_configuration {
        scan_on_push = true
      }
    }
    
    resource "aws_ecr_lifecycle_policy" "main" {
      for_each = aws_ecr_repository.main
    
      repository = each.value.name
      policy = jsonencode({
        rules = [{
          rulePriority = 1
          description  = "Keep the 30 newest images"
          selection = {
            tagStatus   = "any"
            countType   = "imageCountMoreThan"
            countNumber = 30
          }
          action = {
            type = "expire"
          }
        }]
      })
    }
    
    resource "aws_ecr_replication_configuration" "main" {
      replication_configuration {
        rule {
          destination {
            region      = local.second_region
            registry_id = data.aws_caller_identity.current.account_id
          }
    
          repository_filter {
            filter      = "quickcart-"
            filter_type = "PREFIX_MATCH"
          }
        }
      }
    
      depends_on = [aws_ecr_repository.second_region]
    }
    
    output "repository_urls" {
      description = "Where to push images, in the main region"
      value       = { for name, repo in aws_ecr_repository.main : name => repo.repository_url }
    }

    The terraform block, required_providers and the backend "s3" settings are explained in Terraform state. This layer’s key is shared/terraform.tfstate, so it can never touch an environment’s state. The repository_urls output is explained in Variables, locals and outputs.

    SettingWhat it doesIf you leave it outWhen you’d change it
    provider "aws" → default_tagsadds Project and ManagedBy tags to every resource this provider createsresources have no tags unless each one sets them; costs are hard to traceadd an owner or cost-centre tag
    local.repositoriesthe two repositories: the order service, and the operations tool Project 5 builds—add one line per new service
    data "aws_caller_identity"reads your account ID, so no account number is typed into the codeyou’d hard-code 111122223333, and the code would break in another accountnever
    aws_ecr_repository.main → for_eachone repository per name in the list——
    image_tag_mutability = "IMMUTABLE"a tag, once pushed, can never point at a different imagedefaults to MUTABLE: v1 could quietly mean different code tomorrownever, for release images
    force_delete = truelets terraform destroy delete a repository that still holds imagesdestroy fails while any image is insideset to false once a repository holds images you can’t rebuild
    image_scanning_configuration → scan_on_pushscan every image for known security problems as it arrivesno automatic scannever
    aws_ecr_repository.second_region → regioncreates the same repositories in us-west-2. Setting region on one resource is new in AWS provider 6; before, you needed a second provider block with an aliasthe replicated images would land in repositories that Terraform doesn’t manage, and destroy would leave them behindwhen you pick a different second region
    aws_ecr_lifecycle_policydeletes all but the 30 newest images in each repositoryimages pile up forever, and storage slowly costs morekeep more if you need to roll back further
    policy = jsonencode({...})writes the lifecycle rule as JSON from HCL, so a typo is caught by Terraform, not by AWS——
    aws_ecr_replication_configurationcopies every image pushed to a quickcart- repository into the same repository in us-west-2you’d push every image twice, by hand: a manual stepwhen the second region changes
    repository_filter → PREFIX_MATCH "quickcart-"only these repositories are copiedevery repository in the account is copied—
    depends_on = [aws_ecr_repository.second_region]make the destination repositories before turning replication onthe first replicated push could create an unmanaged repositorynever
  2. Apply it

    Terminal
    cd shared
    PartWhat it means
    cd sharedmove into the shared layer’s folder
    Terminal
    terraform init
    PartWhat it means
    terraform initdownload the AWS provider and connect to the state bucket
    You should see
    Successfully configured the backend "s3"! Terraform will automatically
    use this backend unless the backend configuration changes.
    Terminal
    terraform apply
    PartWhat it means
    terraform applyplan, show the plan, and build it once you type yes
    You should see

    Plan: 7 to add, 0 to change, 0 to destroy. Type yes. It ends with Apply complete! Resources: 7 added, 0 changed, 0 destroyed. and the two repository addresses.

  3. Push the order service image, once

    Use the image you built in Project 3. If you deleted it, build it again from that project’s first stage. Log in, tag and push exactly as Project 3 showed:

    Terminal
    aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 111122223333.dkr.ecr.us-east-1.amazonaws.com
    PartWhat it means
    aws ecr get-login-passworda temporary registry password
    |pass it straight into docker login
    docker login --username AWS --password-stdinlog in, reading the password from the pipe
    111122223333.dkr.ecr.us-east-1.amazonaws.comyour registry. Use your account ID
    You should see
    Login Succeeded
    Terminal
    docker tag quickcart-orders:v1 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1
    PartWhat it means
    docker taggive the local image its registry name
    Terminal
    docker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1
    PartWhat it means
    docker pushupload it. Replication then copies it to us-west-2
    Terminal
    cd ..
    PartWhat it means
    cd ..back to the top of the repository
You should see

Checkpoint: in ECR > Repositories, quickcart-orders holds v1. Switch the console’s region to US West (Oregon): the same repository exists there, and within a few minutes it holds v1 too.

The modules

Each module is one folder with one main.tf: its inputs (variable blocks), its resources, and its outputs. A module never knows which environment it’s in. Everything that could differ comes in as a variable, with a description that says why it exists. Modules explains the idea.

  1. The network

    modules/network/main.tf
    variable "name" {
      description = "Prefix for every name, like quickcart-dev"
      type        = string
    }
    
    variable "cidr" {
      description = "The VPC's address range. Each environment and region needs its own, so they can be connected later"
      type        = string
    
      validation {
        condition     = can(cidrhost(var.cidr, 0)) && endswith(var.cidr, "/16")
        error_message = "Use a /16 range, like 10.20.0.0/16."
      }
    }
    
    variable "single_nat_gateway" {
      description = "One NAT gateway for both zones. Cheaper, but the app subnets in the second zone lose outbound internet if the first zone fails"
      type        = bool
    }
    data "aws_availability_zones" "available" {
      state = "available"
    }
    
    data "aws_region" "current" {}
    
    locals {
      azs     = slice(data.aws_availability_zones.available.names, 0, 2)
      zones   = { for i, az in local.azs : az => i }
      nat_azs = var.single_nat_gateway ? slice(local.azs, 0, 1) : local.azs
    }
    
    resource "aws_vpc" "this" {
      cidr_block           = var.cidr
      enable_dns_support   = true
      enable_dns_hostnames = true
    
      tags = { Name = "${var.name}-vpc" }
    }
    
    resource "aws_subnet" "public" {
      for_each = local.zones
    
      vpc_id                  = aws_vpc.this.id
      cidr_block              = cidrsubnet(var.cidr, 8, each.value)
      availability_zone       = each.key
      map_public_ip_on_launch = true
    
      tags = {
        Name                     = "${var.name}-public-${each.key}"
        "kubernetes.io/role/elb" = "1"
      }
    }
    
    resource "aws_subnet" "app" {
      for_each = local.zones
    
      vpc_id            = aws_vpc.this.id
      cidr_block        = cidrsubnet(var.cidr, 8, each.value + 10)
      availability_zone = each.key
    
      tags = {
        Name                              = "${var.name}-app-${each.key}"
        "kubernetes.io/role/internal-elb" = "1"
      }
    }
    
    resource "aws_subnet" "data" {
      for_each = local.zones
    
      vpc_id            = aws_vpc.this.id
      cidr_block        = cidrsubnet(var.cidr, 8, each.value + 20)
      availability_zone = each.key
    
      tags = { Name = "${var.name}-data-${each.key}" }
    }
    
    resource "aws_internet_gateway" "this" {
      vpc_id = aws_vpc.this.id
    
      tags = { Name = "${var.name}-igw" }
    }
    
    resource "aws_eip" "nat" {
      for_each = toset(local.nat_azs)
    
      domain = "vpc"
    
      tags = { Name = "${var.name}-nat-${each.key}" }
    }
    
    resource "aws_nat_gateway" "this" {
      for_each = toset(local.nat_azs)
    
      allocation_id = aws_eip.nat[each.key].id
      subnet_id     = aws_subnet.public[each.key].id
    
      tags = { Name = "${var.name}-nat-${each.key}" }
    
      depends_on = [aws_internet_gateway.this]
    }
    
    resource "aws_route_table" "public" {
      vpc_id = aws_vpc.this.id
    
      route {
        cidr_block = "0.0.0.0/0"
        gateway_id = aws_internet_gateway.this.id
      }
    
      tags = { Name = "${var.name}-public" }
    }
    
    resource "aws_route_table" "app" {
      for_each = local.zones
    
      vpc_id = aws_vpc.this.id
    
      route {
        cidr_block     = "0.0.0.0/0"
        nat_gateway_id = aws_nat_gateway.this[var.single_nat_gateway ? local.nat_azs[0] : each.key].id
      }
    
      tags = { Name = "${var.name}-app-${each.key}" }
    }
    
    resource "aws_route_table" "data" {
      vpc_id = aws_vpc.this.id
    
      tags = { Name = "${var.name}-data" }
    }
    
    resource "aws_route_table_association" "public" {
      for_each = aws_subnet.public
    
      subnet_id      = each.value.id
      route_table_id = aws_route_table.public.id
    }
    
    resource "aws_route_table_association" "app" {
      for_each = aws_subnet.app
    
      subnet_id      = each.value.id
      route_table_id = aws_route_table.app[each.key].id
    }
    
    resource "aws_route_table_association" "data" {
      for_each = aws_subnet.data
    
      subnet_id      = each.value.id
      route_table_id = aws_route_table.data.id
    }
    
    resource "aws_vpc_endpoint" "s3" {
      vpc_id            = aws_vpc.this.id
      service_name      = "com.amazonaws.${data.aws_region.current.region}.s3"
      vpc_endpoint_type = "Gateway"
      route_table_ids   = [for rt in aws_route_table.app : rt.id]
    
      tags = { Name = "${var.name}-s3" }
    }
    output "vpc_id" {
      description = "The VPC's ID"
      value       = aws_vpc.this.id
    }
    
    output "public_subnet_ids" {
      description = "One public subnet per zone, for load balancers and NAT gateways"
      value       = [for s in aws_subnet.public : s.id]
    }
    
    output "app_subnet_ids" {
      description = "One private app subnet per zone, for containers and Kubernetes nodes"
      value       = [for s in aws_subnet.app : s.id]
    }
    
    output "data_subnet_ids" {
      description = "One isolated data subnet per zone, for the database"
      value       = [for s in aws_subnet.data : s.id]
    }

    This is Project 1’s network. The ideas are in What is a VPC?, Subnets and IP ranges and Routes, gateways and NAT.

    SettingWhat it doesIf you leave it outWhen you’d change it
    variable "cidr" → validationrefuses anything that isn’t a valid /16, before anything is builta typo like 10.20.0.0/61 fails half-way through an applyif you plan differently sized networks
    variable "single_nat_gateway"the cost trade-off from Project 1, written where it’s decided—true in dev to save about $1 a day; false in prod
    data "aws_availability_zones" → state = "available"looks up the region’s zone names instead of typing us-east-1ahard-coded zone names break in any other regionnever
    data "aws_region"reads the region this provider is using, for the S3 endpoint’s nameyou’d type the region into the modulenever
    local.azs → slice(…, 0, 2)the first two zones, whatever the region calls them—three zones for more resilience, at the cost of a third NAT gateway in prod
    local.zonesa map like { "us-east-1a" = 0, "us-east-1b" = 1 }, so each subnet has a stable key and a number——
    local.nat_azsone zone or both, from single_nat_gateway——
    aws_vpc → cidr_blockthe VPC’s address rangerequiredonce per environment and region; never after it’s built, because it replaces everything
    enable_dns_support, enable_dns_hostnamesthe VPC can resolve names, and resources get DNS namesRDS endpoints and VPC endpoints don’t resolve properlynever
    tags = { Name = … }the name shown in the console. The provider’s default tags are added toothe console shows a blank name—
    aws_subnet → for_each = local.zonesone subnet per zone, keyed by zone name. Why not count: see Loops and lifecycle——
    cidr_block = cidrsubnet(var.cidr, 8, …)carves a /24 out of the /16: public gets .0. and .1., app .10. and .11., data .20. and .21.you’d work out and type every range by handif a tier needs more than 251 addresses
    availability_zone = each.keywhich zone the subnet lives inAWS picks, and two subnets could land in one zonenever
    map_public_ip_on_launch = true (public only)anything started here gets a public addressNAT gateways still work (they use an Elastic IP), but a public test instance couldn’t be reachednever; nothing but the load balancer and NAT live here
    "kubernetes.io/role/elb" = "1"tells the EKS load balancer controller it may put internet-facing load balancers here: the tag you added by hand in Project 3the Ingress never gets an addressnever
    "kubernetes.io/role/internal-elb" = "1"the same, for internal load balancers, on the app subnetsinternal load balancers can’t be placednever
    aws_internet_gatewaythe VPC’s door to the internetnothing public worksnever
    aws_eip.nat → domain = "vpc"a fixed public address for each NAT gatewaythe NAT gateway has no address to usenever
    aws_nat_gateway → allocation_id, subnet_idwhich address it uses, and which public subnet it sits inrequirednever
    depends_on = [aws_internet_gateway.this]build the internet gateway first. Nothing in the NAT gateway’s settings points at it, so Terraform can’t see the orderthe NAT gateway can be created before the VPC can reach the internet, and failnever
    aws_route_table.public → route to the internet gatewaypublic subnets send 0.0.0.0/0 to the internetthe load balancer is unreachablenever
    aws_route_table.app → nat_gateway_ideach app subnet sends outbound traffic to a NAT gateway: its own zone’s, or the single onecontainers can’t reach ECR, so they can’t startnever
    aws_route_table.data with no routesonly the VPC’s built-in local route: the database subnets can’t reach or be reached from the internet—never
    aws_route_table_associationattaches each subnet to its route tablethe subnet uses the VPC’s main route tablenever
    aws_vpc_endpoint → vpc_endpoint_type = "Gateway", service_name, route_table_idsS3 traffic from the app subnets goes straight to S3 instead of through the NAT gateway. Gateway endpoints are free; NAT traffic is notS3 traffic costs NAT data chargesadd interface endpoints (for ECR, Secrets Manager and logs) to drop the NAT gateway entirely, at about $7 a month each per zone
    the four output blocksthe IDs other modules needother modules can’t find the subnets—
  2. The security groups

    modules/security/main.tf
    variable "name" {
      description = "Prefix for every name, like quickcart-dev"
      type        = string
    }
    
    variable "vpc_id" {
      description = "The VPC the security groups belong to"
      type        = string
    }
    
    variable "app_port" {
      description = "The port the order service listens on inside its container"
      type        = number
    }
    
    resource "aws_security_group" "alb" {
      name        = "${var.name}-alb"
      description = "Load balancer: HTTPS and HTTP from anywhere"
      vpc_id      = var.vpc_id
    }
    
    resource "aws_security_group" "app" {
      name        = "${var.name}-app"
      description = "Order service: only from the load balancer"
      vpc_id      = var.vpc_id
    }
    
    resource "aws_security_group" "db" {
      name        = "${var.name}-db"
      description = "Database: only from the order service"
      vpc_id      = var.vpc_id
    }
    
    resource "aws_vpc_security_group_ingress_rule" "alb_https" {
      security_group_id = aws_security_group.alb.id
      description       = "HTTPS from anywhere"
      cidr_ipv4         = "0.0.0.0/0"
      ip_protocol       = "tcp"
      from_port         = 443
      to_port           = 443
    }
    
    resource "aws_vpc_security_group_ingress_rule" "alb_http" {
      security_group_id = aws_security_group.alb.id
      description       = "HTTP from anywhere, only to be redirected to HTTPS"
      cidr_ipv4         = "0.0.0.0/0"
      ip_protocol       = "tcp"
      from_port         = 80
      to_port           = 80
    }
    
    resource "aws_vpc_security_group_egress_rule" "alb_to_app" {
      security_group_id            = aws_security_group.alb.id
      description                  = "To the order service only"
      referenced_security_group_id = aws_security_group.app.id
      ip_protocol                  = "tcp"
      from_port                    = var.app_port
      to_port                      = var.app_port
    }
    
    resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
      security_group_id            = aws_security_group.app.id
      description                  = "From the load balancer only"
      referenced_security_group_id = aws_security_group.alb.id
      ip_protocol                  = "tcp"
      from_port                    = var.app_port
      to_port                      = var.app_port
    }
    
    resource "aws_vpc_security_group_egress_rule" "app_out" {
      security_group_id = aws_security_group.app.id
      description       = "Out to ECR, Secrets Manager, CloudWatch and the database"
      cidr_ipv4         = "0.0.0.0/0"
      ip_protocol       = "-1"
    }
    
    resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
      security_group_id            = aws_security_group.db.id
      description                  = "MySQL from the order service only"
      referenced_security_group_id = aws_security_group.app.id
      ip_protocol                  = "tcp"
      from_port                    = 3306
      to_port                      = 3306
    }
    
    output "alb_id" {
      description = "Security group for the load balancer"
      value       = aws_security_group.alb.id
    }
    
    output "app_id" {
      description = "Security group for the order service's containers"
      value       = aws_security_group.app.id
    }
    
    output "db_id" {
      description = "Security group for the database"
      value       = aws_security_group.db.id
    }

    The chain from Security groups: anyone may reach the load balancer, only the load balancer may reach the app, only the app may reach the database.

    SettingWhat it doesIf you leave it outWhen you’d change it
    aws_security_group → name, description, vpc_idan empty group with a name, a reason, and a VPCname and description are optional but you’d get random names; vpc_id defaults to the default VPC, which is wrong herenever
    separate aws_vpc_security_group_ingress_rule and egress_rule resourcesone resource per rule. Adding or removing one rule never rewrites the othersinline ingress blocks work too, but mixing both styles makes Terraform fight itselfnever mix the two
    cidr_ipv4 = "0.0.0.0/0"from anywhere on the internet—only on the load balancer’s 443 and 80
    referenced_security_group_idfrom any resource in that group, whatever its address. Tasks come and go; the rule keeps workingyou’d need IP addresses, which change on every releasenever
    ip_protocol, from_port, to_portTCP on one port. "-1" means every protocol and portrequired—
    alb_to_app egressthe load balancer may only send to the app porta Terraform-made group has no outbound rules, so the load balancer couldn’t reach anythingnever
    app_out egress to anywherethe containers may reach ECR, Secrets Manager, CloudWatch and the databasethe tasks can’t pull their image or read the secret, and never starttighten to the VPC endpoints’ prefix lists once you add them
    no egress rule on dbthe database starts no connections—never
    description on each ruleshown in the console, so the next person knows why the rule existsthe rule works, but nobody knows why it’s therealways write one
  3. The certificate

    modules/certificate/main.tf
    terraform {
      required_providers {
        aws = {
          source = "hashicorp/aws"
        }
        tls = {
          source = "hashicorp/tls"
        }
      }
    }
    
    variable "name" {
      description = "Prefix for every name, like quickcart-dev"
      type        = string
    }
    
    variable "domain_name" {
      description = "A domain you own, like orders.example.com. Leave empty for a self-signed certificate"
      type        = string
    }
    
    variable "zone_id" {
      description = "The Route 53 hosted zone for domain_name. Leave empty for a self-signed certificate"
      type        = string
    }
    
    locals {
      self_signed = var.domain_name == ""
    }
    
    resource "tls_private_key" "this" {
      count = local.self_signed ? 1 : 0
    
      algorithm = "RSA"
      rsa_bits  = 2048
    }
    
    resource "tls_self_signed_cert" "this" {
      count = local.self_signed ? 1 : 0
    
      private_key_pem       = tls_private_key.this[0].private_key_pem
      validity_period_hours = 2160
      allowed_uses          = ["key_encipherment", "digital_signature", "server_auth"]
    
      subject {
        common_name = "${var.name}.quickcart.test"
      }
    }
    
    resource "aws_acm_certificate" "imported" {
      count = local.self_signed ? 1 : 0
    
      private_key      = tls_private_key.this[0].private_key_pem
      certificate_body = tls_self_signed_cert.this[0].cert_pem
    
      tags = { Name = "${var.name}-self-signed" }
    
      lifecycle {
        create_before_destroy = true
      }
    }
    
    resource "aws_acm_certificate" "issued" {
      count = local.self_signed ? 0 : 1
    
      domain_name       = var.domain_name
      validation_method = "DNS"
    
      tags = { Name = var.name }
    
      lifecycle {
        create_before_destroy = true
      }
    }
    
    resource "aws_route53_record" "validation" {
      for_each = local.self_signed ? {} : {
        for dvo in aws_acm_certificate.issued[0].domain_validation_options : dvo.domain_name => dvo
      }
    
      zone_id         = var.zone_id
      name            = each.value.resource_record_name
      type            = each.value.resource_record_type
      records         = [each.value.resource_record_value]
      ttl             = 60
      allow_overwrite = true
    }
    
    resource "aws_acm_certificate_validation" "issued" {
      count = local.self_signed ? 0 : 1
    
      certificate_arn         = aws_acm_certificate.issued[0].arn
      validation_record_fqdns = [for r in aws_route53_record.validation : r.fqdn]
    }
    
    output "arn" {
      description = "The certificate for the load balancer's HTTPS listener"
      value       = local.self_signed ? aws_acm_certificate.imported[0].arn : aws_acm_certificate_validation.issued[0].certificate_arn
    }

    Two ways to get HTTPS, chosen by one variable. With domain_name empty, it makes the self-signed certificate you made with openssl in Project 3, with no manual step. With a domain you own in Route 53, it requests a trusted one and validates it by DNS. Domain names and HTTPS explains the difference.

    SettingWhat it doesIf you leave it outWhen you’d change it
    terraform → required_providers → tlsthis module uses the tls provider as well as awsTerraform assumes hashicorp/tls anyway; saying so is clearernever
    local.self_signedtrue when there’s no domain——
    count = local.self_signed ? 1 : 0build these resources in one mode and not the other. count is right here: it’s on or off, never a list. See Loops and lifecycleboth paths would be built—
    tls_private_key → algorithm, rsa_bitsa 2048-bit RSA key, the type ACM acceptsrequirednever
    tls_self_signed_cert → private_key_pemsigns the certificate with that keyrequired—
    validity_period_hours = 2160valid for 90 daysrequiredshorter for practice; a real site uses a trusted certificate
    allowed_useswhat the certificate may be used for: a TLS serverrequirednever
    subject → common_namethe name inside the certificatethe certificate has no name—
    aws_acm_certificate.imported → private_key, certificate_bodyimports the pair into ACM, like the console’s Import buttonrequired for an import—
    lifecycle → create_before_destroywhen the certificate is replaced, make the new one first, so the listener always has oneTerraform deletes the old one first, and the listener refuses because it’s in usenever
    aws_acm_certificate.issued → domain_name, validation_method = "DNS"requests a trusted certificate, proven by a DNS record—add subject_alternative_names for more names
    aws_route53_record.validation → for_each over domain_validation_optionswrites the record ACM asks for, one per namethe certificate waits forever in Pending validation—
    zone_id, name, type, records, ttlwhere the record goes, and what it saysrequired—
    allow_overwrite = trueif a validation record already exists from an earlier try, take it overthe second apply fails because the record existsnever
    aws_acm_certificate_validationnot a real AWS thing: a wait, until ACM says Issuedthe listener could be given a certificate that isn’t ready, and failnever
    output "arn"whichever certificate was made——
  4. The load balancer

    modules/alb/main.tf
    variable "name" {
      description = "Prefix for every name, like quickcart-dev"
      type        = string
    }
    
    variable "vpc_id" {
      description = "The VPC the target groups belong to"
      type        = string
    }
    
    variable "public_subnet_ids" {
      description = "Public subnets in at least two zones"
      type        = list(string)
    }
    
    variable "security_group_id" {
      description = "The load balancer's security group"
      type        = string
    }
    
    variable "certificate_arn" {
      description = "The certificate for HTTPS"
      type        = string
    }
    
    variable "app_port" {
      description = "The port the containers listen on"
      type        = number
    }
    
    variable "deletion_protection" {
      description = "Refuse to delete the load balancer. On in production"
      type        = bool
    }
    
    resource "aws_lb" "this" {
      name                       = "${var.name}-alb"
      internal                   = false
      load_balancer_type         = "application"
      security_groups            = [var.security_group_id]
      subnets                    = var.public_subnet_ids
      enable_deletion_protection = var.deletion_protection
    }
    
    resource "aws_lb_target_group" "this" {
      for_each = toset(["stable", "canary"])
    
      name                 = "${var.name}-${each.key}"
      port                 = var.app_port
      protocol             = "HTTP"
      vpc_id               = var.vpc_id
      target_type          = "ip"
      deregistration_delay = 30
    
      health_check {
        path                = "/health"
        matcher             = "200"
        interval            = 10
        timeout             = 5
        healthy_threshold   = 2
        unhealthy_threshold = 2
      }
    }
    
    resource "aws_lb_listener" "https" {
      load_balancer_arn = aws_lb.this.arn
      port              = 443
      protocol          = "HTTPS"
      ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
      certificate_arn   = var.certificate_arn
    
      default_action {
        type = "forward"
    
        forward {
          target_group {
            arn    = aws_lb_target_group.this["stable"].arn
            weight = 100
          }
    
          target_group {
            arn    = aws_lb_target_group.this["canary"].arn
            weight = 0
          }
        }
      }
    
      lifecycle {
        ignore_changes = [default_action]
      }
    }
    
    resource "aws_lb_listener" "http" {
      load_balancer_arn = aws_lb.this.arn
      port              = 80
      protocol          = "HTTP"
    
      default_action {
        type = "redirect"
    
        redirect {
          port        = "443"
          protocol    = "HTTPS"
          status_code = "HTTP_301"
        }
      }
    }
    
    output "dns_name" {
      description = "The load balancer's address"
      value       = aws_lb.this.dns_name
    }
    
    output "arn_suffix" {
      description = "The short name CloudWatch uses for the load balancer"
      value       = aws_lb.this.arn_suffix
    }
    
    output "https_listener_arn" {
      description = "The listener whose weights a canary release changes"
      value       = aws_lb_listener.https.arn
    }
    
    output "target_group_arns" {
      description = "The stable and canary target groups"
      value       = { for k, tg in aws_lb_target_group.this : k => tg.arn }
    }
    
    output "target_group_arn_suffixes" {
      description = "The short names CloudWatch uses for the target groups"
      value       = { for k, tg in aws_lb_target_group.this : k => tg.arn_suffix }
    }

    Project 3’s load balancer, with the lessons you learned the hard way built in. Load balancers explains the pieces; Canary from a pipeline explains the second target group.

    SettingWhat it doesIf you leave it outWhen you’d change it
    aws_lb → namethe load balancer’s name; 32 characters at mosta random name—
    internal = falsereachable from the internetdefaults to false anyway; written so nobody has to wondertrue for a service only other services call
    load_balancer_type = "application"an ALB, which understands HTTPdefaults to applicationnetwork for plain TCP
    security_groups, subnetsits firewall, and the public subnets in two zones it lives inrequired for an internet-facing ALB—
    enable_deletion_protectionrefuses terraform destroy until turned offdefaults to falsetrue in prod
    aws_lb_target_group → for_each = toset(["stable", "canary"])two target groups: stable gets the traffic; canary sits empty until a release——
    port, protocol = "HTTP"the port and protocol the load balancer uses to reach the containersrequiredwhen the app’s port changes
    vpc_idthe VPC the targets live inrequired—
    target_type = "ip"targets are IP addresses, because Fargate tasks have no instance behind themdefaults to instance, and ECS refuses to attach the servicenever, for Fargate
    deregistration_delay = 30a removed task gets 30 seconds to finish its requests300 seconds: every release waits five minutes for nothinglonger if the app has slow requests
    health_check → path, matcher/health must answer 200checks /, which proves less—
    interval, timeoutcheck every 10 seconds; give up on one check after 530 and 5—
    healthy_threshold = 2, unhealthy_threshold = 2two good checks to join, two bad checks to leave5 and 2: a new task waits 50 seconds for traffic—
    aws_lb_listener.https → port, protocol, certificate_arnHTTPS on 443 with the certificaterequired—
    ssl_policyTLS 1.2 and 1.3 onlyan older default policy that also allows weaker settingswhen AWS publishes a newer recommended policy
    default_action → forward with two target_group blocks and weight100% to stable, 0% to canary. A canary release changes these numbersa single target group, and no way to split trafficnever here; the pipeline changes the weights
    lifecycle → ignore_changes = [default_action]Terraform sets the weights once and then leaves them to the pipelinethe next plan would try to put the weights back mid-canary: a plan that’s never cleannever
    aws_lb_listener.http → redirectport 80 answers with a 301 to HTTPSplain HTTP would be refused or served unencryptednever
    the five outputsthe address, and the names and ARNs the pipeline and alarms need——
  5. The ECS service

    modules/ecs-service/main.tf
    variable "name" {
      description = "The service's full name, like quickcart-dev-orders"
      type        = string
    }
    
    variable "cluster_arn" {
      description = "The ECS cluster to run in"
      type        = string
    }
    
    variable "image" {
      description = "The full image address, including its tag"
      type        = string
    }
    
    variable "cpu" {
      description = "CPU units for each task. 256 is a quarter of a vCPU"
      type        = number
    }
    
    variable "memory" {
      description = "Memory for each task, in MiB"
      type        = number
    }
    
    variable "app_port" {
      description = "The port the container listens on"
      type        = number
    }
    
    variable "desired_count" {
      description = "How many tasks to start with. Terraform sets it once; after that, releases and scaling own it"
      type        = number
    }
    
    variable "subnet_ids" {
      description = "Private app subnets in at least two zones"
      type        = list(string)
    }
    
    variable "security_group_id" {
      description = "The app's security group"
      type        = string
    }
    
    variable "target_group_arn" {
      description = "The target group the tasks register with"
      type        = string
    }
    
    variable "execution_role_arn" {
      description = "The role ECS uses to pull the image, read secrets and write logs"
      type        = string
    }
    
    variable "environment_variables" {
      description = "Plain settings passed to the container"
      type        = map(string)
    }
    
    variable "secrets" {
      description = "Settings read from Secrets Manager when the task starts: name => secret ARN and key"
      type        = map(string)
    }
    
    variable "log_retention_days" {
      description = "How long to keep the container's logs"
      type        = number
    }
    
    data "aws_region" "current" {}
    
    resource "aws_cloudwatch_log_group" "this" {
      name              = "/ecs/${var.name}"
      retention_in_days = var.log_retention_days
    }
    
    resource "aws_ecs_task_definition" "this" {
      family                   = var.name
      requires_compatibilities = ["FARGATE"]
      network_mode             = "awsvpc"
      cpu                      = var.cpu
      memory                   = var.memory
      execution_role_arn       = var.execution_role_arn
    
      runtime_platform {
        operating_system_family = "LINUX"
        cpu_architecture        = "X86_64"
      }
    
      container_definitions = jsonencode([{
        name      = "app"
        image     = var.image
        essential = true
    
        portMappings = [{
          containerPort = var.app_port
          protocol      = "tcp"
        }]
    
        environment = [for k, v in var.environment_variables : { name = k, value = v }]
        secrets     = [for k, v in var.secrets : { name = k, valueFrom = v }]
    
        logConfiguration = {
          logDriver = "awslogs"
          options = {
            "awslogs-group"         = aws_cloudwatch_log_group.this.name
            "awslogs-region"        = data.aws_region.current.region
            "awslogs-stream-prefix" = "app"
          }
        }
      }])
    }
    
    resource "aws_ecs_service" "this" {
      name                              = var.name
      cluster                           = var.cluster_arn
      task_definition                   = aws_ecs_task_definition.this.arn
      desired_count                     = var.desired_count
      launch_type                       = "FARGATE"
      health_check_grace_period_seconds = 30
    
      deployment_minimum_healthy_percent = 100
      deployment_maximum_percent         = 200
    
      deployment_circuit_breaker {
        enable   = true
        rollback = true
      }
    
      network_configuration {
        subnets          = var.subnet_ids
        security_groups  = [var.security_group_id]
        assign_public_ip = false
      }
    
      load_balancer {
        target_group_arn = var.target_group_arn
        container_name   = "app"
        container_port   = var.app_port
      }
    
      lifecycle {
        ignore_changes = [desired_count, task_definition]
      }
    }
    
    output "service_name" {
      description = "The ECS service's name"
      value       = aws_ecs_service.this.name
    }
    
    output "task_family" {
      description = "The task definition family. Every release adds a revision to it"
      value       = aws_ecs_task_definition.this.family
    }
    
    output "log_group" {
      description = "Where the containers' output goes"
      value       = aws_cloudwatch_log_group.this.name
    }

    Everything you clicked in Project 3’s task definition and service forms, as one block. ECS on Fargate and Releases without downtime explain each setting.

    SettingWhat it doesIf you leave it outWhen you’d change it
    aws_cloudwatch_log_group → name, retention_in_dayswhere the container’s output goes, and how long it’s keptECS can’t write logs, and tasks fail to startlonger in prod
    aws_ecs_task_definition → familythe recipe’s name; each change adds a revisionrequired—
    requires_compatibilities = ["FARGATE"], network_mode = "awsvpc"runs on Fargate, and each task gets its own network interface and IPECS rejects the task for Fargatenever
    cpu, memorythe task’s sizerequired for Fargateper environment
    execution_role_arnthe role ECS uses to pull the image, read the secret and write logsthe task can’t start—
    runtime_platformLinux on x86: matches --platform linux/amd64defaults to the same, but writing it catches an ARM image earlyARM64 if you build ARM images, which are about 20% cheaper on Fargate
    container_definitions = jsonencode([…])the container’s settings in AWS’s own JSON shape, written as HCLrequired—
    name = "app", image, essentialthe container’s name, which image, and “if this stops, the task stops”required—
    portMappingsthe container listens on 8080the load balancer can’t reach it—
    environmentplain settings, built from a map with a for expressionnone are passed—
    secrets → valueFromread the database password from Secrets Manager when the task starts. It never appears in the code, the task definition or the planthe app has no password—
    logConfiguration → awslogssend output to the log group, in this region, under the prefix appthe output is lost—
    aws_ecs_service → cluster, task_definition, desired_count, launch_typerun this recipe on Fargate in this cluster, this many timesrequired—
    health_check_grace_period_seconds = 30ignore failed health checks for 30 seconds after a task startsa slow-starting task can be killed before it’s readylonger for slow starters
    deployment_minimum_healthy_percent = 100, deployment_maximum_percent = 200never go below the full count during a release; allow double100 and 200 are the defaults for a service like this; written so nobody changes them by accidentnever
    deployment_circuit_breaker → enable, rollbacka release whose tasks keep failing stops and goes back by itselfa broken release retries forevernever
    network_configuration → subnets, security_groups, assign_public_ip = falseprivate app subnets, the app security group, no public addressrequired for awsvpcnever; NAT gives them their way out
    load_balancer → target_group_arn, container_name, container_portregister each task’s IP with the target groupno traffic reaches the tasks—
    lifecycle → ignore_changes = [desired_count, task_definition]Terraform sets these once. After that, releases (Project 5’s pipeline) change the task definition, and scaling changes the countevery release would show up as drift, and the next apply would roll production back to the image in Terraformnever, once a pipeline releases
    the three outputsnames the pipeline and alarms need——
  6. The data layer

    modules/data/main.tf
    variable "name" {
      description = "Prefix for every name, like quickcart-dev"
      type        = string
    }
    
    variable "subnet_ids" {
      description = "Isolated data subnets in at least two zones"
      type        = list(string)
    }
    
    variable "security_group_id" {
      description = "The database's security group"
      type        = string
    }
    
    variable "instance_class" {
      description = "The database server's size"
      type        = string
    }
    
    variable "multi_az" {
      description = "Keep a standby copy in the second zone that takes over by itself"
      type        = bool
    }
    
    variable "backup_retention_days" {
      description = "How many days of automatic backups to keep"
      type        = number
    }
    
    variable "deletion_protection" {
      description = "Refuse to delete the database, and take a final snapshot if it is deleted. On in production"
      type        = bool
    }
    
    data "aws_caller_identity" "current" {}
    
    data "aws_region" "current" {}
    
    resource "aws_db_subnet_group" "this" {
      name       = "${var.name}-db"
      subnet_ids = var.subnet_ids
    }
    
    resource "aws_db_instance" "this" {
      identifier     = "${var.name}-db"
      engine         = "mysql"
      engine_version = "8.4"
      instance_class = var.instance_class
    
      allocated_storage = 20
      storage_type      = "gp3"
      storage_encrypted = true
    
      db_name                     = "quickcart"
      username                    = "quickcart_admin"
      manage_master_user_password = true
    
      multi_az               = var.multi_az
      db_subnet_group_name   = aws_db_subnet_group.this.name
      vpc_security_group_ids = [var.security_group_id]
      publicly_accessible    = false
    
      backup_retention_period   = var.backup_retention_days
      deletion_protection       = var.deletion_protection
      skip_final_snapshot       = !var.deletion_protection
      final_snapshot_identifier = "${var.name}-db-final"
    }
    
    resource "aws_s3_bucket" "assets" {
      bucket        = "${var.name}-assets-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.region}"
      force_destroy = !var.deletion_protection
    }
    
    resource "aws_s3_bucket_public_access_block" "assets" {
      bucket = aws_s3_bucket.assets.id
    
      block_public_acls       = true
      block_public_policy     = true
      ignore_public_acls      = true
      restrict_public_buckets = true
    }
    
    resource "aws_s3_bucket_versioning" "assets" {
      bucket = aws_s3_bucket.assets.id
    
      versioning_configuration {
        status = "Enabled"
      }
    }
    
    output "db_address" {
      description = "The database's host name, reachable only from inside the VPC"
      value       = aws_db_instance.this.address
    }
    
    output "db_secret_arn" {
      description = "The Secrets Manager secret RDS created for the master password"
      value       = aws_db_instance.this.master_user_secret[0].secret_arn
    }
    
    output "db_identifier" {
      description = "The database's name in RDS"
      value       = aws_db_instance.this.identifier
    }
    
    output "bucket_name" {
      description = "The private bucket for order receipts and images"
      value       = aws_s3_bucket.assets.bucket
    }

    Project 2’s database and bucket. Managed databases: RDS, Files on S3 and Secrets explain them.

    SettingWhat it doesIf you leave it outWhen you’d change it
    aws_db_subnet_group → name, subnet_idsthe isolated data subnets the database may use, in two zonesthe database goes in the default VPCnever
    identifierthe database’s name in RDSa random namenever after creation; it replaces the database
    engine = "mysql", engine_version = "8.4"MySQL 8.4, the long-term-support version. RDS picks the newest 8.4 release and keeps it patchedrequired; without a version you get today’s default, which changeswhen you plan a major upgrade. MySQL 8.0’s standard support has ended, so RDS charges extra to keep running it
    instance_classthe server’s sizerequiredper environment
    allocated_storage = 20, storage_type = "gp3"20 GB of current-generation SSDrequired; the type defaults to older gp2when the data grows
    storage_encrypted = truethe disk and its backups are encryptedunencrypted, and it can’t be turned on later without a copynever
    db_name, usernamethe first database and the admin user’s nameno database is created inside the server—
    manage_master_user_password = trueRDS creates the password and keeps it in Secrets Manager. It never appears in code, state or planyou’d pass a password, and it would sit in state in plain textnever
    multi_aza standby copy in the second zone that takes over by itselfone copy; a zone failure takes the database downtrue in prod
    db_subnet_group_name, vpc_security_group_idswhere it lives and who may reach itthe default VPC and default groupnever
    publicly_accessible = falseno public addressdefaults to false; written so nobody flips itnever
    backup_retention_perioddays of automatic backups1 day1 in dev to save money; 7 or more in prod
    deletion_protectionrefuse to delete the databasedefaults to falsetrue in prod
    skip_final_snapshot = !var.deletion_protectionin dev, delete without a final snapshot; in prod, always take onedefaults to false: destroy fails without final_snapshot_identifiernever
    final_snapshot_identifierthe name of that last snapshotrequired when a final snapshot is taken—
    aws_s3_bucket → bucketa name that is unique worldwide: environment, account and regiona random name—
    force_destroy = !var.deletion_protectionin dev, destroy empties the bucket firstdestroy fails while the bucket holds filesnever in prod
    aws_s3_bucket_public_access_block, all four settings trueno policy or ACL can ever make the bucket publicnew buckets block public access by default today; saying so survives a changed account settingnever
    aws_s3_bucket_versioning → status = "Enabled"every overwritten or deleted file can be brought backa deleted receipt is gonenever
    output "db_secret_arn" → master_user_secret[0].secret_arnwhere RDS put the password——
  7. The alarms

    modules/alarms/main.tf
    variable "name" {
      description = "Prefix for every name, like quickcart-dev"
      type        = string
    }
    
    variable "alb_arn_suffix" {
      description = "The load balancer, as CloudWatch names it"
      type        = string
    }
    
    variable "stable_target_group_arn_suffix" {
      description = "The stable target group, as CloudWatch names it"
      type        = string
    }
    
    variable "db_identifier" {
      description = "The database's name in RDS"
      type        = string
    }
    
    variable "alarm_email" {
      description = "Where to send alarm emails. Leave empty for no emails"
      type        = string
    }
    
    resource "aws_sns_topic" "alarms" {
      name = "${var.name}-alarms"
    }
    
    resource "aws_sns_topic_subscription" "email" {
      count = var.alarm_email == "" ? 0 : 1
    
      topic_arn = aws_sns_topic.alarms.arn
      protocol  = "email"
      endpoint  = var.alarm_email
    }
    
    resource "aws_cloudwatch_metric_alarm" "errors" {
      alarm_name          = "${var.name}-5xx"
      alarm_description   = "The order service returned more than 10 server errors in 5 minutes"
      namespace           = "AWS/ApplicationELB"
      metric_name         = "HTTPCode_Target_5XX_Count"
      dimensions          = { LoadBalancer = var.alb_arn_suffix }
      statistic           = "Sum"
      period              = 300
      evaluation_periods  = 1
      threshold           = 10
      comparison_operator = "GreaterThanThreshold"
      treat_missing_data  = "notBreaching"
      alarm_actions       = [aws_sns_topic.alarms.arn]
      ok_actions          = [aws_sns_topic.alarms.arn]
    }
    
    resource "aws_cloudwatch_metric_alarm" "unhealthy" {
      alarm_name          = "${var.name}-unhealthy"
      alarm_description   = "At least one order service task has failed its health check for 3 minutes"
      namespace           = "AWS/ApplicationELB"
      metric_name         = "UnHealthyHostCount"
      dimensions          = { LoadBalancer = var.alb_arn_suffix, TargetGroup = var.stable_target_group_arn_suffix }
      statistic           = "Maximum"
      period              = 60
      evaluation_periods  = 3
      threshold           = 0
      comparison_operator = "GreaterThanThreshold"
      treat_missing_data  = "notBreaching"
      alarm_actions       = [aws_sns_topic.alarms.arn]
      ok_actions          = [aws_sns_topic.alarms.arn]
    }
    
    resource "aws_cloudwatch_metric_alarm" "db_cpu" {
      alarm_name          = "${var.name}-db-cpu"
      alarm_description   = "The database has been above 80% CPU for 10 minutes"
      namespace           = "AWS/RDS"
      metric_name         = "CPUUtilization"
      dimensions          = { DBInstanceIdentifier = var.db_identifier }
      statistic           = "Average"
      period              = 300
      evaluation_periods  = 2
      threshold           = 80
      comparison_operator = "GreaterThanThreshold"
      treat_missing_data  = "notBreaching"
      alarm_actions       = [aws_sns_topic.alarms.arn]
      ok_actions          = [aws_sns_topic.alarms.arn]
    }

    Project 2’s alarms, now impossible to forget. Alarms that actually fire explains how to choose thresholds.

    SettingWhat it doesIf you leave it outWhen you’d change it
    aws_sns_topicwhere alarms send their messagesalarms change colour in the console, and tell no one—
    aws_sns_topic_subscription → count, protocol = "email", endpointan email subscription, only if you gave an address. AWS emails a confirmation link, the one click this project can’t avoid, so it’s optional—add a chat or paging integration
    alarm_name, alarm_descriptionwhat it’s called, and what it means in plain wordsthe person woken at 3am has to guess—
    namespace, metric_name, dimensionswhich number to watch, and for which load balancer, target group or databaserequired—
    statistic, period, evaluation_periodshow to add up the numbers, over what window, for how many windows in a rowrequired—
    threshold, comparison_operatorthe line that counts as badrequiredwhen you learn what normal looks like
    treat_missing_data = "notBreaching"no traffic means no errors, not an alarmmissing data leaves the alarm in INSUFFICIENT_DATA—
    alarm_actions, ok_actionsnotify when it goes bad, and when it recoversnobody is told either way—
  8. EKS

    modules/eks/main.tf
    terraform {
      required_providers {
        aws = {
          source = "hashicorp/aws"
        }
        http = {
          source = "hashicorp/http"
        }
      }
    }
    
    variable "name" {
      description = "Prefix for every name, like quickcart-dev"
      type        = string
    }
    
    variable "subnet_ids" {
      description = "Private app subnets in at least two zones, for the nodes"
      type        = list(string)
    }
    
    variable "node_instance_type" {
      description = "The EC2 size for each node"
      type        = string
    }
    
    variable "node_count" {
      description = "How many nodes to run"
      type        = number
    }
    
    variable "admin_role_arns" {
      description = "Extra IAM roles that may run kubectl as cluster admins, such as a CI agent's role"
      type        = list(string)
    }
    
    data "aws_iam_policy_document" "cluster_assume" {
      statement {
        actions = ["sts:AssumeRole", "sts:TagSession"]
    
        principals {
          type        = "Service"
          identifiers = ["eks.amazonaws.com"]
        }
      }
    }
    
    resource "aws_iam_role" "cluster" {
      name               = "${var.name}-eks-cluster"
      assume_role_policy = data.aws_iam_policy_document.cluster_assume.json
    }
    
    resource "aws_iam_role_policy_attachment" "cluster" {
      role       = aws_iam_role.cluster.name
      policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
    }
    
    resource "aws_eks_cluster" "this" {
      name     = "${var.name}-eks"
      role_arn = aws_iam_role.cluster.arn
    
      vpc_config {
        subnet_ids              = var.subnet_ids
        endpoint_public_access  = true
        endpoint_private_access = true
      }
    
      access_config {
        authentication_mode                         = "API"
        bootstrap_cluster_creator_admin_permissions = true
      }
    
      upgrade_policy {
        support_type = "STANDARD"
      }
    
      depends_on = [aws_iam_role_policy_attachment.cluster]
    }
    
    data "aws_iam_policy_document" "node_assume" {
      statement {
        actions = ["sts:AssumeRole"]
    
        principals {
          type        = "Service"
          identifiers = ["ec2.amazonaws.com"]
        }
      }
    }
    
    resource "aws_iam_role" "node" {
      name               = "${var.name}-eks-node"
      assume_role_policy = data.aws_iam_policy_document.node_assume.json
    }
    
    resource "aws_iam_role_policy_attachment" "node" {
      for_each = toset([
        "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
        "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy",
        "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
      ])
    
      role       = aws_iam_role.node.name
      policy_arn = each.value
    }
    
    resource "aws_eks_node_group" "this" {
      cluster_name    = aws_eks_cluster.this.name
      node_group_name = "${var.name}-nodes"
      node_role_arn   = aws_iam_role.node.arn
      subnet_ids      = var.subnet_ids
      instance_types  = [var.node_instance_type]
    
      scaling_config {
        desired_size = var.node_count
        min_size     = var.node_count
        max_size     = var.node_count + 1
      }
    
      update_config {
        max_unavailable = 1
      }
    
      depends_on = [aws_iam_role_policy_attachment.node]
    }
    
    resource "aws_eks_addon" "pod_identity" {
      cluster_name = aws_eks_cluster.this.name
      addon_name   = "eks-pod-identity-agent"
    }
    
    resource "aws_eks_access_entry" "admins" {
      for_each = toset(var.admin_role_arns)
    
      cluster_name  = aws_eks_cluster.this.name
      principal_arn = each.value
    }
    
    resource "aws_eks_access_policy_association" "admins" {
      for_each = toset(var.admin_role_arns)
    
      cluster_name  = aws_eks_cluster.this.name
      principal_arn = each.value
      policy_arn    = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
    
      access_scope {
        type = "cluster"
      }
    
      depends_on = [aws_eks_access_entry.admins]
    }
    
    data "aws_iam_policy_document" "lbc_assume" {
      statement {
        actions = ["sts:AssumeRole", "sts:TagSession"]
    
        principals {
          type        = "Service"
          identifiers = ["pods.eks.amazonaws.com"]
        }
      }
    }
    
    resource "aws_iam_role" "lbc" {
      name               = "${var.name}-eks-lbc"
      assume_role_policy = data.aws_iam_policy_document.lbc_assume.json
    }
    
    data "http" "lbc_policy" {
      url = "https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v3.5.0/docs/install/iam_policy.json"
    }
    
    resource "aws_iam_role_policy" "lbc" {
      name   = "load-balancer-controller"
      role   = aws_iam_role.lbc.id
      policy = data.http.lbc_policy.response_body
    }
    
    resource "aws_eks_pod_identity_association" "lbc" {
      cluster_name    = aws_eks_cluster.this.name
      namespace       = "kube-system"
      service_account = "aws-load-balancer-controller"
      role_arn        = aws_iam_role.lbc.arn
    
      depends_on = [aws_eks_addon.pod_identity]
    }
    
    output "cluster_name" {
      description = "The cluster's name"
      value       = aws_eks_cluster.this.name
    }
    
    output "endpoint" {
      description = "The Kubernetes API address"
      value       = aws_eks_cluster.this.endpoint
    }
    
    output "ca_data" {
      description = "The cluster's certificate authority, base64 encoded"
      value       = aws_eks_cluster.this.certificate_authority[0].data
    }
    
    output "ready" {
      description = "Changes only once the nodes and the controller's permissions exist. The in-cluster resources wait for it"
      value       = "${aws_eks_node_group.this.id}/${aws_eks_pod_identity_association.lbc.association_id}"
    }

    Project 3’s cluster, nodes and controller permissions. Kubernetes and EKS explains the pieces. The IAM ideas are in Who can do what: IAM.

    SettingWhat it doesIf you leave it outWhen you’d change it
    required_providers → httpthis module downloads a file——
    aws_iam_policy_document → actions, principalsa trust policy: who may use the role. Here, the EKS service——
    aws_iam_role.cluster and its policy attachmentthe role the console called “recommended”: EKS uses it to manage the clusterthe cluster can’t be creatednever
    aws_eks_cluster → name, role_arnthe cluster and its rolerequired—
    no versionEKS uses its current default version, as you did in the console—pin it, and upgrade on purpose, once you run real workloads
    vpc_config → subnet_idsthe cluster’s network interfaces go in the private app subnetsrequired—
    endpoint_public_access, endpoint_private_accesskubectl works from your laptop, and nodes talk to the API inside the VPCpublic only, with node traffic leaving the VPCturn public off once you have a VPN or bastion
    access_config → authentication_mode = "API"who may use the cluster is managed with EKS access entries, not a file inside the clusterAPI_AND_CONFIG_MAPnever, for a new cluster
    bootstrap_cluster_creator_admin_permissions = truewhoever runs apply becomes a cluster adminnobody can use the cluster at first—
    upgrade_policy → support_type = "STANDARD"when this version’s standard support ends, EKS upgrades it instead of charging extended support, about six times as muchextended support, and a surprise billnever, for practice clusters
    depends_on on the attachmentthe role’s permission exists before the cluster needs itcreation can fail on a racenever
    aws_iam_role.node and three attachments with for_eachthe node role: join the cluster, run pod networking, pull from ECRnodes never become Ready—
    aws_eks_node_group → cluster_name, node_group_name, node_role_arn, subnet_ids, instance_typesa managed group of EC2 nodes in the private subnetsrequired—
    scaling_config → desired_size, min_size, max_sizehow many nodes, and the range they may move withinrequired—
    update_config → max_unavailable = 1replace one node at a time during upgradesone at a time is the default; written so the choice is visiblemore for faster upgrades of big groups
    aws_eks_addon → addon_name = "eks-pod-identity-agent"installs the agent that hands roles to podsPod Identity associations do nothingnever
    aws_eks_access_entry and aws_eks_access_policy_association with for_eachextra roles that may run kubectl as admins. Project 5 adds the pipeline’s role hereonly the creator can use the clusteradd roles; access_scope type = "cluster" means every namespace
    data "http" → urldownloads the controller’s official permissions, pinned to release v3.5.0, so it can’t change under youyou’d paste a very long JSON file into the repositorywhen you upgrade the controller: change the version here and in stack/eks.tf together
    aws_iam_role.lbc, trust pods.eks.amazonaws.com, sts:AssumeRole and sts:TagSessiona role only EKS Pod Identity may hand out——
    aws_iam_role_policy → policy = data.http.lbc_policy.response_bodyputs the downloaded permissions on the rolethe controller can’t build load balancers—
    aws_eks_pod_identity_association → namespace, service_account, role_arnonly pods using that exact service account get the rolethe controller has no permissionsnever
    output "ready"a value that only exists once the nodes and the association do, so the in-cluster pieces wait for boththe controller could be installed before it has permissions, and fail—
You should see

Checkpoint: eight folders under modules/, each with one main.tf. Run terraform fmt -recursive at the top of the repository; it prints nothing if every file is already tidy.

The stack

The stack is one whole environment. It calls every module and connects their outputs to each other’s inputs. Every difference between environments arrives as a variable.

  1. The inputs: stack/variables.tf

    stack/variables.tf
    variable "environment" {
      description = "The environment's short name: dev, prod or dev-usw2"
      type        = string
    
      validation {
        condition     = can(regex("^[a-z0-9-]{2,9}$", var.environment))
        error_message = "Use 2 to 9 lower-case letters, digits or dashes, so every AWS name stays under its length limit."
      }
    }
    
    variable "vpc_cidr" {
      description = "The VPC's range. Different in every environment and region"
      type        = string
    }
    
    variable "single_nat_gateway" {
      description = "One NAT gateway instead of one per zone. Cheaper; loses outbound internet in zone 2 if zone 1 fails"
      type        = bool
    }
    
    variable "image_tag" {
      description = "The order service image to start with. After the first apply, the pipeline releases new versions"
      type        = string
    }
    
    variable "desired_count" {
      description = "How many order service tasks to start with"
      type        = number
    }
    
    variable "task_cpu" {
      description = "CPU units for each task. 256 is a quarter of a vCPU"
      type        = number
    }
    
    variable "task_memory" {
      description = "Memory for each task, in MiB"
      type        = number
    }
    
    variable "db_instance_class" {
      description = "The database server's size"
      type        = string
    }
    
    variable "db_multi_az" {
      description = "Keep a standby database in the second zone"
      type        = bool
    }
    
    variable "backup_retention_days" {
      description = "Days of automatic database backups"
      type        = number
    }
    
    variable "deletion_protection" {
      description = "Protect the load balancer, database and bucket from deletion"
      type        = bool
    }
    
    variable "log_retention_days" {
      description = "Days to keep container logs"
      type        = number
    }
    
    variable "alarm_email" {
      description = "Where alarm emails go. Empty means no emails"
      type        = string
    }
    
    variable "domain_name" {
      description = "A domain you own for a trusted certificate. Empty means self-signed"
      type        = string
    }
    
    variable "zone_id" {
      description = "The Route 53 hosted zone for domain_name. Empty when domain_name is empty"
      type        = string
    }
    
    variable "enable_eks" {
      description = "Also run the order service on EKS. Costs about $2.40 a day for the control plane alone"
      type        = bool
    }
    
    variable "eks_node_instance_type" {
      description = "EC2 size for each EKS node"
      type        = string
    }
    
    variable "eks_node_count" {
      description = "How many EKS nodes"
      type        = number
    }
    
    variable "eks_admin_role_arns" {
      description = "Extra IAM roles allowed to manage the cluster, such as the CI agent's role"
      type        = list(string)
    }

    Each variable’s description says what it’s for, which matters more than its name: it’s the documentation the next person reads. type catches a wrong kind of value before anything is built. None has a default, on purpose: every environment must choose every value, so nothing is decided silently. Variables, locals and outputs covers the syntax.

    VariableWhy it’s an inputThe validation
    environmentgoes into every name2 to 9 characters, so quickcart-<environment>-canary fits AWS’s 32-character limit for target group names
    vpc_cidreach environment and region needs its own range, so they can be connected laterchecked in the network module
    single_nat_gateway, db_multi_az, deletion_protection, backup_retention_days, log_retention_daysthe cost-against-safety choices that differ between dev and prod—
    image_tag, desired_count, task_cpu, task_memory, db_instance_classsizes and the first image—
    alarm_email, domain_name, zone_idoptional extras; empty means off—
    enable_eks, eks_node_instance_type, eks_node_count, eks_admin_role_arnswhether this environment also runs EKS, and how—
  2. The wiring: stack/main.tf

    stack/main.tf
    terraform {
      required_providers {
        aws = {
          source = "hashicorp/aws"
        }
        tls = {
          source = "hashicorp/tls"
        }
        helm = {
          source = "hashicorp/helm"
        }
        kubernetes = {
          source = "hashicorp/kubernetes"
        }
        http = {
          source = "hashicorp/http"
        }
      }
    }
    
    data "aws_caller_identity" "current" {}
    
    data "aws_region" "current" {}
    
    locals {
      name     = "quickcart-${var.environment}"
      app_port = 8080
      image    = "${data.aws_caller_identity.current.account_id}.dkr.ecr.${data.aws_region.current.region}.amazonaws.com/quickcart-orders:${var.image_tag}"
    }
    
    module "network" {
      source = "../modules/network"
    
      name               = local.name
      cidr               = var.vpc_cidr
      single_nat_gateway = var.single_nat_gateway
    }
    
    module "security" {
      source = "../modules/security"
    
      name     = local.name
      vpc_id   = module.network.vpc_id
      app_port = local.app_port
    }
    
    module "certificate" {
      source = "../modules/certificate"
    
      name        = local.name
      domain_name = var.domain_name
      zone_id     = var.zone_id
    }
    
    module "alb" {
      source = "../modules/alb"
    
      name                = local.name
      vpc_id              = module.network.vpc_id
      public_subnet_ids   = module.network.public_subnet_ids
      security_group_id   = module.security.alb_id
      certificate_arn     = module.certificate.arn
      app_port            = local.app_port
      deletion_protection = var.deletion_protection
    }
    
    module "data" {
      source = "../modules/data"
    
      name                  = local.name
      subnet_ids            = module.network.data_subnet_ids
      security_group_id     = module.security.db_id
      instance_class        = var.db_instance_class
      multi_az              = var.db_multi_az
      backup_retention_days = var.backup_retention_days
      deletion_protection   = var.deletion_protection
    }
    
    data "aws_iam_policy_document" "ecs_tasks_assume" {
      statement {
        actions = ["sts:AssumeRole"]
    
        principals {
          type        = "Service"
          identifiers = ["ecs-tasks.amazonaws.com"]
        }
      }
    }
    
    resource "aws_iam_role" "execution" {
      name               = "${local.name}-ecs-execution"
      assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
    }
    
    resource "aws_iam_role_policy_attachment" "execution" {
      role       = aws_iam_role.execution.name
      policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
    }
    
    data "aws_iam_policy_document" "read_db_secret" {
      statement {
        actions   = ["secretsmanager:GetSecretValue"]
        resources = [module.data.db_secret_arn]
      }
    }
    
    resource "aws_iam_role_policy" "read_db_secret" {
      name   = "read-db-secret"
      role   = aws_iam_role.execution.id
      policy = data.aws_iam_policy_document.read_db_secret.json
    }
    
    resource "aws_ecs_cluster" "this" {
      name = "${local.name}-cluster"
    
      setting {
        name  = "containerInsights"
        value = "disabled"
      }
    }
    
    module "orders" {
      source = "../modules/ecs-service"
    
      for_each = {
        stable = var.desired_count
        canary = 0
      }
    
      name               = each.key == "stable" ? "${local.name}-orders" : "${local.name}-orders-canary"
      cluster_arn        = aws_ecs_cluster.this.arn
      image              = local.image
      cpu                = var.task_cpu
      memory             = var.task_memory
      app_port           = local.app_port
      desired_count      = each.value
      subnet_ids         = module.network.app_subnet_ids
      security_group_id  = module.security.app_id
      target_group_arn   = module.alb.target_group_arns[each.key]
      execution_role_arn = aws_iam_role.execution.arn
      log_retention_days = var.log_retention_days
    
      environment_variables = {
        APP_ENV = var.environment
        DB_HOST = module.data.db_address
      }
    
      secrets = {
        DB_PASSWORD = "${module.data.db_secret_arn}:password::"
      }
    
      depends_on = [module.alb, aws_iam_role_policy_attachment.execution]
    }
    
    resource "aws_cloudwatch_log_group" "ops" {
      name              = "/ecs/${local.name}-ops"
      retention_in_days = var.log_retention_days
    }
    
    module "alarms" {
      source = "../modules/alarms"
    
      name                           = local.name
      alb_arn_suffix                 = module.alb.arn_suffix
      stable_target_group_arn_suffix = module.alb.target_group_arn_suffixes["stable"]
      db_identifier                  = module.data.db_identifier
      alarm_email                    = var.alarm_email
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    terraform → required_providersthe stack uses four providers. Versions are pinned once, in each environment’s main.tf, not hereTerraform guesses the sourcesnever
    local.namequickcart-dev, quickcart-prod, …: the prefix on every name——
    local.app_port = 8080one place for the app’s portthe port would be typed in four modulesif the app changes port
    local.imagethe image address, built from the account, the region and the tag. In us-west-2 it points at the replicated copya hard-coded address would pull from us-east-1 in every region—
    each module block → sourcewhich folder the module’s code is inrequired—
    the arguments in each module blockthe module’s variables, fed from this stack’s variables and other modules’ outputs. That’s how the pieces connectTerraform stops: a required variable is missing—
    aws_iam_role.execution and its attachmentthe ECS execution role, with AWS’s managed policy for pulling images and writing logstasks can’t start—
    aws_iam_role_policy.read_db_secretadds permission to read only this environment’s database secret: least privilege from Project 1tasks fail to start with a secrets error (see Troubleshooting)—
    aws_ecs_cluster → setting containerInsightsthe cluster, with Container Insights off to save moneythe account’s default is usedenabled in prod for per-task metrics, at extra cost
    module "orders" → for_each = { stable = …, canary = 0 }the same module twice: the stable service with its count, and an idle canary service with none——
    name = each.key == "stable" ? … : …quickcart-dev-orders and quickcart-dev-orders-canary——
    target_group_arn = module.alb.target_group_arns[each.key]each service registers with its own target group——
    environment_variables and secretsthe app learns its environment and database host; the password comes from the secret——
    depends_on = [module.alb, …]the listener must exist before ECS attaches a service to its target groupsECS fails with “target group does not have an associated load balancer”never
    aws_cloudwatch_log_group.opsa log group Project 5’s operations jobs write toProject 5’s jobs have nowhere to write—
  3. The EKS side: stack/eks.tf

    stack/eks.tf
    module "eks" {
      source = "../modules/eks"
      count  = var.enable_eks ? 1 : 0
    
      name               = local.name
      subnet_ids         = module.network.app_subnet_ids
      node_instance_type = var.eks_node_instance_type
      node_count         = var.eks_node_count
      admin_role_arns    = var.eks_admin_role_arns
    }
    
    resource "helm_release" "lbc" {
      count = var.enable_eks ? 1 : 0
    
      name       = "aws-load-balancer-controller"
      repository = "https://aws.github.io/eks-charts"
      chart      = "aws-load-balancer-controller"
      version    = "3.5.0"
      namespace  = "kube-system"
    
      set = [
        { name = "clusterName", value = module.eks[0].cluster_name },
        { name = "region", value = data.aws_region.current.region },
        { name = "vpcId", value = module.network.vpc_id },
        { name = "serviceAccount.name", value = "aws-load-balancer-controller" },
      ]
    
      depends_on = [module.eks]
    }
    
    resource "kubernetes_labels" "readiness_gate" {
      count = var.enable_eks ? 1 : 0
    
      api_version = "v1"
      kind        = "Namespace"
    
      metadata {
        name = "default"
      }
    
      labels = {
        "elbv2.k8s.aws/pod-readiness-gate-inject" = "enabled"
      }
    
      depends_on = [helm_release.lbc]
    }
    
    resource "kubernetes_deployment_v1" "orders" {
      count = var.enable_eks ? 1 : 0
    
      metadata {
        name      = "orders"
        namespace = "default"
      }
    
      spec {
        replicas = 2
    
        selector {
          match_labels = { app = "orders" }
        }
    
        strategy {
          type = "RollingUpdate"
    
          rolling_update {
            max_unavailable = "0"
            max_surge       = "1"
          }
        }
    
        template {
          metadata {
            labels = { app = "orders" }
          }
    
          spec {
            termination_grace_period_seconds = 40
    
            container {
              name  = "app"
              image = local.image
    
              port {
                container_port = local.app_port
              }
    
              env {
                name  = "APP_ENV"
                value = var.environment
              }
    
              readiness_probe {
                http_get {
                  path = "/health"
                  port = local.app_port
                }
                period_seconds = 5
              }
    
              resources {
                requests = { cpu = "100m", memory = "128Mi" }
                limits   = { memory = "256Mi" }
              }
    
              lifecycle {
                pre_stop {
                  exec {
                    command = ["sleep", "15"]
                  }
                }
              }
            }
          }
        }
      }
    
      depends_on = [kubernetes_labels.readiness_gate]
    }
    
    resource "kubernetes_service_v1" "orders" {
      count = var.enable_eks ? 1 : 0
    
      metadata {
        name      = "orders"
        namespace = "default"
      }
    
      spec {
        selector = { app = "orders" }
    
        port {
          port        = 80
          target_port = local.app_port
        }
      }
    }
    
    resource "kubernetes_ingress_v1" "orders" {
      count = var.enable_eks ? 1 : 0
    
      wait_for_load_balancer = true
    
      metadata {
        name      = "orders"
        namespace = "default"
    
        annotations = {
          "alb.ingress.kubernetes.io/scheme"                  = "internet-facing"
          "alb.ingress.kubernetes.io/target-type"             = "ip"
          "alb.ingress.kubernetes.io/healthcheck-path"        = "/health"
          "alb.ingress.kubernetes.io/listen-ports"            = jsonencode([{ HTTP = 80 }, { HTTPS = 443 }])
          "alb.ingress.kubernetes.io/ssl-redirect"            = "443"
          "alb.ingress.kubernetes.io/certificate-arn"         = module.certificate.arn
          "alb.ingress.kubernetes.io/target-group-attributes" = "deregistration_delay.timeout_seconds=30"
        }
      }
    
      spec {
        ingress_class_name = "alb"
    
        rule {
          http {
            path {
              path      = "/"
              path_type = "Prefix"
    
              backend {
                service {
                  name = kubernetes_service_v1.orders[0].metadata[0].name
                  port {
                    number = 80
                  }
                }
              }
            }
          }
        }
      }
    
      depends_on = [helm_release.lbc, kubernetes_deployment_v1.orders]
    }

    Everything EKS needs, including what you installed with Helm and kubectl in Project 3. Each resource has count = var.enable_eks ? 1 : 0, so one variable switches the whole side on or off.

    SettingWhat it doesIf you leave it outWhen you’d change it
    module "eks" → countbuilds the cluster only when enable_eks is true——
    helm_release → repository, chart, version, namespacethe same Helm install as Project 3, done by Terraform. The version matches the policy in modules/eks—together with the policy URL
    set = [{ name, value }, …]the --set values from Project 3’s commandthe controller can’t find its cluster, region or VPC—
    depends_on = [module.eks]install only once the nodes and permissions existthe install waits for pods that can’t schedule, then times outnever
    kubernetes_labels → api_version, kind, metadata, labelsadds the readiness-gate label to the existing default namespace, as kubectl label didnew pods get no readiness gate, and releases can drop requestsnever
    kubernetes_deployment_v1Project 3’s Deployment, field for field: 2 replicas, max_unavailable = 0, max_surge = 1, the readiness probe, requests and limits, and the preStop sleep. HCL spells the YAML names in snake_case——
    kubernetes_service_v1the fixed name in front of the pods——
    kubernetes_ingress_v1 → annotationsthe same annotations as Project 3; certificate-arn now comes from the certificate module——
    wait_for_load_balancer = trueapply waits until the controller has built the load balancer, so its address can be an outputthe output would be empty on the first applynever
    depends_on = [helm_release.lbc, …]create the Ingress after the controller, and delete it before the controller on destroy, so the controller is still running to delete its load balancerthe load balancer is left behind, still billing, and blocks the VPC’s deletionnever
  4. The outputs: stack/outputs.tf

    stack/outputs.tf
    output "ecs_url" {
      description = "The order service on ECS"
      value       = "https://${module.alb.dns_name}"
    }
    
    output "eks_url" {
      description = "The order service on EKS. Empty when EKS is off"
      value       = var.enable_eks ? "https://${kubernetes_ingress_v1.orders[0].status[0].load_balancer[0].ingress[0].hostname}" : ""
    }
    
    output "eks_cluster_name" {
      description = "The EKS cluster's name. Empty when EKS is off"
      value       = var.enable_eks ? module.eks[0].cluster_name : ""
    }
    
    output "eks_endpoint" {
      description = "The Kubernetes API address. Empty when EKS is off"
      value       = var.enable_eks ? module.eks[0].endpoint : ""
    }
    
    output "eks_ca_data" {
      description = "The cluster's certificate authority. Empty when EKS is off"
      value       = var.enable_eks ? module.eks[0].ca_data : ""
    }
    
    output "cluster_name" {
      description = "The ECS cluster"
      value       = aws_ecs_cluster.this.name
    }
    
    output "services" {
      description = "The stable and canary ECS services"
      value       = { for k, m in module.orders : k => m.service_name }
    }
    
    output "https_listener_arn" {
      description = "The listener a canary release re-weights"
      value       = module.alb.https_listener_arn
    }
    
    output "target_group_arns" {
      description = "The stable and canary target groups"
      value       = module.alb.target_group_arns
    }
    
    output "alb_arn_suffix" {
      description = "The load balancer, as CloudWatch names it"
      value       = module.alb.arn_suffix
    }
    
    output "target_group_arn_suffixes" {
      description = "The target groups, as CloudWatch names them"
      value       = module.alb.target_group_arn_suffixes
    }
    
    output "app_subnet_ids" {
      description = "Where one-off operations tasks run"
      value       = module.network.app_subnet_ids
    }
    
    output "app_security_group_id" {
      description = "The security group one-off operations tasks use to reach the database"
      value       = module.security.app_id
    }
    
    output "execution_role_arn" {
      description = "The role that lets a task pull images, read the database secret and write logs"
      value       = aws_iam_role.execution.arn
    }
    
    output "ops_log_group" {
      description = "Where one-off operations tasks write their output"
      value       = aws_cloudwatch_log_group.ops.name
    }
    
    output "db_address" {
      description = "The database's host name"
      value       = module.data.db_address
    }
    
    output "db_secret_arn" {
      description = "The secret holding the database password"
      value       = module.data.db_secret_arn
    }
    
    output "bucket_name" {
      description = "The private assets bucket"
      value       = module.data.bucket_name
    }

    These are what the outside world needs: your curl tests, and Project 5’s pipeline and operations jobs. Only outputs can be read from outside a module, so this list is the stack’s public interface.

You should see

Checkpoint: stack/ holds four files, and terraform fmt -recursive still prints nothing.

The environments

  1. The dev environment

    envs/dev/main.tf
    terraform {
      required_version = ">= 1.10.0"
    
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 6.0"
        }
        tls = {
          source  = "hashicorp/tls"
          version = "~> 4.0"
        }
        helm = {
          source  = "hashicorp/helm"
          version = "~> 3.0"
        }
        kubernetes = {
          source  = "hashicorp/kubernetes"
          version = "~> 2.38"
        }
        http = {
          source  = "hashicorp/http"
          version = "~> 3.5"
        }
      }
    }
    
    variable "region" { type = string }
    variable "environment" { type = string }
    variable "vpc_cidr" { type = string }
    variable "single_nat_gateway" { type = bool }
    variable "image_tag" { type = string }
    variable "desired_count" { type = number }
    variable "task_cpu" { type = number }
    variable "task_memory" { type = number }
    variable "db_instance_class" { type = string }
    variable "db_multi_az" { type = bool }
    variable "backup_retention_days" { type = number }
    variable "deletion_protection" { type = bool }
    variable "log_retention_days" { type = number }
    variable "alarm_email" { type = string }
    variable "domain_name" { type = string }
    variable "zone_id" { type = string }
    variable "enable_eks" { type = bool }
    variable "eks_node_instance_type" { type = string }
    variable "eks_node_count" { type = number }
    variable "eks_admin_role_arns" { type = list(string) }
    
    provider "aws" {
      region = var.region
    
      default_tags {
        tags = {
          Project     = "quickcart"
          Environment = var.environment
          ManagedBy   = "terraform"
        }
      }
    }
    
    provider "helm" {
      kubernetes = {
        host                   = module.stack.eks_endpoint
        cluster_ca_certificate = base64decode(module.stack.eks_ca_data)
        exec = {
          api_version = "client.authentication.k8s.io/v1beta1"
          command     = "aws"
          args        = ["eks", "get-token", "--cluster-name", module.stack.eks_cluster_name, "--region", var.region]
        }
      }
    }
    
    provider "kubernetes" {
      host                   = module.stack.eks_endpoint
      cluster_ca_certificate = base64decode(module.stack.eks_ca_data)
    
      exec {
        api_version = "client.authentication.k8s.io/v1beta1"
        command     = "aws"
        args        = ["eks", "get-token", "--cluster-name", module.stack.eks_cluster_name, "--region", var.region]
      }
    }
    
    module "stack" {
      source = "../../stack"
    
      environment            = var.environment
      vpc_cidr               = var.vpc_cidr
      single_nat_gateway     = var.single_nat_gateway
      image_tag              = var.image_tag
      desired_count          = var.desired_count
      task_cpu               = var.task_cpu
      task_memory            = var.task_memory
      db_instance_class      = var.db_instance_class
      db_multi_az            = var.db_multi_az
      backup_retention_days  = var.backup_retention_days
      deletion_protection    = var.deletion_protection
      log_retention_days     = var.log_retention_days
      alarm_email            = var.alarm_email
      domain_name            = var.domain_name
      zone_id                = var.zone_id
      enable_eks             = var.enable_eks
      eks_node_instance_type = var.eks_node_instance_type
      eks_node_count         = var.eks_node_count
      eks_admin_role_arns    = var.eks_admin_role_arns
    }
    
    output "stack" {
      description = "Everything the stack exposes: addresses, names and IDs"
      value       = module.stack
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    required_version = ">= 1.10.0"refuse older Terraform, which has no use_lockfilean old Terraform fails later with a confusing backend error—
    required_providers with versionpins each provider’s major version for every environmenteach environment could pick up a different, newer major versionwhen you upgrade on purpose
    the one-line variable blocksthe values terraform.tfvars sets. The descriptions live in the stack, so they aren’t repeated hereTerraform refuses values for variables it doesn’t knowwhen the stack gains a variable
    provider "aws" → region = var.regionwhere everything is built. This one line is how the second region works——
    default_tags with Environmentevery resource is tagged with its environment——
    provider "helm" → kubernetes = { host, cluster_ca_certificate, exec }how Helm reaches the cluster: its address, its certificate, and a token from aws eks get-token. Helm provider 3 writes this as an object with =Helm can’t reach the cluster—
    provider "kubernetes"the same three things, written as blocks in this provider’s older stylethe Kubernetes objects can’t be created—
    module "stack"the whole environment, with every value passed through——
    output "stack"everything the stack outputs, as one object——
    envs/dev/backend.tf
    terraform {
      backend "s3" {
        bucket       = "quickcart-tfstate-111122223333"
        key          = "dev/terraform.tfstate"
        region       = "us-east-1"
        encrypt      = true
        use_lockfile = true
      }
    }

    The same backend block as Terraform state, with this environment’s own key. Because the key is written in the folder’s own file, there’s no way to run dev’s code against prod’s state by typing the wrong option.

    envs/dev/terraform.tfvars
    region                 = "us-east-1"
    environment            = "dev"
    vpc_cidr               = "10.20.0.0/16"
    single_nat_gateway     = true
    image_tag              = "v1"
    desired_count          = 2
    task_cpu               = 256
    task_memory            = 512
    db_instance_class      = "db.t4g.micro"
    db_multi_az            = false
    backup_retention_days  = 1
    deletion_protection    = false
    log_retention_days     = 7
    alarm_email            = ""
    domain_name            = ""
    zone_id                = ""
    enable_eks             = true
    eks_node_instance_type = "t3.small"
    eks_node_count         = 2
    eks_admin_role_arns    = []

    Terraform reads terraform.tfvars in the folder you run it from automatically, so there’s no -var-file option to forget or get wrong.

  2. The prod environment

    Copy dev’s main.tf unchanged:

    Terminal
    cp envs/dev/main.tf envs/prod/main.tf
    PartWhat it means
    cpcopy a file
    envs/dev/main.tffrom dev
    envs/prod/main.tfto prod, byte for byte

    Then create its own backend and values:

    envs/prod/backend.tf
    terraform {
      backend "s3" {
        bucket       = "quickcart-tfstate-111122223333"
        key          = "prod/terraform.tfstate"
        region       = "us-east-1"
        encrypt      = true
        use_lockfile = true
      }
    }
    envs/prod/terraform.tfvars
    region                 = "us-east-1"
    environment            = "prod"
    vpc_cidr               = "10.30.0.0/16"
    single_nat_gateway     = false
    image_tag              = "v1"
    desired_count          = 3
    task_cpu               = 512
    task_memory            = 1024
    db_instance_class      = "db.t4g.small"
    db_multi_az            = true
    backup_retention_days  = 7
    deletion_protection    = true
    log_retention_days     = 30
    alarm_email            = ""
    domain_name            = ""
    zone_id                = ""
    enable_eks             = false
    eks_node_instance_type = "t3.small"
    eks_node_count         = 2
    eks_admin_role_arns    = []
  3. The second region

    Terminal
    cp envs/dev/main.tf envs/dev-usw2/main.tf
    PartWhat it means
    cpcopy dev’s main.tf, unchanged, to the second region’s folder
    envs/dev-usw2/backend.tf
    terraform {
      backend "s3" {
        bucket       = "quickcart-tfstate-111122223333"
        key          = "dev-usw2/terraform.tfstate"
        region       = "us-east-1"
        encrypt      = true
        use_lockfile = true
      }
    }

    The state stays in the us-east-1 bucket. Only the infrastructure moves.

    envs/dev-usw2/terraform.tfvars
    region                 = "us-west-2"
    environment            = "dev-usw2"
    vpc_cidr               = "10.40.0.0/16"
    single_nat_gateway     = true
    image_tag              = "v1"
    desired_count          = 2
    task_cpu               = 256
    task_memory            = 512
    db_instance_class      = "db.t4g.micro"
    db_multi_az            = false
    backup_retention_days  = 1
    deletion_protection    = false
    log_retention_days     = 7
    alarm_email            = ""
    domain_name            = ""
    zone_id                = ""
    enable_eks             = false
    eks_node_instance_type = "t3.small"
    eks_node_count         = 2
    eks_admin_role_arns    = []
  4. Check the environments differ only in values

    Terminal
    diff envs/dev/main.tf envs/prod/main.tf
    PartWhat it means
    diffcompare two files and print every line that differs
    the two pathsdev’s and prod’s main.tf

    It prints nothing: the files are identical. Now compare the values:

    Terminal
    diff envs/dev/terraform.tfvars envs/prod/terraform.tfvars
    PartWhat it means
    diffthe same comparison, on the values
    You should see

    A list of changed lines: environment, vpc_cidr, single_nat_gateway, desired_count, the task and database sizes, db_multi_az, backup_retention_days, deletion_protection, log_retention_days and enable_eks. That list is the difference between dev and prod, all of it, in one screen.

You should see

Checkpoint: commit your work. git add . then git commit -m "QuickCart infrastructure as code" records every file. git status afterwards says nothing to commit, working tree clean.

Build dev from empty

  1. Initialise dev

    Terminal
    cd envs/dev
    PartWhat it means
    cd envs/devwork in the dev environment. The folder you’re in decides which environment you change
    Terminal
    terraform init
    PartWhat it means
    terraform initconnect to dev’s state, find the modules, and download all five providers
    You should see

    Lines about initialising modules, the backend and the providers, ending with Terraform has been successfully initialized!. A new .terraform.lock.hcl appears: commit it.

  2. Read the plan before you apply it

    Terminal
    terraform plan -out=tfplan
    PartWhat it means
    terraform planwork out everything that would be created, without creating it
    -out=tfplansave exactly this plan, so the apply does this and nothing else
    You should see

    A long list of resources, each marked will be created, ending with Plan: 76 to add, 0 to change, 0 to destroy. If your count is different, check you copied every file. Nothing should say destroy: there’s nothing to destroy yet.

    Scroll through it. Look for module.stack.module.network.aws_nat_gateway.this["us-east-1a"]: only one NAT gateway, because dev sets single_nat_gateway = true. The plan is where you check the values did what you meant, before any money is spent.

  3. Build it, and time it

    Note the time, then:

    Terminal
    terraform apply tfplan
    PartWhat it means
    terraform apply tfplancarry out the saved plan. No question is asked: you already reviewed it
    You should see

    Resources being created for 20 to 30 minutes. The database, the EKS cluster and the NAT gateway take the longest. It ends with Apply complete! and the stack output. Write down how long it took.

  4. Test both runtimes

    Terminal
    terraform output stack
    PartWhat it means
    terraform output stackprint the stack’s outputs from the state
    You should see

    Among the values, ecs_url and eks_url, both starting https://.

    Test each one with curl, as in Project 3. Replace ECS_URL and EKS_URL with the two values:

    Terminal
    curl -k ECS_URL/health
    PartWhat it means
    curl -kfetch, accepting the self-signed certificate
    ECS_URL/healththe ECS load balancer’s health page
    You should see
    {"status":"ok","version":"v1"}
    Terminal
    curl -k EKS_URL/health
    PartWhat it means
    EKS_URL/healththe same page, through the EKS load balancer
    You should see
    {"status":"ok","version":"v1"}
  5. Plan again: it must be clean

    Terminal
    terraform plan -detailed-exitcode
    PartWhat it means
    terraform plancompare code, state and AWS again
    -detailed-exitcodealso report the result as a number: 0 means no changes, 2 means changes, 1 means an error. A pipeline can check it
    You should see
    No changes. Your infrastructure matches the configuration.

    Check the number:

    Terminal
    echo $?
    PartWhat it means
    echo $?print the exit code of the command that just ran
    You should see
    0

    Anything else means something shows a change on every plan. Troubleshooting lists the usual causes.

You should see

Checkpoint: both URLs answer ok, and a second plan says No changes with exit code 0. You wrote down how long the apply took.

The second region

  1. Build dev in us-west-2

    Terminal
    cd ../dev-usw2
    PartWhat it means
    cd ../dev-usw2up one folder, then into the second region’s environment
    Terminal
    terraform init
    PartWhat it means
    terraform initconnect to dev-usw2/terraform.tfstate and download the providers
    Terminal
    terraform apply
    PartWhat it means
    terraform applyplan, show, and build once you type yes
    You should see

    A plan with no EKS resources (it’s off here) and one NAT gateway in a us-west-2 zone. Type yes. About 15 minutes later: Apply complete!.

    Terminal
    terraform output stack
    PartWhat it means
    terraform output stackthe second region’s addresses

    Test its ecs_url with curl -k and /health, as before. It answers {"status":"ok","version":"v1"}, served from Oregon by the image replication copied there.

  2. Write down what didn't translate

    A region is “just a variable” only if the code never assumed one. Fill this in for your notes; it’s the evidence that you checked.

    ThingMoves with region?How this code handles it
    Availability zone namesno: us-east-1a doesn’t exist in us-west-2looked up with data "aws_availability_zones"
    ACM certificatesno: certificates are regionalthe certificate module runs again in each region
    Container imagesno: ECR is regionalreplication, set up in shared/
    S3 bucket namesno: they’re unique worldwidethe region is part of the bucket’s name
    IAM role namesno: IAM is global, so two regions’ roles would clashquickcart-dev-usw2-… differs from quickcart-dev-…
    The state bucketstays in us-east-1 on purposeone place for every environment’s state
    Instance types and database classesusually, but not every type exists in every regionchecked: t3.small and db.t4g.micro exist in both
You should see

Checkpoint: the second region’s ecs_url answers ok, and terraform plan -detailed-exitcode there exits with 0.

Plan production

  1. Plan prod without building it

    Terminal
    cd ../prod
    PartWhat it means
    cd ../prodinto the prod environment’s folder
    Terminal
    terraform init
    PartWhat it means
    terraform initconnect to prod/terraform.tfstate, which doesn’t exist yet
    Terminal
    terraform plan
    PartWhat it means
    terraform planshow what prod would be, without building it
    You should see

    A plan with two NAT gateways and two Elastic IPs, a database with multi_az = true and deletion_protection = true, a load balancer with enable_deletion_protection = true, three tasks’ worth of desired_count, and no EKS resources. Every difference traces back to a line in prod/terraform.tfvars.

    Building prod is optional. If you do, Clean up shows the extra step its deletion protection needs.

You should see

Checkpoint: a prod plan that differs from dev’s only in the ways its values say.

Prove it

The claim is that this environment is disposable: it can be destroyed and rebuilt from nothing, with no clicks, and come back clean. Prove it with a timed run in dev.

  1. Destroy dev completely

    Terminal
    cd ../dev
    PartWhat it means
    cd ../devback to dev
    Terminal
    terraform destroy
    PartWhat it means
    terraform destroyplan the removal of everything in dev’s state, and do it once you type yes
    You should see

    Plan: 0 to add, 0 to change, N to destroy. Type yes. The Kubernetes objects go first, then the controller, the cluster, and the network last. It ends with Destroy complete!. Write down how long it took.

  2. Rebuild from empty

    Terminal
    terraform apply
    PartWhat it means
    terraform applybuild everything again from the same code and values

    Type yes, and time it. Then run terraform plan -detailed-exitcode and echo $? again: No changes and 0.

  3. Check nothing secret is in the code or the plan

    Terminal
    git grep -n -i -E "password|secret_key|BEGIN RSA|BEGIN PRIVATE" -- ':!*.md'
    PartWhat it means
    git grep -n -isearch every file git tracks, printing line numbers, ignoring case
    -E "password|secret_key|BEGIN RSA|BEGIN PRIVATE"any of these words, as one pattern
    -- ':!*.md'skip Markdown notes
    You should see

    Only lines that mention the secret’s ARN or name, like DB_PASSWORD = "${module.data.db_secret_arn}:password::", and the manage_master_user_password setting. No actual password or key.

Fill in the teardown log. It’s your evidence:

RunWhatTookResult
1apply dev, us-east-1, from emptyyour time, usually 20 to 30 minutesboth URLs answer ok
2second planabout a minuteNo changes, exit code 0
3apply dev-usw2your timeits URL answers ok
4destroy devyour time, usually 15 to 25 minutesDestroy complete!
5apply dev again, from emptyyour timeboth URLs answer ok
6second planabout a minuteNo changes, exit code 0

The pass mark: run 5 finishes with no step outside Terraform, and run 6 is clean.

Troubleshooting

Clean up

Destroy in the opposite order to building: environments first, the shared layer last.

  1. Destroy the second region

    Terminal
    cd envs/dev-usw2
    PartWhat it means
    cd envs/dev-usw2from the top of the repository, into the second region
    Terminal
    terraform destroy
    PartWhat it means
    terraform destroyremove everything in this environment, once you type yes
    You should see
    Destroy complete! Resources: 58 destroyed.
  2. Destroy dev

    Terminal
    cd ../dev
    PartWhat it means
    cd ../devinto dev
    Terminal
    terraform destroy
    PartWhat it means
    terraform destroyremove dev, EKS included
    You should see

    Destroy complete! after 15 to 25 minutes.

  3. Destroy prod, if you built it

    Skip this if you only planned prod. Otherwise, in envs/prod/terraform.tfvars, change deletion_protection = true to false, then:

    Terminal
    cd ../prod
    PartWhat it means
    cd ../prodinto prod
    Terminal
    terraform apply
    PartWhat it means
    terraform applyturn deletion protection off on the database and load balancer. Read the plan: it should only change those settings and skip_final_snapshot
    Terminal
    terraform destroy
    PartWhat it means
    terraform destroynow prod can be removed

    Then change the value back to true and commit nothing: prod’s protection belongs in the code.

  4. Destroy the shared layer

    Terminal
    cd ../../shared
    PartWhat it means
    cd ../../sharedup two folders, then into shared/
    Terminal
    terraform destroy
    PartWhat it means
    terraform destroydelete the repositories in both regions, and the replication rule. force_delete empties them first
    You should see
    Destroy complete! Resources: 7 destroyed.

    Keep the state bucket if you’re going on to Project 5. It costs pennies. If you’re not, empty and delete it as Terraform state shows.

  5. Clean-up step: Clean up

    Check nothing is left billing, in both regions. Switch the console between US East (N. Virginia) and US West (Oregon) for each:

    WhereShould show
    EKS > Clustersnothing
    ECS > Clustersnothing
    RDS > Databasesnothing
    EC2 > Load Balancersnothing
    VPC > NAT gatewaysnothing Available (deleted ones stay listed for a while)
    EC2 > Elastic IPsnothing
    Secrets Managernothing starting rds! (RDS deletes its secret with the database; one scheduled for deletion is fine)
    You should see

    All empty in both regions. Resource Groups & Tag Editor > Tag Editor, with All regions and the tag Project = quickcart, finds nothing except the state bucket, which was created by hand and has no such tag.

Built it, and cleaned up? Mark it done.