Laptop to Production

Modules

Reusable pieces, and where your standards live.

Time
50 minutes
Level
Intermediate

Part 1: The problem

QuickCart, a second service

QuickCart’s orders service had its own ECR repository, written in Terraform by Maya. When the payments service arrived, Leo copied the block, changed orders to payments, and moved on.

A month later, a security review asked for two things on every repository: scan images for known vulnerabilities when they’re pushed, and never let a tag like v1 be pointed at a different image. Maya added both to the orders repository.

Nobody remembered the copy. The payments repository — the one handling card payments — was the one left unscanned, with tags anyone could overwrite.

Copied code doesn’t stay the same. Each copy gets its own fixes, at its own time, or none at all. By the third service, nobody can say which copy is the right one.

This lesson is about modules: how to write a piece of infrastructure once, use it many times, and control when a change to it reaches everyone.

Part 2: The idea

A module is a folder of .tf files. That’s all. You’ve been writing one since your first resource: the folder you run terraform in is a module too.

KindWhat it isExample
Root moduleThe folder you run terraform plan and apply in. It has the provider settings and the statequickcart-modules/
Child moduleA folder the root module calls with a module block. It has no state of its own; its resources are stored in the root’s statequickcart-modules/modules/ecr-repo/

A child module has three kinds of files, by convention:

FileHoldsIn a function, this is
variables.tfThe inputs: what the caller must or may pass inThe parameters
main.tfThe resources it buildsThe body
outputs.tfThe outputs: values the caller can read backThe return value

The file names are only a habit. Terraform reads every .tf file in the folder as one.

Key point: A module is where your standards live: write them once, and every use of the module gets them.

In the story, if the repository had been a module, Maya would have added scanning and immutable tags to the module once, and the next plan would have shown the change for both repositories.

A module for QuickCart’s network

Here’s a small but real network module: a VPC, public subnets in two or three availability zones, an internet gateway, and the route that makes the subnets public. What is a VPC? explains each piece. Here we care about how it’s packaged.

The inputs first:

modules/network/variables.tf
variable "name" {
  description = "Name prefix for every resource, like quickcart-dev"
  type        = string
}

variable "cidr_block" {
  description = "The VPC's address range, like 10.10.0.0/16"
  type        = string

  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "cidr_block must be an IPv4 range, like 10.10.0.0/16."
  }
}

variable "zone_count" {
  description = "How many availability zones to spread the public subnets across"
  type        = number
  default     = 2

  validation {
    condition     = var.zone_count >= 2 && var.zone_count <= 3
    error_message = "zone_count must be 2 or 3."
  }
}

variable "tags" {
  description = "Extra tags to put on every resource in the module"
  type        = map(string)
  default     = {}
}
SettingWhat it doesIf you leave it outWhen you’d change it
variable "name"An input called name. Inside the module it’s read as var.name——
descriptionExplains the input to whoever calls the module. Shown in errors and editor hintsCallers have to read the module’s code to guess what it wantsWhenever the meaning changes
type = stringOnly text is acceptedAny type is accepted, and mistakes surface later with a vaguer errorRarely
No default on name and cidr_blockThe caller must pass them—Never give these a default: every network should choose its own name and range on purpose
validation → conditionA check that must be true. cidrhost(var.cidr_block, 0) fails on anything that isn’t a range, and can() turns that failure into falseA typo like 10.10.0/16 is only caught by AWS, halfway through an applyWhen you want stricter rules, such as only /16 ranges
validation → error_messageWhat the caller sees when the check failsRequired inside a validation blockTo make the message clearer
zone_count → default = 2Two zones unless the caller asks for threeThe caller would have to pass it every timeIf three zones becomes your standard
zone_count → validationRefuses 1, because one zone can’t survive losing a zone, and anything over 3Someone could build a single-zone network by accidentIf you ever need four zones
tags → type = map(string)A set of name and value pairs, like { Project = "quickcart" }——
tags → default = {}No extra tags unless the caller passes someThe caller would have to pass tags every time, even an empty {}Rarely

