← Back to all topics
$ terraform plan -out tfplan

Infrastructure as Code
Terraform Instructor Guide

From "click in the AWS console" to "git diff your infrastructure" — declarative, reviewable, reproducible.

01
Why IaC? Click-Ops vs Declarative Infrastructure
"Who clicked that?" is the wrong question. The answer should be in git.

How to explain to students

Open with the war story: "Production goes down. The team finds a security group rule was 'temporarily' loosened 3 weeks ago for a vendor demo. Nobody documented it. Nobody can answer 'who, when, why'." That's click-ops — managing infrastructure through console clicks. It scales to one engineer; it does not scale to a team.

Infrastructure as Code (IaC) describes infrastructure in text files (HCL, YAML, JSON), commits them to git, and applies them with a tool. Now every change has an author, a date, a reason, a review, and a rollback. "git blame the security group" becomes a real workflow.

click-ops vs IaC
# Click-ops Friday — what really happened
02:14 Sara loosens SG rule for "the new vendor"
02:16 Sara forgets to document anything
14d later 3am page: "RDS exposed to 0.0.0.0/0"
Slack: "who did this?" → silence
CloudTrail → 2-week event retention exceeded

# Same event with IaC
PR #487 — "loosen prod-db-sg for vendor X demo"
author: sara@example.com
reviewer: muzammil@example.com
diff: -from = "10.0.0.0/8"
+from = "0.0.0.0/0"
applied: 2026-04-29 02:14 UTC
ticket: ENG-1273

14d later → 30 seconds to find what changed, who, why
→ 30 seconds to revert with git revert + terraform apply
📜
Auditable
git blame on a Terraform file shows who changed every line and why.
↩️
Reversible
git revert && terraform apply = roll back any infra change.
🪞
Reproducible
Spin up dev / staging / prod from the same code with different vars.
🤝
Reviewable
Infra changes go through PR review — the same process as application code.

🎯 Practice Questions

Q1.
Define click-ops. List three concrete failure modes click-ops introduces that IaC eliminates.
Show Answer
Click-ops = managing cloud infrastructure manually through the web console.

Three failure modes:
1. No audit trail beyond CloudTrail's retention. Who changed what 6 months ago is irrecoverable.
2. Drift between environments. "It works in staging but not prod" because someone clicked something in one and not the other. IaC makes both structurally identical.
3. No review. A single misclick rolls out to production with no second pair of eyes. IaC routes infra changes through PR review, like code.

IaC is also reversible (git revert), portable (same code spins up a new region in 10 minutes), and self-documenting (the code is the architecture diagram).
Q2.
A teammate says "we'll add Terraform once we're bigger — it's overkill for 1 engineer." Counter-argument in 3 sentences.
Q3.
Compare CloudFormation, Terraform, and AWS CDK in one line each. When does each shine?
💡 Multi-cloud, programming-language preference, AWS-native lock-in.
02
Terraform Architecture — Providers, Resources & State
Three concepts. Once they click, every Terraform error message stops being mysterious.

How to explain to students

A provider is a plugin that knows how to talk to a specific cloud (aws, google, cloudflare, github). A resource is one thing in that cloud (an S3 bucket, an EC2, a DNS record). The state is Terraform's mental map of "what I created last time" — without it, Terraform can't know whether to create, update, or skip.

The lifecycle: init (download providers) → plan (compare desired vs current state, show diff) → apply (do it). Always read the plan before applying. The plan is your safety net.

main.tf — first resource
terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

resource "aws_s3_bucket" "portfolio" {
  bucket = "muzammil-portfolio-2026"

  tags = {
    Name = "portfolio"
    Environment = "prod"
  }
}

# The 3-step lifecycle
$ terraform init
Initializing provider plugins... aws v5.34.0 ✓

$ terraform plan
Terraform will perform the following actions:
+ resource "aws_s3_bucket" "portfolio" {
+ bucket = "muzammil-portfolio-2026"
}
Plan: 1 to add, 0 to change, 0 to destroy.

$ terraform apply
aws_s3_bucket.portfolio: Creating...
aws_s3_bucket.portfolio: Creation complete after 2s ✓

# What just happened: state file written
$ cat terraform.tfstate | jq '.resources[].instances[].attributes.bucket'
"muzammil-portfolio-2026"

