Week 9: Infrastructure as Code with Terraform

Last week you created a VPC, an EC2 instance and an S3 bucket by hand, one aws CLI command at a time — accurate, but unrepeatable, and impossible to review as a diff before it changes anything. Terraform lets you describe that same infrastructure declaratively, in version-controlled files, and preview every change before it happens. This week you'll write your first real Terraform configuration, learn the init/plan/apply/destroy workflow, and understand Terraform state well enough not to break it — the foundation Week 10 builds on with reusable modules and a remote backend a whole team can share safely.

Module 7 of 22 Week 9 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a Terraform configuration using a provider block and one or more resource blocks
  • Run the init/plan/apply/destroy workflow and read a plan's output correctly
  • Explain what Terraform state is for and provision a real AWS resource end to end

1. Why Infrastructure as Code

Every command you ran in Week 8 worked, but none of it is repeatable without a human retyping it, none of it is reviewable before it runs, and nothing tells you two weeks from now whether the security group you're looking at in the console still matches what you intended. Infrastructure as Code (IaC) tools like Terraform fix this by describing the desired end state in a file, checked into Git like any other code, that a tool then reconciles reality against.

That reconciliation is the key difference from a shell script of CLI commands. A script describes steps — run it twice and you might create two buckets or get an error on the second run. A Terraform configuration describes a desired state — run it twice and the second run does nothing, because the infrastructure already matches what you asked for. When someone changes a resource by hand in the console, that's drift, and Terraform's next plan will show you exactly what's different.

Treat infrastructure changes like code changes

Once a resource is managed by Terraform, stop clicking it in the AWS console. A manual change works until the next apply either reverts it or fights it — the same "someone edited main.js directly on the server" problem Git branching in Week 2 exists to prevent, just for infrastructure instead of application code.

2. Providers & Resources

Terraform itself knows nothing about AWS, Azure, or any specific platform — that knowledge lives in a provider, a plugin that translates HCL (HashiCorp Configuration Language) into API calls. You declare which providers you need and which versions are acceptable, then Terraform downloads them during init:

main.tf
terraform {
  required_version = ">= 1.7.0"

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

provider "aws" {
  region = "us-east-1"
}

A resource block is the actual thing you want to exist — resource "<provider_type>" "<local_name>" followed by its arguments. The local name is only used to reference this resource elsewhere in your configuration; it never appears in AWS itself:

main.tf — an S3 bucket resource
resource "aws_s3_bucket" "app_logs" {
  bucket = "codeverse-app-logs-ada"

  tags = {
    Environment = "dev"
    Owner       = "ada"
    ManagedBy   = "terraform"
  }
}

Resources can reference each other's computed attributes — aws_s3_bucket.app_logs.arn, for instance — and Terraform automatically works out the order to create them in based on those references, without you writing that order down yourself.

3. The Workflow: init, plan, apply, destroy

Four commands cover almost everything you'll do day to day. terraform init downloads the providers a configuration references and sets up its local working directory — run it once per configuration, and again any time you add a provider:

terminal
$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.42.0...

Terraform has been successfully initialized!

terraform plan compares your configuration against real infrastructure and prints exactly what would change, without changing anything yet — this is the step you actually read carefully, every time:

terminal
$ terraform plan

Terraform will perform the following actions:

  # aws_s3_bucket.app_logs will be created
  + resource "aws_s3_bucket" "app_logs" {
      + arn    = (known after apply)
      + bucket = "codeverse-app-logs-ada"
      + id     = (known after apply)
      + tags   = {
          + "Environment" = "dev"
          + "ManagedBy"   = "terraform"
          + "Owner"       = "ada"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

The prefix on each line is the whole vocabulary: + creates a new resource, - destroys one, ~ updates one in place, and -/+ means Terraform has to destroy and recreate it because you changed an attribute that can't be updated live (an S3 bucket's name, for example). terraform apply shows you that same plan and asks for confirmation before executing it:

terminal
$ terraform apply

  Enter a value: yes

aws_s3_bucket.app_logs: Creating...
aws_s3_bucket.app_logs: Creation complete after 2s [id=codeverse-app-logs-ada]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

$ terraform destroy
  Enter a value: yes

aws_s3_bucket.app_logs: Destroying... [id=codeverse-app-logs-ada]
aws_s3_bucket.app_logs: Destruction complete after 1s

Destroy complete! Resources: 1 destroyed.
Never apply a plan you haven't read

An unexpected -/+ next to a database resource is how people accidentally destroy production data — Terraform will do exactly what the plan says with no further warning once you type "yes". In a CI pipeline, save the plan output as an artifact and require a human to approve it before the apply job runs, the same required-reviewer pattern from Week 7's GitHub Environments.

4. Terraform State

Terraform needs to remember which real-world resources correspond to which blocks in your configuration, and it stores that mapping — including resource IDs and, often, sensitive attribute values — in a state file, terraform.tfstate, written in JSON. Every plan and apply reads and updates it.

terminal — inspecting state
terraform state list                       # every resource Terraform is tracking
terraform state show aws_s3_bucket.app_logs # full recorded attributes for one resource
terraform show                              # human-readable dump of the whole state

Without an accurate state file, Terraform has no way to know a resource already exists — it would try to create a duplicate, or fail because a bucket with that name is already taken. State is also how destroy knows what to tear down: it isn't re-reading your .tf files' history, only the current configuration plus whatever state already recorded.

.gitignore
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl

You should never hand-edit a state file, and — critically — you should never commit it to Git either. It's a database, not a config file, and it routinely contains plaintext secrets (database passwords set via a resource argument, for instance). This week you'll keep state locally; Week 10 replaces that with a remote backend a team can share without emailing a JSON file around.

Lost state, orphaned resources

If terraform.tfstate is deleted, Terraform doesn't know your S3 bucket or EC2 instance exists anymore — it will happily try to create a new one, leaving the old one running and billing you, unmanaged and invisible to future plans. Back up or, better, remotely store state before you ever run apply against anything you care about.

5. Provisioning a Real AWS Resource End to End

Let's put it together: a versioned S3 bucket, driven by a variable so the same configuration can be reused with a different name, with an output so you can see the bucket's ARN after applying.

variables.tf
variable "bucket_suffix" {
  description = "Unique suffix appended to the S3 bucket name"
  type        = string
}
main.tf
resource "aws_s3_bucket" "app_logs" {
  bucket = "codeverse-app-logs-${var.bucket_suffix}"

  tags = {
    Environment = "dev"
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket_versioning" "app_logs" {
  bucket = aws_s3_bucket.app_logs.id

  versioning_configuration {
    status = "Enabled"
  }
}
outputs.tf
output "bucket_arn" {
  description = "ARN of the app logs bucket"
  value       = aws_s3_bucket.app_logs.arn
}
terminal
terraform fmt                       # auto-format .tf files consistently
terraform validate                  # catch syntax/type errors before planning
terraform plan -var="bucket_suffix=ada-0417"
terraform apply -var="bucket_suffix=ada-0417"
terraform output bucket_arn
# "arn:aws:s3:::codeverse-app-logs-ada-0417"

That's the full loop: write HCL, initialize providers, preview the plan, apply it, and read back an output — the same four-step shape you'll use for every resource in this course from here on, whether it's one S3 bucket or an entire Kubernetes cluster.

6. Hands-on Exercise

Hands-on

Codify last week's S3 bucket in Terraform

Recreate one piece of Week 8's manual environment declaratively, and practice reading a plan before you trust it.

Requirements:

  1. Create a project directory with main.tf declaring the hashicorp/aws provider pinned to ~> 5.0, and run terraform init.
  2. Define a bucket_suffix input variable, and use it to name an aws_s3_bucket resource codeverse-app-logs-<suffix>.
  3. Add an aws_s3_bucket_versioning resource enabling versioning, and tag the bucket with Environment and ManagedBy.
  4. Run terraform plan, read the output line by line, and confirm you understand every attribute marked (known after apply) before running terraform apply.
  5. Add a bucket_arn output and confirm it with terraform output bucket_arn, then verify the bucket exists with aws s3api get-bucket-versioning --bucket <name>.
  6. Run terraform destroy to clean up, and confirm the bucket is gone from aws s3 ls.
Hint

Add .gitignore before your first init, not after — it's easy to forget and accidentally stage terraform.tfstate in your very first commit, exactly the mistake the state section above warns about.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why should you always read a terraform plan's output before running apply, even for a change you're confident about?

An attribute you didn't expect to be immutable can force a -/+ destroy-and-recreate instead of an in-place ~ update, and drift from a manual console change can make the plan include actions you never intended. Reading the plan is the only checkpoint before Terraform actually executes changes against real infrastructure — after you type "yes" there's no undo.

Q2

What actually breaks if Terraform's state file doesn't accurately reflect real infrastructure?

Terraform has no other way to know a resource already exists, so a missing or stale state entry makes it try to create a duplicate (which fails for globally unique names like S3 buckets, or silently doubles cost for things like EC2 instances) and makes destroy unable to find resources it should tear down, leaving them orphaned and unmanaged.

Q3

Why should terraform.tfstate never be committed to Git, even in a private repository?

State files commonly contain sensitive resource attributes in plaintext — database passwords, connection strings, keys — because Terraform has to record every attribute it manages, sensitive or not. It's also a database that multiple people would be editing concurrently through Git, which conflicts and corrupts far more easily than it would through a proper backend built for concurrent access.

Q4

In a plan's output, what's the practical difference between a ~ update and a -/+ destroy-and-recreate?

A ~ update changes the resource in place without deleting it — an EC2 instance's tags, say — while a -/+ means the changed attribute can't be modified live (an S3 bucket's name, for example), so Terraform destroys the existing resource entirely and creates a new one with a new ID. That distinction matters enormously for anything stateful, since a destroy-and-recreate on a database means data loss unless it's backed up first.