Then the resources:

modules/network/main.tf
data "aws_availability_zones" "available" {
  state = "available"
}

locals {
  zones = slice(data.aws_availability_zones.available.names, 0, var.zone_count)
}

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true

  tags = merge(var.tags, { Name = var.name })
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = merge(var.tags, { Name = "${var.name}-igw" })
}

resource "aws_subnet" "public" {
  for_each = { for index, zone in local.zones : zone => index }

  vpc_id                  = aws_vpc.this.id
  availability_zone       = each.key
  cidr_block              = cidrsubnet(var.cidr_block, 8, each.value)
  map_public_ip_on_launch = true

  tags = merge(var.tags, { Name = "${var.name}-public-${each.key}" })
}

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 = merge(var.tags, { Name = "${var.name}-public" })
}

resource "aws_route_table_association" "public" {
  for_each = aws_subnet.public

  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}
SettingWhat it doesIf you leave it outWhen you’d change it
data "aws_availability_zones" → state = "available"Asks AWS which zones this region has, skipping any that are unavailableThe list may include a zone you can’t build inNever
local.zonesThe first zone_count zone names, like us-east-1a and us-east-1b. slice(list, 0, 2) takes items 0 and 1You’d type zone names in, and the module would break in any other regionNever; this is what makes the module work anywhere
aws_vpc → cidr_blockThe VPC’s address range, from the inputRequiredNever after creation: changing it replaces the VPC and everything in it
aws_vpc → enable_dns_hostnamesServers with a public IP also get a public DNS name. Several AWS features expect it onDefaults to falseRarely
tags = merge(var.tags, { Name = … })The caller’s tags, plus a Name tag, which is what the console shows in its Name columnThe console shows a blank nameTo add tags every resource should have
aws_internet_gateway → vpc_idAttaches the gateway to this VPCThe gateway is created but attached to nothingNever
aws_subnet → for_eachOne subnet per zone. The expression builds a map like { "us-east-1a" = 0, "us-east-1b" = 1 }: the key is the zone, the value its positionOne subnet onlyNever; Loops and lifecycle explains why keys beat positions
aws_subnet → vpc_idWhich VPC the subnet belongs toRequiredNever; changing it replaces the subnet
aws_subnet → availability_zoneThe zone this subnet lives in: each.keyAWS picks a zone, maybe the same one twiceNever
aws_subnet → cidr_blockcidrsubnet(var.cidr_block, 8, each.value) cuts a /24 out of the VPC’s /16: 10.10.0.0/24 for position 0, 10.10.1.0/24 for position 1RequiredIf you need bigger or smaller subnets: the 8 is how many bits to add
aws_subnet → map_public_ip_on_launchServers started here get a public IP automaticallyDefaults to false: servers get no public IP, even in a public subnetfalse for private subnets
aws_route_table → vpc_idThe VPC this route table belongs toRequiredNever; changing it replaces the route table
route → cidr_block = "0.0.0.0/0"This route matches traffic to anywhere outside the VPC——
route → gateway_id…and sends it to the internet gateway. This one route is what makes a subnet publicThe subnets have no way to the internetNever, for public subnets
aws_route_table_association → for_each = aws_subnet.publicOne link for each subnet, keyed by the same zone namesSubnets stay on the VPC’s main route table, with no internet routeNever
subnet_id / route_table_idWhich subnet uses which route tableBoth requiredNever

And the outputs, the only values the caller can read:

modules/network/outputs.tf
output "vpc_id" {
  description = "The VPC's ID, for security groups and load balancers"
  value       = aws_vpc.this.id
}

output "public_subnet_ids" {
  description = "One public subnet ID per zone"
  value       = [for subnet in aws_subnet.public : subnet.id]
}

