Skip to content

Phase 4 · ORCHESTRATION & IAC

Terraform + AWS — provision the project infra

Day 79 of 90 ~60 min 0/20 in phase Builds on Day 78

By the end of today

  • Configure the AWS provider with a region and credentials that never live in code
  • Run terraform init, plan and apply to create real AWS resources from HCL
  • Verify resources with the AWS CLI, then destroy them back to zero

The AWS provider: how Terraform turns HCL into real AWS resources

Section 1 of 5 · ~3 min

For three days Terraform has been abstract — local files, state you inspected, modules you refactored. Today it reaches the cloud. The bridge is a provider: a plugin Terraform downloads that knows how to talk to exactly one API. The AWS provider (hashicorp/aws) translates the resources you write in HCL into real calls against the AWS API — resource "aws_s3_bucket" becomes a CreateBucket call, aws_vpc becomes CreateVpc, and the ID AWS hands back is written into your state file.

You wire it in two blocks. required_providers pins the provider and its version — ~> 6.0 locks it to the v6 major line, and pinning the major version is what stops a surprise upgrade from breaking a future apply. The provider "aws" block sets the region every resource is created in. That’s the whole configuration, except the one thing that must never appear in it: your credentials.

Where credentials come from. The AWS provider uses the same credential chain as the AWS CLI, so if aws configure already works, Terraform already works — it reads ~/.aws/credentials automatically. In CI you pass AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION as environment variables, or better, assume an IAM role so nothing long-lived is stored. What you do not do is type a key into a .tf file — that file is source code, it gets committed and pushed, and a hardcoded AWS key in a public repo is scraped and abused within minutes.

Terraform maps HCL to real AWS resources: your .tf files feed the Terraform core, which on apply calls the AWS provider plugin, which makes AWS API calls that create the S3 bucket, VPC and security group; the resulting resource IDs are written back into the state file. HCL (.tf) desired state Terraform core plan / apply AWS provider hashicorp/aws AWS API CreateBucket… Real infra S3 · VPC · SG state file (IDs)
Your HCL feeds the Terraform core; on apply the AWS provider turns each resource into an AWS API call, and the IDs come back into state.

Real world: A provider is an interpreter you hire for a negotiation in a language you don’t speak. You state what you want in your own words — “a bucket named this, a network this size, a firewall that allows port 80” — and the interpreter turns each request into the exact phrasing the AWS API demands, hands it over, and writes down the reference number that comes back. You never learn AWS’s dialect; the AWS provider speaks it for you and keeps every receipt in the state file.

How HCL becomes real infrastructure. The loop is initplanapply. init downloads the provider and locks its version; plan diffs your HCL against state and the live account and prints what it would do; apply makes the calls and records the results. Because the plan is shown before anything changes, you get one deliberate checkpoint — the “4 to add, 0 to change, 0 to destroy” line — before the account is touched.

A named example makes it concrete. The hashicorp/aws provider is the most-downloaded provider on the public Terraform Registry — billions of installs — jointly maintained by HashiCorp and AWS, covering well over a thousand AWS resource types. That’s why resource "aws_s3_bucket" behaves identically on your laptop, in CI, and on a teammate’s machine: the same versioned plugin, pinned by your lock file, makes the same API calls everywhere.

Back to Project 2. On days 62–65 you stood linkstash up on AWS the real way — but through the console and a long list of aws CLI commands, one click and one flag at a time. Today the same footprint — a VPC, a security group, a bucket — becomes a handful of HCL blocks you can read, review in a pull request, apply in seconds, and destroy just as fast. The infra stops being a sequence of actions someone remembered to run, and becomes a description a machine keeps true. (Prefer a fully open-source binary? OpenTofu, the community fork, reads the exact same HCL and providers.)

Hands-On Lab

Section 2 of 5 · ~5 min

What this costs: ₹0 if you run terraform destroy at the end. Everything this lab creates is free: an empty S3 bucket (you pay only for stored bytes and requests, and there are none), a VPC, and a security group all cost nothing to exist — AWS bills for what runs inside the network, and this launches no EC2, no NAT gateway, no load balancer. Nothing here bills by the hour. The only way to be charged is to leave objects in a bucket, or a running instance, behind — so finish with the destroy in step 8 and confirm 0 resources in step 9.

Budget about 30 minutes. Drive this from your WSL2 Ubuntu 24.04 terminal with Terraform 1.10+ and AWS CLI v2 installed, authenticated as your Day 47 IAM user (not root). Terraform IDs and the random bucket suffix are unique to each run — yours will differ from the samples.