# Tear it all down
$ terraform destroy
init plan apply destroy provider resource state

🎯 Practice Questions

Q1.
Explain in one line each: provider, resource, state. Which one would you most fear losing?
Show Answer
Provider — a plugin that knows how to call a cloud's API (e.g. aws, google, cloudflare). Downloaded by terraform init.
Resource — one cloud thing you want to exist (S3 bucket, EC2, DNS record).
State — Terraform's record of what it has created. Maps each resource block to a real cloud ID.

Losing state is the scariest. Without state, Terraform doesn't know which resources it owns. terraform plan will try to create everything fresh alongside your existing resources, leaving you with duplicates. Recovery is manual: terraform import for every resource. Always store state remotely with versioning + locking.
Q2.
Why is "always read the plan before apply" the cardinal Terraform rule? What's a horror scenario it prevents?
Q3.
Pin the AWS provider to "any 5.x version, but no 6.x". Show the constraint syntax.
💡 The ~> operator.
Q4.
A teammate runs terraform apply in production while you're running terraform plan locally. What can go wrong without remote state + locking?
03
Variables, Outputs, Locals & Data Sources
From "hard-coded mess" to "one config, many environments"

How to explain to students

Variables = inputs (what changes between dev/prod). Outputs = exports (the bucket URL, the EC2 IP — what other code needs to consume). Locals = computed values (DRY constants used inside the module). Data sources = read-only lookups against the cloud (e.g. "give me the latest Ubuntu AMI").

Always pass environment-specific values (region, instance size, domain) as variables. .tfvars files hold the actual values per environment. Never hard-code anything that changes between dev and prod.

variables + outputs + data sources
# variables.tf
variable "environment" {
  type = string
  description = "dev | staging | prod"
  validation {
    condition = contains(["dev","staging","prod"], var.environment)
    error_message = "environment must be dev, staging, or prod"
  }
}

variable "instance_type" {
  type = string
  default = "t3.micro"
}

# locals.tf — computed once, used many times
locals {
  name_prefix = "myapp-${var.environment}"
  common_tags = {
    Project = "myapp"
    Environment = var.environment
    ManagedBy = "terraform"
  }
}