output "zones" {
  description = "The availability zones the subnets are in"
  value       = local.zones
}
SettingWhat it doesIf you leave it outWhen you’d change it
output "vpc_id"Makes the VPC’s ID readable from outside as module.network.vpc_idNothing outside the module can find the VPC—
descriptionExplains the output to the callerCallers guess what it holdsWhen the meaning changes
valueWhat the output returnsRequired—
[for subnet in aws_subnet.public : subnet.id]Turns the map of subnets into a plain list of their IDs, which is what a load balancer expects——
output "zones"The zone names the module chose, so the caller can see themThe caller can’t tell which zones were used—

Notice what’s not in the module: no provider block, no region, no environment name. The root module decides those. That’s what lets the same module build dev, stg and a second region in Environments and regions.

Calling the module, and composing with it

The root module calls the module with a module block, then passes one of its outputs to something else. That’s composition: small pieces, wired together in the root.

network.tf
module "network" {
  source = "./modules/network"

  name       = "quickcart-dev"
  cidr_block = "10.10.0.0/16"
  zone_count = 2

  tags = {
    Project = "quickcart"
  }
}

resource "aws_security_group" "web" {
  name        = "quickcart-dev-web"
  description = "HTTPS from anywhere to the load balancer"
  vpc_id      = module.network.vpc_id

  ingress {
    description = "HTTPS from anywhere"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "Everything out"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Project = "quickcart"
  }
}
SettingWhat it doesIf you leave it outWhen you’d change it
module "network"Calls a module. network is the root’s name for this use of it, so its outputs are module.network.…——
sourceWhere the module’s code is. ./ means a folder in this repositoryRequiredTo use a shared or published module (see below)
name, cidr_block, zone_count, tagsValues for the module’s variablesname and cidr_block are required; the others fall back to their defaultsPer use: every network gets its own name and range
aws_security_group → nameThe security group’s name in AWSAWS generates a random nameChanging it later replaces the group
descriptionShown in the consoleDefaults to Managed by TerraformChanging it later replaces the group, so get it right first
vpc_id = module.network.vpc_idPuts the group in the module’s VPC, read from the module’s outputThe group goes into the account’s default VPC, not yoursNever
ingress → from_port, to_port, protocol, cidr_blocksLet TCP port 443 in from anywhereNo ingress block: nothing can connect inTo open other ports, or narrow who can connect
ingress / egress → descriptionA note on the rule, shown in the consoleBlankAlways write one
egress with protocol = "-1"Let every kind of traffic out. -1 means all protocols; ports 0 to 0 then means all portsTerraform removes AWS’s usual allow-everything-out rule, so nothing can leaveTo restrict outbound traffic
tagsLabels for finding and costing itNo labels—

You don’t need to build this now. The lab in Environments and regions builds this exact network, three times.

Where a module can come from

The source setting says where Terraform fetches the module’s code from. Three kinds cover almost everything:

SourceLooks likeUse it for
A local foldersource = "./modules/network"Modules only this repository uses. They change when the repository changes, so they need no version of their own
The public Terraform Registrysource = "terraform-aws-modules/vpc/aws" with version = "~> 6.0"Well-known building blocks written by others. Read one before you trust it: you inherit its choices
A git repositorysource = "git::https://git.example.com/quickcart/quickcart-modules.git//network?ref=v1.4.0"Your team’s modules, shared by several repositories

In the git source, //network means “the network folder inside that repository”, and ?ref=v1.4.0 means “at the tag v1.4.0”.

Versioning: control when a change reaches you

A shared module will change. Pinning decides when each user gets the change.

SourceHow you pin itUnpinned, you get
Registryversion = "~> 6.0": any 6.x, never 7.0. Major versions may break thingsThe newest version on the day you run init
Git?ref= a tag, like v1.4.0. A branch name like main moves, so it isn’t a pinWhatever the default branch holds on the day you run init
Local folderNothing to pin. It’s versioned with the rest of the repository—

To take a newer version on purpose, change the pin in a pull request, then run terraform init -upgrade and read the plan. The change to shared code then shows up as a change in your repository, where it can be reviewed.

When not to make a module

Modules make code easier to reuse, and harder to read. A module that only wraps one resource, with an input for each of its settings, adds a layer and saves nothing.