# 1. Confirm Terraform 1.10+ and that your AWS credentials work (you, not root).
terraform version
aws sts get-caller-identity
# Output (your Terraform minor and patch versions and AWS account will differ):
# Terraform v1.15.2
# on linux_amd64
# {
#     "UserId": "AIDA...EXAMPLE",
#     "Account": "123456789012",
#     "Arn": "arn:aws:iam::123456789012:user/devops-you"
# }
# 2. Fresh directory. Declare the required providers and the region — credentials are NOT here.
mkdir -p ~/m90-tf-aws && cd ~/m90-tf-aws
cat > versions.tf <<'EOF'
terraform {
  required_version = ">= 1.10"
  required_providers {
    aws    = { source = "hashicorp/aws", version = "~> 6.0" }
    random = { source = "hashicorp/random", version = "~> 3.6" }
  }
}

provider "aws" {
  region = "us-east-1" # creds come from aws configure / env vars, never a key in this file
}
EOF
echo "versions.tf written"
# Output:
# versions.tf written
# 3. Describe the infra: a random suffix, a globally-unique S3 bucket, a VPC, a security group.
cat > main.tf <<'EOF'
resource "random_id" "suffix" {
  byte_length = 4
}

resource "aws_s3_bucket" "assets" {
  bucket = "m90-linkstash-assets-${random_id.suffix.hex}"
  tags   = { Project = "linkstash", ManagedBy = "terraform" }
}

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
  tags       = { Name = "m90-linkstash-vpc" }
}

resource "aws_security_group" "web" {
  name        = "m90-linkstash-web"
  description = "HTTP in, all out"
  vpc_id      = aws_vpc.main.id
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = { Name = "m90-linkstash-web" }
}

output "bucket_name" { value = aws_s3_bucket.assets.bucket }
output "vpc_id"      { value = aws_vpc.main.id }
EOF
echo "main.tf written"
# Output:
# main.tf written
# 4. Download the providers this config needs and write the lock file.
terraform init
# Output (the resolved 6.x / 3.x versions will differ — your minor and patch versions vary; anything satisfying the ~> pins works):
# Initializing the backend...
# Initializing provider plugins...
# - Finding hashicorp/aws versions matching "~> 6.0"...
# - Finding hashicorp/random versions matching "~> 3.6"...
# - Installing hashicorp/aws v6.6.0...
# - Installed hashicorp/aws v6.6.0 (signed by HashiCorp)
# - Installing hashicorp/random v3.6.3...
# - Installed hashicorp/random v3.6.3 (signed by HashiCorp)
# Terraform has created a lock file .terraform.lock.hcl to record the provider selections...
# Terraform has been successfully initialized!
# 5. Preview what Terraform will create — it touches nothing yet.
terraform plan
# Output (trimmed; the important line is the last one):
# Terraform will perform the following actions:
#   # aws_s3_bucket.assets will be created
#   + resource "aws_s3_bucket" "assets" { ... }
#   # aws_security_group.web will be created
#   + resource "aws_security_group" "web" { ... }
#   # aws_vpc.main will be created
#   + resource "aws_vpc" "main" { ... }
#   # random_id.suffix will be created
#   + resource "random_id" "suffix" { ... }
#
# Plan: 4 to add, 0 to change, 0 to destroy.
# 6. Apply — type `yes` at the prompt. Terraform now makes the real AWS API calls.
terraform apply
# Output (IDs and the random suffix are unique to your run — yours will differ):
#   Enter a value: yes
# random_id.suffix: Creating...
# random_id.suffix: Creation complete after 0s [id=Ky3x8Q]
# aws_vpc.main: Creating...
# aws_s3_bucket.assets: Creating...
# aws_vpc.main: Creation complete after 2s [id=vpc-0a1b2c3d4e5f67890]
# aws_security_group.web: Creating...
# aws_s3_bucket.assets: Creation complete after 3s [id=m90-linkstash-assets-2b2df1f1]
# aws_security_group.web: Creation complete after 2s [id=sg-0abc123def4567890]
#
# Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
#
# Outputs:
# bucket_name = "m90-linkstash-assets-2b2df1f1"
# vpc_id = "vpc-0a1b2c3d4e5f67890"
# 7. Prove the HCL became REAL AWS resources — query them with the AWS CLI, not Terraform.
aws s3 ls | grep "$(terraform output -raw bucket_name)"
aws ec2 describe-vpcs --vpc-ids "$(terraform output -raw vpc_id)" \
  --query 'Vpcs[0].{Id:VpcId,Cidr:CidrBlock,State:State}'