# data.tf — look up live values from AWS
data "aws_ami" "ubuntu_latest" {
  most_recent = true
  owners = ["099720109477"] # Canonical
  filter {
    name = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

# outputs.tf — expose values for humans + CI
output "bucket_name" { value = aws_s3_bucket.portfolio.id }
output "bucket_url" { value = aws_s3_bucket.portfolio.bucket_regional_domain_name }

# prod.tfvars — environment-specific values
environment = "prod"
instance_type = "t3.small"

$ terraform apply -var-file=prod.tfvars
$ terraform output bucket_url
"muzammil-portfolio-2026.s3.eu-west-1.amazonaws.com"

🎯 Practice Questions

Q1.
Pick variable, output, local, or data source for: (a) "current AWS account ID", (b) "the EC2 size for prod", (c) "the standard tag set", (d) "the EC2 public IP for downstream Ansible".
Q2.
Why use a data "aws_ami" lookup instead of hard-coding an AMI ID like ami-0abcd1234?
Show Answer
AMI IDs are region-specific and change over time. Hard-coding ami-0abcd1234 means:
1. Your code only works in one region.
2. After a few months, the AMI is deprecated and replaced — you don't get OS security patches without manual updates.
3. Spinning up a fresh dev environment in a new region requires editing the code.

Using data "aws_ami" with a name pattern (ubuntu-jammy-22.04-amd64-server-*) makes your config region-portable and automatically picks up the latest patched AMI on every terraform apply.
Q3.
Add a validation block to a variable so that only "eu-west-1" or "me-central-1" are accepted regions. Show the HCL.
Q4.
A CI pipeline needs to read the bucket URL Terraform created and pass it to the next deploy step. Which Terraform feature delivers this, and what's the command?
💡 terraform output -raw bucket_url
04
Terraform CLI & State Management
The 8 commands that 99% of Terraform work boils down to

How to explain to students

Beyond init/plan/apply/destroy, the commands you'll reach for most: fmt + validate in CI, state list/show/rm for surgical state edits, import to bring existing resources under Terraform management, and taint/untaint (now -replace) to force a re-create.

terraform — top 8 commands
$ terraform fmt -recursive  # normalise indentation across all .tf files
$ terraform validate  # syntax + reference check

$ terraform plan -out=tfplan  # save the plan to apply later
$ terraform apply tfplan  # apply the EXACT plan you reviewed

# State surgery
$ terraform state list
aws_s3_bucket.portfolio
aws_cloudfront_distribution.portfolio
aws_route53_record.portfolio

$ terraform state show aws_s3_bucket.portfolio
# aws_s3_bucket.portfolio:
resource "aws_s3_bucket" "portfolio" { ... }

# Force-replace a single resource (next apply will destroy + create)
$ terraform apply -replace=aws_instance.web

# Adopt an existing manually-created bucket into Terraform
$ terraform import aws_s3_bucket.legacy old-bucket-name
aws_s3_bucket.legacy: Import prepared!
Prepared aws_s3_bucket for import
aws_s3_bucket.legacy: Refreshing state... ✓

# Remove a resource from state without deleting in cloud
$ terraform state rm aws_s3_bucket.no_longer_managed
📐
fmt + validate in CI
Block PRs that don't pass — keeps the diff clean.
💾
plan → file → apply
Apply the saved plan to guarantee no drift between review and execution.
📦
import for legacy
Bring click-ops resources under Terraform without deleting them.
🔁
-replace = recreate
Force a single resource to be destroyed + re-created on next apply.

🎯 Practice Questions

Q1.
A senior engineer manually created an S3 bucket in the AWS console 6 months ago. You want it under Terraform without deleting it. Outline the workflow.
Show Answer
Three-step process:
1. Write the Terraform code that matches the existing resource (a resource "aws_s3_bucket" "legacy" block with the same bucket name, tags, etc.).
2. Import the existing resource: terraform import aws_s3_bucket.legacy <real-bucket-name>. This populates state without touching the live bucket.
3. Run terraform plan. If your HCL exactly matches the live config, plan should show "No changes". If it shows differences, your HCL is incomplete — iterate until plan is clean.

Tip: tools like terraformer or aws2tf can auto-generate the HCL for many resource types — useful for adopting large legacy estates.
Q2.
Why use terraform plan -out=tfplan + terraform apply tfplan instead of just terraform apply in CI?
Q3.
A CI lint check runs terraform fmt -check -recursive. Why -check not just fmt?
💡 fmt rewrites files; -check exits non-zero if files would be rewritten — perfect for CI.
Q4.
When would terraform state rm be the right tool, vs terraform destroy?
05
Modules — Reusing Infrastructure Patterns
Stop copy-pasting Terraform. A module is a function for infrastructure.

How to explain to students

A module is a folder of .tf files with declared inputs (variables) and outputs. You "call" a module from another Terraform configuration to instantiate the resources it describes — like calling a function with arguments. Same module, called twice with different vars, gives you dev and prod.

The Terraform Registry (registry.terraform.io) hosts thousands of community modules. terraform-aws-modules/vpc/aws creates a production-grade VPC in 3 lines. Compose them; don't reinvent.

modules — local + registry
# modules/static-site/main.tf — your reusable module
variable "domain" { type = string }
variable "bucket_name" { type = string }

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
}

resource "aws_cloudfront_distribution" "this" {
  enabled = true
  aliases = [var.domain]
  # ... origin + cert + behaviours
}

output "distribution_id" { value = aws_cloudfront_distribution.this.id }

# root/main.tf — call the module twice
module "portfolio" {
  source = "./modules/static-site"
  domain = "muzammilbilwani.com"
  bucket_name = "muzammil-portfolio-2026"
}

module "blog" {
  source = "./modules/static-site"
  domain = "blog.muzammilbilwani.com"
  bucket_name = "muzammil-blog-2026"
}

# Use a community module from the registry
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
  version = "5.5.0"

  name = "myapp-prod"
  cidr = "10.0.0.0/16"

  azs = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true # save $$ in non-prod
}
module source registry version pinning depends_on

🎯 Practice Questions

