Skip to main content
Terraform logo

Infrastructure as Code: Read and Annotate a Terraform Config

Introduction

Infrastructure as Code (IaC) means describing your infrastructure — servers, storage buckets, networks, permissions — as text files, rather than clicking through a cloud console or running one-off CLI commands. Terraform is the most widely used tool for this: you write .tf files describing the infrastructure you want, and Terraform works out what to create, change, or destroy to make reality match that description.

This lab is deliberately read-only — no Terraform installation or AWS account needed. You'll read a short, realistic Terraform configuration, use GenAI to explain the parts you don't recognise, verify what it tells you against a real source, and then annotate the file yourself in your own words. The goal isn't to memorise Terraform syntax; it's to practice a skill you'll use constantly in real IaC work: reading someone else's infrastructure config and understanding exactly what it will do before it runs.

By the end of this lab you will have:

  • Read a short Terraform configuration and understood what each block does.
  • Used GenAI to explain unfamiliar syntax, then verified the explanation yourself.
  • Annotated the file with your own comments, in your own words.
  • Understood how this connects to declarative vs. imperative approaches, and to CI/CD.

Setup: GitHub Copilot Chat, and the sample config below (also downloadable as sample-terraform-config.tf). No Terraform installation or AWS account needed.


Concepts

Terraform files are built from a small number of block types, all present in the sample config below:

  • terraform { } — meta-configuration for Terraform itself. required_providers pins which provider plugins this config needs and which version range, so the same config behaves the same way for everyone who runs it.
  • provider "aws" { } — configures a specific cloud provider (here, AWS) — in this case, just which region to operate in.
  • variable "name" { } — declares an input parameter. description documents intent, type constrains what's valid, and an optional default makes the variable optional. A variable with no default (like environment here) must be supplied by whoever runs this config.
  • resource "<type>" "<local_name>" { } — the core building block: declares one real piece of infrastructure. <type> (e.g. aws_s3_bucket) identifies what kind of resource; <local_name> (e.g. static_assets) is just a name for referring to it within this config, not the real cloud resource's name.
  • Cross-references like aws_s3_bucket.static_assets.id let one resource block refer to an attribute of another — Terraform uses these references to work out the order to create things in, without you specifying it manually.
  • output "name" { } — exposes a value (often generated only once real infrastructure exists, like a bucket's actual name or an assigned IP) so it can be read after apply, or consumed by another Terraform config.

Declarative vs. imperative

Terraform is declarative: you state the end state you want ("this bucket should exist, with versioning enabled and public access blocked"), and Terraform figures out the steps. An imperative equivalent — a script of aws CLI commands — would instead have to specify the exact sequence of actions to get there, and handle every case itself: does the bucket already exist? Is versioning already enabled, or does it need to be turned on? What if the script fails halfway through — is it safe to re-run? Terraform's declarative model handles all of this itself by comparing the current state against the desired state and computing only the necessary changes.

Connecting to CI/CD: plan and apply

A Terraform config like this typically runs through two distinct pipeline stages:

  • terraform plan — computes and displays what would change, without changing anything. This is safe to run automatically on every commit.
  • terraform apply — actually makes the change.

Teams commonly gate apply behind a manual approval, even when plan runs freely, because apply can have consequences that are expensive or impossible to undo — deleting a resource, replacing a database, or (as in this file) changing public access settings on a bucket holding real data. A routine application deploy is usually easy to roll back by redeploying the previous version; an infrastructure change might not be.


Examples

sample-terraform-config.tf:

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}

provider "aws" {
region = var.aws_region
}

variable "aws_region" {
description = "AWS region to deploy PaySprint's static assets bucket into"
type = string
default = "eu-west-2"
}

variable "environment" {
description = "Deployment environment name, e.g. dev, staging, production"
type = string
}

resource "aws_s3_bucket" "static_assets" {
bucket = "paysprint-static-assets-${var.environment}"

tags = {
Project = "PaySprint Mobile"
Environment = var.environment
ManagedBy = "terraform"
}
}

resource "aws_s3_bucket_versioning" "static_assets_versioning" {
bucket = aws_s3_bucket.static_assets.id

versioning_configuration {
status = "Enabled"
}
}

resource "aws_s3_bucket_public_access_block" "static_assets_block" {
bucket = aws_s3_bucket.static_assets.id

block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}

output "bucket_name" {
description = "Name of the S3 bucket created for static assets"
value = aws_s3_bucket.static_assets.bucket
}

This config declares one S3 bucket with a name that includes the environment (so dev, staging, and production never collide), turns on versioning (so an overwritten or deleted object can be recovered), and blocks every form of public access on it.


Challenges

1. Read it once, unaided

Before asking GenAI anything, read through the file above and note which blocks you already understand, and which are unfamiliar.

💡 Show Answer

There's no single correct answer here — this step is a self-check. A reasonable read-through, block by block:

  • terraform { } — some setup about which plugin/version this needs.
  • provider "aws" { } — this deploys to AWS, region comes from a variable.
  • The two variable blocks — region (with a sensible default) and environment (no default, must be supplied).
  • aws_s3_bucket — creates the actual storage bucket, name includes the environment.
  • aws_s3_bucket_versioning — turns some kind of history/recovery feature on.
  • aws_s3_bucket_public_access_block — least obvious at a glance; four similarly-named boolean flags, worth asking GenAI about specifically.
  • output "bucket_name" — makes the bucket's real name available after the config runs.

If your unaided read matched most of this, you're in good shape for the next step — GenAI verification. If aws_s3_bucket_public_access_block was a total guess, that's exactly the kind of block this lab is designed to make you dig into properly, rather than accepting a plausible-sounding guess.


2. Ask GenAI to explain the unfamiliar parts, then verify