Make a module whenDon’t, when
The same group of resources is needed two or more timesIt’s used once, and probably always will be
There’s a standard to enforce, like scanning on every repositoryIt wraps a single resource and passes every setting straight through
The pieces belong together, like a VPC and its subnetsYou’re guessing at future needs
It has a handful of clear inputsIt needs 30 inputs, or several named enable_something

The last row is the most common trap: one giant module that builds everything, with dozens of inputs. It becomes impossible to change safely, because every team depends on it. Several small modules, each doing one thing and composed in the root, like network.tf above, age much better.

Part 3: See it

Here is the root module calling the network module, with the security group outside it. Change one input at a time, and see which resources inside the module change, and whether the change escapes through an output. Then try reading a resource from outside.

Change one input

Inputs

variables the root passes in
name = "quickcart-dev"
cidr_block = "10.10.0.0/16"
zone_count = 2
tags = { Project = "quickcart" }

Inside modules/network

resources the module builds
VPC
Internet gateway
Subnet us-east-1a
Subnet us-east-1b
Route table
Route table link 1a
Route table link 1b

Outputs

the only way values leave
vpc_id → security group web
public_subnet_ids → a load balancer, later
zones → printed by the root
$ terraform plan

No changes. Your infrastructure matches the configuration.
The module's interfaceFour inputs go in. Seven resources are built inside. Three outputs come out. The root module can only talk to the module through those inputs and outputs.

What to notice: a change only travels where the code sends it. name and tags stay inside the module. cidr_block replaces the VPC, which changes the vpc_id output, and that carries the change out to the security group. Outputs are both the module’s only way out and the path a change can escape along.

Part 4: Do it

Free — two empty ECR repositories. ECR charges only for stored images, and you push none.

You’ll write a small module for an ECR repository with QuickCart’s standards built in, use it for two services, change the standard once, and watch both repositories follow. About 25 minutes.