Q1.
When should you write a custom module vs use a community one from the Terraform Registry?
Q2.
Why pin a community module to a specific version (version = "5.5.0") and not just track main?
Show Answer
Community modules are arbitrary code from third parties. main can change at any moment — a maintainer might:
1. Push a breaking change (rename a variable, restructure resources) that your terraform apply would happily execute, destroying live infra.
2. Be compromised, with a malicious commit injected into main.
3. Refactor in a way that triggers replacements of stable resources.

Pinning to 5.5.0 means your infrastructure is reproducible across team members and CI runs, and supply-chain attacks are blocked unless you explicitly bump the version. Treat module versions like npm dependencies — pin and review every upgrade.
Q3.
Sketch the file layout for a module that creates an S3 bucket + CloudFront + Route 53 alias. What would variables.tf, outputs.tf, and main.tf contain?
Q4.
A module's outputs include sensitive data (DB password). How does Terraform mark them as such, and what changes in CLI output?
06
Remote State — S3 Backend with DynamoDB Locking
The single biggest "this is a real team" upgrade. Stop committing tfstate to git.

How to explain to students

Default Terraform stores state in terraform.tfstate in your local folder. This is fine for solo learning. It is fatal for teams — two engineers running apply at the same time corrupts state. Worse, state files contain secrets (DB passwords, API keys). They must never go to git.

The standard fix: store state in S3 with DynamoDB locking. S3 holds the file (versioned + encrypted); DynamoDB holds a lock so only one apply can run at a time. Five extra lines of HCL.

backend.tf — S3 + DynamoDB
terraform {
  backend "s3" {
    bucket = "myorg-terraform-state"
    key = "myapp/prod/terraform.tfstate"
    region = "eu-west-1"
    dynamodb_table = "terraform-locks"
    encrypt = true
  }
}

# One-time bootstrap (chicken-and-egg solved by manual or separate config)
$ aws s3 mb s3://myorg-terraform-state
$ aws s3api put-bucket-versioning --bucket myorg-terraform-state \
  --versioning-configuration Status=Enabled
$ aws s3api put-bucket-encryption --bucket myorg-terraform-state \
  --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'

$ aws dynamodb create-table --table-name terraform-locks \
  --billing-mode PAY_PER_REQUEST \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH

# Migrate local state → remote
$ terraform init -migrate-state
Successfully configured the backend "s3"!
Terraform has detected that the configuration specified for the backend has changed.
Migrate existing state to the new backend? [yes/no]

# Now two engineers run plan + apply safely
$ terraform apply
Acquiring state lock. This may take a few moments...
Error: Error acquiring the state lock
Lock Info:
ID: a1b2c3d4-...
Operation: OperationTypeApply
Who: sara@example.com
Created: 2026-04-29 14:23 UTC
🚫
Never git the state
Contains secrets in plain text. Add *.tfstate* to .gitignore.
📦
S3 + versioning
Every apply writes a new version. Recover from accidents in seconds.
🔒
DynamoDB lock
Prevents two simultaneous applies from corrupting state.
🔑
Encrypt at rest
SSE-S3 minimum. Use SSE-KMS if compliance requires.

🎯 Practice Questions

Q1.
Two engineers run terraform apply on the same config at the same time. Without a backend lock, what corrupted-state scenarios result?
Q2.
Why is bootstrap of the S3-backend bucket itself usually not done with Terraform? How do most teams handle the chicken-and-egg?
Show Answer
Chicken-and-egg: Terraform needs the S3 bucket to store its state — but if the bucket is itself defined in Terraform, where does that state live?

Common patterns:
1. Manual bootstrap via aws s3 mb + aws dynamodb create-table once, run by hand. Document the steps in the repo's README. The bucket is then "outside" Terraform forever — fine, it's stable.
2. A small separate Terraform config (bootstrap/) that uses local state and creates the S3 bucket + DynamoDB. Run once; commit the local tfstate (it contains no secrets at this point — just bucket names).
3. Terragrunt auto-creates the backend bucket if missing.

Whatever you pick, document it loudly so the next engineer doesn't run terraform destroy on the bootstrap.
Q3.
A teammate ctrl-C'd a hanging apply and the state lock is now stuck. How do you safely release it?
💡 terraform force-unlock <LOCK_ID> — but verify nothing is actually running first.
Q4.
Why does the S3 backend support multiple state files via the key attribute (e.g. myapp/prod/terraform.tfstate vs myapp/dev/terraform.tfstate)? What pattern does this enable?
07
Using AI to Write & Debug Terraform
HCL is verbose, the docs are thorough, and AI ate both — perfect for AI-assist