Ask Copilot Chat to explain what each block does. For at least two specific claims in its explanation, verify them yourself against the Terraform AWS provider docs (for example, what aws_s3_bucket_public_access_block actually controls).

💡 Show Answer

Two claims worth specifically verifying, and what the provider docs actually say:

  1. Claim: "block_public_acls and block_public_policy prevent new public ACLs/policies from being applied, while ignore_public_acls and restrict_public_buckets also affect existing ones." Verification: this is accurate — the four flags are deliberately split into "block new" vs. "ignore/restrict existing," which is why a secure config typically sets all four rather than assuming one implies the others.
  2. Claim: "Enabling versioning on a bucket means deleting an object never truly removes it — it just adds a delete marker, and the previous version is still recoverable." Verification: also accurate, and worth confirming because it has a real cost implication GenAI might not mention unprompted — old versions still count against storage costs unless a separate lifecycle rule expires them.

The specific claims you check will vary depending on exactly what Copilot Chat says, but the exercise is the same: don't take a plausible-sounding explanation of an AWS-specific behaviour at face value when a wrong assumption here could mean a bucket is less locked-down than you think.


3. Annotate the file yourself

Make a copy of the file and add a comment above each block, in your own words, explaining what it does and, where relevant, why it matters.

💡 Show Answer
# Pin the AWS provider plugin so everyone running this config gets the same
# behaviour, rather than whatever the latest version happens to do.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}

# Configure the AWS provider itself - which region we're deploying into.
provider "aws" {
region = var.aws_region
}

# Which AWS region to use. Defaults to eu-west-2 so most runs don't need to
# specify it explicitly, but it can still be overridden per environment.
variable "aws_region" {
description = "AWS region to deploy PaySprint's static assets bucket into"
type = string
default = "eu-west-2"
}

# Which environment this is for (dev/staging/production). No default on
# purpose - forces whoever runs this to be explicit about which environment
# they're targeting, rather than accidentally defaulting to production.
variable "environment" {
description = "Deployment environment name, e.g. dev, staging, production"
type = string
}

# The actual S3 bucket. Name includes the environment so dev/staging/prod
# buckets never collide with each other.
resource "aws_s3_bucket" "static_assets" {
bucket = "paysprint-static-assets-${var.environment}"

tags = {
Project = "PaySprint Mobile"
Environment = var.environment
ManagedBy = "terraform"
}
}

# Turn on versioning so an accidentally overwritten or deleted file can be
# recovered from an earlier version, rather than being gone for good.
resource "aws_s3_bucket_versioning" "static_assets_versioning" {
bucket = aws_s3_bucket.static_assets.id

versioning_configuration {
status = "Enabled"
}
}

# Block every form of public access on this bucket by default. Even though
# it's "static assets," we don't want it directly public - anything that
# genuinely needs to be public-facing should go through a CDN/CloudFront in
# front of it, not a bucket that's public itself.
resource "aws_s3_bucket_public_access_block" "static_assets_block" {
bucket = aws_s3_bucket.static_assets.id

block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}

# Expose the real bucket name after apply, so it can be read by whoever ran
# this, or consumed by another Terraform config (e.g. a CDN config that
# needs to know which bucket to point at).
output "bucket_name" {
description = "Name of the S3 bucket created for static assets"
value = aws_s3_bucket.static_assets.bucket
}

4. Declarative vs. imperative

In two or three sentences, explain why this file is declarative rather than imperative. What would an imperative equivalent (a script of aws CLI commands) have to handle that this file doesn't need to?

💡 Show Answer

This file describes the desired end state — a bucket with versioning on and public access blocked — without specifying the steps to get there; Terraform compares that description against what actually exists and computes the difference itself. An imperative script of aws CLI commands would instead have to check whether the bucket already exists before trying to create it, check whether versioning is already enabled before trying to enable it again, and decide what to do if it's re-run halfway through a previous failed run — all logic Terraform's plan/apply model handles for you.


5. Connect it to CI/CD

This config would typically run through a pipeline stage that shows what would change (terraform plan) before another stage actually applies it (terraform apply), often gated behind an approval. Why might a team want a human approval step specifically before apply, when they don't necessarily require one before, say, a routine application deploy?

💡 Show Answer

A routine application deploy is usually quick and cheap to reverse — redeploy the previous version and you're back where you started. Some infrastructure changes are not: deleting or replacing a resource can mean permanent data loss, and a change like this one (public access settings on a bucket) has a security consequence that's easy to miss in an automated diff but obvious to a human skimming the plan output. Gating apply behind approval means a person always sees exactly what will change before it becomes irreversible, while plan — which changes nothing — can safely run unattended on every commit.


6. Stretch goal: sketch a read-only IAM role

Look up the aws_iam_role resource in the AWS provider docs and sketch, in plain English, what block you'd add to attach a minimal read-only IAM role to this bucket.

💡 Show Answer

At a high level, you'd need: an aws_iam_role resource with an assume_role_policy describing who's allowed to use it (e.g. a specific service or account), an aws_iam_policy (or an inline policy) granting only s3:GetObject and s3:ListBucket — deliberately excluding PutObject/DeleteObject — scoped to this specific bucket's ARN via a Resource condition, and an aws_iam_role_policy_attachment connecting the two. The key design point is scoping the policy's Resource to this bucket specifically (using aws_s3_bucket.static_assets.arn), not "*" — a read-only role that can read every bucket in the account is a much bigger blast radius than one scoped to a single bucket.


Acceptance Criteria

  • An annotated copy of the Terraform file, with a comment on every resource, variable, and output block, in your own words.
  • At least two specific claims from GenAI's explanation verified against a second source.
  • A written declarative-vs-imperative explanation specific to this file.
  • A written answer connecting this to a plan/apply pipeline pattern.