You need Terraform 1.10 or later and a working AWS login in your terminal. Set up your computer covers both.

  1. Make the folders

    Terminal
    mkdir -p quickcart-modules/modules/ecr-repo
    PartWhat it means
    mkdirmake a directory
    -pmake any missing parent folders too, and don’t complain if they already exist
    quickcart-modules/modules/ecr-repothe project folder, and inside it the module’s folder
    Terminal
    cd quickcart-modules
    PartWhat it means
    cd quickcart-modulesmove into the project folder. This is the root module; every command runs here
  2. Write the module's inputs

    Create modules/ecr-repo/variables.tf:

    modules/ecr-repo/variables.tf
    variable "name" {
      description = "The service's name, like orders. Lower case letters, numbers and hyphens"
      type        = string
    
      validation {
        condition     = can(regex("^[a-z][a-z0-9-]*$", var.name))
        error_message = "name must start with a lower-case letter and use only a-z, 0-9 and hyphens."
      }
    }
    
    variable "environment" {
      description = "Which environment the repository belongs to"
      type        = string
    
      validation {
        condition     = contains(["dev", "stg", "prod"], var.environment)
        error_message = "environment must be dev, stg or prod."
      }
    }
    
    variable "keep_images" {
      description = "How many images to keep. Older ones are deleted automatically"
      type        = number
      default     = 20
    
      validation {
        condition     = var.keep_images >= 1
        error_message = "keep_images must be at least 1."
      }
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    name → type, no defaultThe caller must say which service this is——
    name → validationregex checks the name uses only lower-case letters, numbers and hyphens, which is what ECR accepts. can() turns a failed match into falseA bad name is only rejected by AWS, during applyIf your naming rules change
    environment → validationcontains accepts only dev, stg or prodA typo like prd would quietly create a new, oddly named repositoryWhen you add an environment
    keep_images → default = 20Keep the newest 20 images unless told otherwiseEvery caller would have to decideIf 20 is the wrong standard for you
    keep_images → validationAt least 1, so the policy can’t delete every imageA 0 would be rejected later by AWS with a less helpful errorNever
  3. Write the module's resources and outputs

    Create modules/ecr-repo/main.tf. This is where QuickCart’s standards live:

    modules/ecr-repo/main.tf
    resource "aws_ecr_repository" "this" {
      name                 = "quickcart-${var.environment}-${var.name}"
      image_tag_mutability = "IMMUTABLE"
      force_delete         = true
    
      image_scanning_configuration {
        scan_on_push = true
      }
    
      tags = {
        Project     = "quickcart"
        Environment = var.environment
        Service     = var.name
      }
    }
    
    resource "aws_ecr_lifecycle_policy" "this" {
      repository = aws_ecr_repository.this.name
    
      policy = jsonencode({
        rules = [{
          rulePriority = 1
          description  = "Keep only the newest images"
          selection = {
            tagStatus   = "any"
            countType   = "imageCountMoreThan"
            countNumber = var.keep_images
          }
          action = { type = "expire" }
        }]
      })
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    resource "aws_ecr_repository" "this"The repository. Inside a module that builds one of something, this is the usual name——
    nameBuilt from the inputs: quickcart-dev-orders. The caller can’t break the naming patternRequiredNever after creation: a new name replaces the repository
    image_tag_mutability = "IMMUTABLE"A tag like v1 can never be moved to a different imageDefaults to MUTABLERarely. Storing images: ECR explains why
    force_delete = trueterraform destroy deletes the repository even if it holds imagesDestroy fails while any image is insideSet it to false, or make it an input, for production
    image_scanning_configuration → scan_on_pushEvery pushed image is checked for known vulnerabilitiesDefaults to false: no scanningNever, for QuickCart
    tagsLabels, including which service owns itNo labelsAdd the tags your team requires
    aws_ecr_lifecycle_policy → repositoryWhich repository the policy applies toRequiredNever
    policy = jsonencode(…)The rules, written as Terraform values and turned into the JSON that ECR expectsRequired—
    rulePriority, descriptionRules run in priority order, lowest first. The description is shown in the consoleRequired / blankWhen you add more rules
    selection → tagStatus = "any"The rule looks at all images, tagged or notRequiredTo treat untagged images differently
    countType = "imageCountMoreThan", countNumberMatch images beyond the newest keep_imagesRequired—
    action → type = "expire"Delete the matched imagesRequired—

    Create modules/ecr-repo/outputs.tf:

    modules/ecr-repo/outputs.tf
    output "repository_url" {
      description = "The address to push images to"
      value       = aws_ecr_repository.this.repository_url
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    output "repository_url"Lets the caller read the repository’s address as module.<name>.repository_urlThe address can’t be read from outside the module—
    descriptionExplains what the value isCallers guess—
    valueThe address AWS gives the repository, like 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-dev-ordersRequired—
  4. Use the module twice

    Back in the project folder, create main.tf. This is the root module:

    main.tf
    terraform {
      required_version = ">= 1.10.0"
    
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 6.0"
        }
      }
    }
    
    provider "aws" {
      region = "us-east-1"
    }
    
    module "orders_repo" {
      source = "./modules/ecr-repo"
    
      name        = "orders"
      environment = "dev"
    }
    
    module "payments_repo" {
      source = "./modules/ecr-repo"
    
      name        = "payments"
      environment = "dev"
      keep_images = 10
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    required_version, required_providersTerraform 1.10 or later, and any 6.x AWS provider. Your first resource explains bothTerraform takes whatever it finds, which may change under youWhen you upgrade on purpose
    provider "aws" → regionWhere both repositories are built. The module doesn’t choose; the root doesTerraform uses your login’s default regionTo build elsewhere
    module "orders_repo" / "payments_repo"Two uses of the same module. Each has its own name, and its own resources in the state—One block per service
    source = "./modules/ecr-repo"The module’s folderRequired—
    name, environmentThe two required inputsTerraform stops: Missing required argument—
    keep_images = 10Payments keeps fewer images; orders takes the default 20The default is usedPer service

    Create outputs.tf:

    outputs.tf
    output "orders_repository_url" {
      description = "Where to push the orders service's images"
      value       = module.orders_repo.repository_url
    }
    
    output "payments_repository_url" {
      description = "Where to push the payments service's images"
      value       = module.payments_repo.repository_url
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    value = module.orders_repo.repository_urlReads the module’s output and shows it after applyNothing is printed; the value still exists in the state—
    descriptionExplains the output——
  5. Initialise, plan and apply

    Terminal
    terraform init
    PartWhat it means
    terraform initfind the modules the code calls, download the AWS provider, and prepare the folder
    You should see
    Initializing the backend...
    
    Initializing modules...
    - orders_repo in modules/ecr-repo
    - payments_repo in modules/ecr-repo
    
    Initializing provider plugins...
    - Finding hashicorp/aws versions matching "~> 6.0"...
    - Installing hashicorp/aws v6.66.0...
    - Installed hashicorp/aws v6.66.0 (signed by HashiCorp)
    
    Terraform has been successfully initialized!

    Your provider version may be newer, and the two module lines may come in either order. There’s a new line here you haven’t seen before: Initializing modules.

    Terminal
    terraform apply
    PartWhat it means
    terraform applyplan the changes, show them, and make them once you type yes

    Read the plan before you type yes. Every address now starts with the module’s name:

    Part of the plan
      # module.orders_repo.aws_ecr_lifecycle_policy.this will be created
      # module.orders_repo.aws_ecr_repository.this will be created
      # module.payments_repo.aws_ecr_lifecycle_policy.this will be created
      # module.payments_repo.aws_ecr_repository.this will be created
    
    Plan: 4 to add, 0 to change, 0 to destroy.

    Type yes.

    You should see
    module.orders_repo.aws_ecr_repository.this: Creating...
    module.payments_repo.aws_ecr_repository.this: Creating...
    module.orders_repo.aws_ecr_repository.this: Creation complete after 1s [id=quickcart-dev-orders]
    module.payments_repo.aws_ecr_repository.this: Creation complete after 1s [id=quickcart-dev-payments]
    module.orders_repo.aws_ecr_lifecycle_policy.this: Creating...
    module.payments_repo.aws_ecr_lifecycle_policy.this: Creating...
    module.orders_repo.aws_ecr_lifecycle_policy.this: Creation complete after 0s [id=quickcart-dev-orders]
    module.payments_repo.aws_ecr_lifecycle_policy.this: Creation complete after 0s [id=quickcart-dev-payments]
    
    Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
    
    Outputs:
    
    orders_repository_url = "111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-dev-orders"
    payments_repository_url = "111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-dev-payments"

    Your account ID appears in place of 111122223333, and the lines may come in a different order. Two small blocks built four resources, each with scanning, immutable tags and a clean-up policy.

    In the AWS console: ECRPrivate registryRepositories

    You should see

    quickcart-dev-orders and quickcart-dev-payments, both with Tag immutability set to Immutable. Open one, and Lifecycle Policy in the left menu shows the rule.

  6. Change the standard once

    A new rule arrives: every repository must carry a ManagedBy tag, so people know not to edit it by hand. You change the module, not each repository. Update the tags in modules/ecr-repo/main.tf; the whole file now reads:

    modules/ecr-repo/main.tf
    resource "aws_ecr_repository" "this" {
      name                 = "quickcart-${var.environment}-${var.name}"
      image_tag_mutability = "IMMUTABLE"
      force_delete         = true
    
      image_scanning_configuration {
        scan_on_push = true
      }
    
      tags = {
        Project     = "quickcart"
        Environment = var.environment
        Service     = var.name
        ManagedBy   = "terraform"
      }
    }
    
    resource "aws_ecr_lifecycle_policy" "this" {
      repository = aws_ecr_repository.this.name
    
      policy = jsonencode({
        rules = [{
          rulePriority = 1
          description  = "Keep only the newest images"
          selection = {
            tagStatus   = "any"
            countType   = "imageCountMoreThan"
            countNumber = var.keep_images
          }
          action = { type = "expire" }
        }]
      })
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    ManagedBy = "terraform"The only new line: a tag every repository built by this module will carry——
    Terminal
    terraform plan
    PartWhat it means
    terraform planshow what would change, without changing anything
    Part of the plan
      # module.orders_repo.aws_ecr_repository.this will be updated in-place
      ~ resource "aws_ecr_repository" "this" {
            id                   = "quickcart-dev-orders"
            name                 = "quickcart-dev-orders"
          ~ tags                 = {
                "Environment" = "dev"
              + "ManagedBy"   = "terraform"
                "Project"     = "quickcart"
                "Service"     = "orders"
            }
    
      # module.payments_repo.aws_ecr_repository.this will be updated in-place
    
    Plan: 0 to add, 2 to change, 0 to destroy.

    One edit, two repositories. This is the story’s missing fix: had the payments repository come from the module, the security review’s change would have reached it too. The full plan also shows tags_all changing, which is the tags plus any the provider adds.

    Apply it, and type yes:

    Terminal
    terraform apply
    PartWhat it means
    terraform applymake the tag change on both repositories
    You should see
    Apply complete! Resources: 0 added, 2 changed, 0 destroyed.
  7. Watch the module refuse a bad input

    In main.tf, change the payments module’s name from "payments" to "Payments", with a capital P, then plan:

    Terminal
    terraform plan
    PartWhat it means
    terraform plancheck the inputs against the module’s validation rules, then plan
    Terraform stops with
    Error: Invalid value for variable
    
      on main.tf line 26, in module "payments_repo":
      26:   name        = "Payments"
        ├────────────────
        │ var.name is "Payments"
    
    name must start with a lower-case letter and use only a-z, 0-9 and hyphens.
    
    This was checked by the validation rule at
    modules/ecr-repo/variables.tf:5,3-13.

    Nothing was built or changed. The module’s author wrote that rule once, and every caller gets the check. Change the name back to "payments" before you go on.

  8. Clean-up step: Clean up

    Terminal
    terraform destroy
    PartWhat it means
    terraform destroyplan the removal of everything this root module built, including everything inside its modules, and do it once you type yes
    You should see
    Plan: 0 to add, 0 to change, 4 to destroy.
    
    Changes to Outputs:
      - orders_repository_url   = "111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-dev-orders" -> null
      - payments_repository_url = "111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-dev-payments" -> null

    Type yes.

    You should see
    Destroy complete! Resources: 4 destroyed.

    In the AWS console: ECRPrivate registryRepositories

    You should see

    Neither repository is listed.

    Keep the quickcart-modules folder if you like; it costs nothing on your laptop.

Part 5: Check yourself

In the root module, Leo needs the ID of the VPC that module.network built. What does he write?

Maya adds a new module block and runs terraform plan. Terraform says "Module not installed". What does she do?

QuickCart keeps shared modules in their own git repository. Which source is safe to use in production?

A team's "app stack" module has 45 inputs, a third of them named enable_something. Every change risks breaking another team. What's the better design?

You change only the cidr_block input of module.network. Why does the plan also replace the security group in the root?

Part 6: Cheat sheet

On one card
ThingWhat to remember
Root moduleThe folder you run terraform in. Has the provider and the state
Child moduleA folder called with module "name" { source = … }. No provider, no state of its own
variables.tf / main.tf / outputs.tfInputs / resources / outputs. A habit, not a rule
Inputs with no defaultMust be set by every caller. Use for things that must be decided, like environment
module.NAME.OUTPUTThe only way to read a value from a module
source = "./modules/x"A local folder: versioned with the repository
source = "org/name/aws" + version = "~> 6.0"The public registry, pinned to a major version
source = "git::https://…//x?ref=v1.4.0"Git, pinned to a tag. Never a branch
terraform initAgain after adding a module or changing its source or version
terraform init -upgradeTake newer versions allowed by your constraints
Compose, don’t bloatSmall modules wired together in the root beat one module with 40 inputs

Finished the build and the quiz? Mark it done to fill in its stop on the route map.