How to explain to students

Terraform errors are notoriously specific ("InvalidParameterCombination: ... requires the InstanceType to be a member of the t3 family"). AI is great at translating these into the actual missing argument. It's also great at scaffolding modules: "Write me a module for an S3 bucket + CloudFront + Route 53 alias for a given domain" gets you 90% of the way.

The trap: AI sometimes uses deprecated resource arguments (e.g. aws_s3_bucket_acl on aws_s3_bucket directly — that moved to a separate resource in v4 of the AWS provider). Always run terraform validate + terraform plan, and check the registry docs for any unfamiliar argument.

AI prompts for Terraform
# ✅ Strong prompt — module scaffold
"Write a Terraform module for AWS provider v5.x that:
- Creates a private S3 bucket (no public access, versioning + SSE-AES256)
- Creates a CloudFront distribution with OAC pointing at the bucket
- Reads an existing ACM cert ARN as a variable
- Creates the Route 53 alias A record
- Outputs: bucket_name, distribution_id, domain
Use the v4+ separate-resource pattern for bucket policy/ACL.
Tag every resource with a 'managed_by = terraform' tag."

# Debug-by-AI: paste the EXACT error
Error: creating S3 Bucket (foo) ACL: AccessControlListNotSupported:
The bucket does not allow ACLs
→ Prompt: "Terraform AWS provider v5 — what replaced the bucket-level ACL
argument? Show me the modern pattern."

# Verify before merging
$ terraform fmt -check -recursive
$ terraform validate
$ terraform plan
$ tflint && tfsec .  # static analysis + security

🎯 Practice Questions

Q1.
Take "terraform module for ec2" and turn it into a 6-bullet detailed prompt that produces production-ready HCL.
Q2.
An AI generates HCL using aws_s3_bucket with an inline acl = "private" argument. Why might that fail in AWS provider v5+, and what's the modern pattern?
Show Answer
The AWS provider v4 split aws_s3_bucket into the bucket itself + many separate resources for individual settings: aws_s3_bucket_acl, aws_s3_bucket_versioning, aws_s3_bucket_public_access_block, etc.

AI trained on older docs sometimes generates the inline form. terraform validate may pass but apply fails or — worse — silently ignores the setting.

Modern pattern:
resource "aws_s3_bucket" "this" { bucket = "..." }
resource "aws_s3_bucket_versioning" "this" { bucket = aws_s3_bucket.this.id, versioning_configuration { status = "Enabled" } }

Always cross-check AI output against registry.terraform.io/providers/hashicorp/aws for the current resource shape.
Q3.
Why is pasting a real terraform.tfstate file to AI a security mistake? What's safe to paste instead?
08
Project: Provision the AWS Static-Site Stack in Pure Terraform
Recreate the Module-6 portfolio from the AWS module — but every resource is HCL

How to explain to students

In the AWS module, students built a portfolio site by clicking through S3, CloudFront, ACM, Route 53. Now they rebuild it as Terraform — same architecture, but every resource declared in HCL. The artefact: a terraform apply that produces a working https://<name>.com portfolio in 3 minutes, and a terraform destroy that removes it cleanly.

portfolio/main.tf — full stack
terraform {
  required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } }
  backend "s3" { bucket = "myorg-tf-state", key = "portfolio/prod.tfstate", region = "eu-west-1", dynamodb_table = "tf-locks" }
}

provider "aws" { region = "eu-west-1" }
provider "aws" {
  alias = "us_east_1"
  region = "us-east-1" # CloudFront cert MUST live here
}

variable "domain" { default = "muzammilbilwani.com" }

# 1. Bucket — private, encrypted, versioned
resource "aws_s3_bucket" "this" { bucket = "muzammil-portfolio-2026" }
resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id
  versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_public_access_block" "this" {
  bucket = aws_s3_bucket.this.id
  block_public_acls = true
  block_public_policy = true
  ignore_public_acls = true
  restrict_public_buckets = true
}

# 2. ACM cert in us-east-1 + DNS validation
resource "aws_acm_certificate" "this" {
  provider = aws.us_east_1
  domain_name = var.domain
  subject_alternative_names = ["www.${var.domain}"]
  validation_method = "DNS"
  lifecycle { create_before_destroy = true }
}