# Output (your bucket, ID and timestamp will differ):
# 2026-07-12 10:14:02 m90-linkstash-assets-2b2df1f1
# {
#     "Id": "vpc-0a1b2c3d4e5f67890",
#     "Cidr": "10.0.0.0/16",
#     "State": "available"
# }
# 8. TEARDOWN — destroy everything Terraform created. Type `yes` at the prompt.
terraform destroy
# Output (trimmed):
# Plan: 0 to add, 0 to change, 4 to destroy.
#   Enter a value: yes
# aws_security_group.web: Destroying... [id=sg-0abc123def4567890]
# random_id.suffix: Destroying... [id=Ky3x8Q]
# aws_s3_bucket.assets: Destroying... [id=m90-linkstash-assets-2b2df1f1]
# aws_vpc.main: Destroying... [id=vpc-0a1b2c3d4e5f67890]
#
# Destroy complete! Resources: 4 destroyed.
# 9. Confirm nothing is left — state is empty and the bucket is gone from your account.
terraform state list
aws s3 ls | grep m90-linkstash-assets || echo "no lab bucket left — nothing to bill"
# Output:
# (terraform state list prints nothing — no resources under management)
# no lab bucket left — nothing to bill

Read the last two outputs back: Destroy complete! Resources: 4 destroyed and the empty terraform state list are the ones that matter — they are the difference between a ₹0 lab and resources you forgot were there. Then open the S3 and VPC consoles in us-east-1 and confirm with your own eyes that nothing lingers; on AWS the console is the ground truth, and trusting only the terminal is how a “destroyed” resource keeps billing.

Common Errors & Fixes

Section 3 of 5 · ~3 min

These three trip up almost everyone the first time Terraform touches a real AWS account. Read the error text slowly — parsing it is the actual skill.

Common error: Running terraform apply before any credentials are configured (no aws configure, no exported keys, no role):