# 3. CloudFront distribution + OAC (truncated for brevity)
resource "aws_cloudfront_origin_access_control" "this" { ... }
resource "aws_cloudfront_distribution" "this" { ... }

# 4. Route 53 — apex + www alias
data "aws_route53_zone" "this" { name = var.domain }
resource "aws_route53_record" "apex" {
  zone_id = data.aws_route53_zone.this.zone_id
  name = var.domain
  type = "A"
  alias {
    name = aws_cloudfront_distribution.this.domain_name
    zone_id = aws_cloudfront_distribution.this.hosted_zone_id
    evaluate_target_health = false
  }
}

output "site_url" { value = "https://${var.domain}" }
output "distribution_id" { value = aws_cloudfront_distribution.this.id }

$ terraform init && terraform apply
🌍
Multi-region provider
provider "aws" + an alias = "us_east_1" for the cert. One config, two regions.
📦
Remote state
From day 1. Don't even start without S3 + DynamoDB.
📤
Useful outputs
site_url + distribution_id consumed by the deploy script in CI.
🔁
create_before_destroy
On the cert — avoids brief outage when CloudFront is detaching.
09
Quiz + Assignment — Recreate Your AWS Stack in Terraform Modules
5 MCQs + 2 fill-ins + a graded module-refactor exercise

Sample quiz questions (interactive)

Q1. Which command ONLY shows what would change, without making changes?
A
terraform apply --dry-run
B
terraform plan
C
terraform validate
D
terraform refresh
Q2. State stored in S3 needs DynamoDB to:
A
Compress the state file
B
Provide locking so two applies don't run at once
C
Encrypt at rest
D
Track Terraform versions
Q3. Which is NOT a Terraform feature?
A
Variables with validation
B
Modules
C
Data sources
D
Built-in monitoring of provisioned resources
Q4. To force a single resource to be recreated on next apply:
A
terraform apply -replace=<address>
B
terraform destroy
C
terraform refresh
D
terraform reset
Q5. Why should community modules be pinned to a specific version?
A
It's faster to download
B
Reproducibility + supply-chain safety
C
Pinning is required for state locking
D
Pinning is mandatory in HCL

Fill-in-the-command

Fill 1: Bring an existing S3 bucket old-data into Terraform state as aws_s3_bucket.legacy.
Fill 2: Apply with the prod tfvars file.

Assignment

📋 Assignment Requirements

  • Take the AWS-module portfolio site (S3 + CloudFront + Route 53) and recreate it entirely in Terraform
  • State must live in S3 + DynamoDB locking — local state is an automatic fail
  • Wrap the architecture in a reusable module at ./modules/static-site
  • Module variables: domain, bucket_name, tags. Module outputs: distribution_id, bucket_id
  • Use data "aws_route53_zone" to look up the existing hosted zone
  • Pin AWS provider version (~> 5.0) and Terraform itself (required_version)
  • Include terraform fmt -check + terraform validate in a CI job
  • Commit a README.md with: how to bootstrap state, how to apply, the architecture diagram
  • Bonus: Use tfsec or checkov to scan and fix at least one finding
  • Bonus: Pass environment as a variable and call the module twice (dev + prod) with different domains
  • Bonus: Use a community VPC module from the registry to spin up a VPC alongside
expected acceptance
$ terraform init
Initializing the backend... Successfully configured "s3" ✓
Initializing modules... ./modules/static-site ✓

$ terraform plan
Plan: 14 to add, 0 to change, 0 to destroy.

$ terraform apply -auto-approve
Apply complete! Resources: 14 added.

$ terraform output site_url
"https://muzammilbilwani.com"

$ curl -sI https://muzammilbilwani.com | head -1
HTTP/2 200  # 100% of infra came from terraform apply ✓
📊
Grading rubric
Apply succeeds: 25. Module reusable: 20. Remote state + locking: 20. CI check: 10. README + diagram: 15. Bonuses: +20.
🎯
Common mistakes
ACM cert in wrong region, no create_before_destroy on cert, hard-coded bucket name (collides on second apply), local state.
💡
Stretch
Wire it into GitHub Actions OIDC + run terraform plan on every PR.