Error: No valid credential sources found

  with provider["registry.terraform.io/hashicorp/aws"],
  on versions.tf line 9, in provider "aws":
  9: provider "aws" {

Please see https://registry.terraform.io/providers/hashicorp/aws
for more information about providing credentials.

Why: The AWS provider walked its whole credential chain — environment variables, the shared ~/.aws/credentials file, an EC2/CI instance role — and found nothing usable. It is not a problem with your HCL; the provider simply has no identity to authenticate as, so it refuses before making any API call.

Fix: Give it credentials outside the code: run aws configure (or export AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION) in this shell, confirm with aws sts get-caller-identity, then re-run apply. If you also see a “region is required” message, set it in the provider block as in step 2.

How you’d spot it in prod: A CI apply failing with “No valid credential sources found” means the pipeline never injected credentials — a missing secret, an expired token, or a failed OIDC role assumption. Check the runner’s credentials, not the Terraform config.

Common error: Giving the bucket a fixed literal name that someone, somewhere, already took:

Error: creating S3 Bucket (my-bucket): operation error S3: CreateBucket, https response error StatusCode: 409, RequestID: 8FE..., HostID: abc..., api error BucketAlreadyExists: The requested bucket name is not available. The bucket namespace is shared by all AWS accounts.

Why: S3 bucket names are globally unique across every AWS account on Earth, so a plain name like my-bucket or assets was claimed years ago. The collision isn’t with your account — it’s with the whole planet — so Terraform’s CreateBucket call comes back 409.

Fix: Never hardcode a bucket name. Append a random or account-specific suffix, exactly as the lab does with random_id: bucket = "m90-linkstash-assets-${random_id.suffix.hex}". If instead you see the sibling error BucketAlreadyOwnedByYou, you already own that exact bucket — import it or reuse it rather than recreating it.

How you’d spot it in prod: A terraform apply failing on CreateBucket with BucketAlreadyExists is almost always a non-unique name in the config. Add an environment, region or account-ID suffix — retrying the same literal name will never succeed.

Common error: Hardcoding an AWS access key into a .tf file and pushing the repo to GitHub:

remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: - GITHUB PUSH PROTECTION
remote:     Resolve the following violations before pushing again
remote:       - Push cannot contain secrets
remote:         —— Amazon AWS Access Key ID ——
remote:           locations:
remote:             - commit: 9a1b2c3d..., path: versions.tf:12
To github.com:you/linkstash-infra.git
 ! [remote rejected] main -> main (push declined due to repository rule violations)

Why: A .tf file is committed like any other source file. GitHub’s secret-scanning push protection recognises the AWS access-key pattern and blocks the push before the secret ever lands on the remote — the guardrail that stops years of leaked keys ending up on public repos.

Fix: Take the key out of the code entirely — delete the line and let the provider read aws configure / env vars / an IAM role instead. Add *.tfstate* and .terraform/ to .gitignore too, because state can hold secrets in plaintext. And since the key already touched your disk and history, rotate it in IAM to be safe — don’t just force-push past the protection.

How you’d spot it in prod: A teammate’s or a CI push failing with GH013 / “Push cannot contain secrets” means a credential was committed to code. Rotate the exposed key first, then scrub it from history — bypassing push protection just moves the leak somewhere quieter.

Terraform + AWS Interview Questions

Section 4 of 5 · ~1 min

These four come up whenever a role touches infrastructure as code on AWS — cover the answers, say your own version out loud first, then compare. The four questions and answers render right after this note.

Go Deeper

Section 5 of 5 · ~1 min

Optional extras if you have ~30 more minutes today:

  • 5 min — Run terraform show (or open terraform.tfstate) after an apply and read how Terraform recorded each resource’s real AWS ID — the mapping the state file exists to keep.
  • 10 min — Add an aws_instance to feel the cost line move: t2.micro is the free-tier type in us-east-1, but the 750 free hours a month only apply to older accounts (accounts created after mid-2025 get a credit-based free tier instead), and every instance’s public IPv4 now bills $0.005/hour on its own since February 2024. Apply, check, and terraform destroy it immediately.
  • 10 min — Add a .gitignore for *.tfstate* and .terraform/, then read why state files can contain secrets in plaintext and must never be committed.
  • 15 min — Skim the hashicorp/aws docs for aws_s3_bucket and note how v4 (Feb 2022) split the old monolithic bucket into separate resources (aws_s3_bucket_versioning, aws_s3_bucket_server_side_encryption_configuration, aws_s3_bucket_lifecycle_configuration) you compose as needed.
How does Terraform authenticate to AWS, and where should credentials live? Both

Terraform authenticates through the AWS provider, which uses the same credential chain as the AWS CLI. In practice I run aws configure once so my key and secret sit in ~/.aws/credentials, and the provider picks them up automatically — I only set region in the provider block. It also reads the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables, which is how a CI job passes short-lived credentials. On an EC2 instance or in a pipeline I prefer assuming an IAM role over static keys, so nothing long-lived is stored at all. The one thing I never do is put the key in the .tf file — that is source code, and it gets committed to git.

Walk me through terraform init, plan and apply. Both

Three commands, one loop. terraform init downloads the providers my config needs — here hashicorp/aws — and writes a lock file pinning their versions. terraform plan compares my HCL against the recorded state and the real AWS account, then prints exactly what it will add, change or destroy without touching anything. terraform apply executes that plan, makes the real AWS API calls, and records each resource's ID in state. The habit that keeps me safe is always reading the plan before I approve the apply: the 'Plan: 4 to add, 0 to change, 0 to destroy' line is the last chance to catch a mistake before it hits the account.

Why should you never hardcode AWS keys in a .tf file, and what do you do instead? Service

Because a .tf file is source code — it gets committed and pushed, so a hardcoded access key is now in a repo's history, often forever, even if you delete the line later. Leaked AWS keys get scraped from public GitHub within minutes and used to spin up crypto miners on your bill. So I keep credentials out of the code entirely: aws configure for local work, environment variables or an IAM role in CI. Secrets that genuinely must be in config, like a database password, go through variables marked sensitive and a real secret store, never a literal. And if a key ever does leak, I rotate it in IAM immediately rather than hoping nobody noticed.

What does terraform destroy do, and why does it matter for cost on AWS? Both

terraform destroy reads the state file and deletes every resource Terraform created, in dependency order, after showing a plan you approve. It matters most on AWS because 'stopped' rarely means 'free' — a load balancer, a NAT gateway or a public IPv4 keeps billing by the hour whether or not anyone uses it. When a lab or a demo environment is defined in code, destroy is one command that guarantees I've left nothing quietly charging me, which is far safer than hunting resources in the console. In production I never destroy shared infra casually, but for ephemeral environments — a review app per pull request — build-then-destroy is the whole point of infrastructure as code.

Mark Day 79 complete

Tomorrow you play: Terraform Trouble

Stuck on today’s lab? Ask in Mission 90 Q&A