Terraform Fundamentals
This creates billable resources. Run it in a dev environment and destroy it when you finish. Set a budget alarm first.
Success criteria
0 of 5
The scenario#
Before a VPC module makes any sense, the five pieces underneath it have to be concrete: what a provider is, what a resource declaration produces, where a value comes in, where a value goes out, and what Terraform remembers between runs.
This lab builds the smallest infrastructure that exercises all five.
1. The provider#
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
provider "aws" {
region = var.region
}Terraform knows nothing about AWS. The provider is a plugin that translates
resource blocks into API calls, and ~> 5.40 means "any 5.x from 5.40, never
6.0" — patch and minor updates are allowed, the breaking major is not.
terraform initinit downloads the plugin and writes .terraform.lock.hcl, which records the
exact version and its checksum. Commit that file — it is what makes your
build and the CI runner's build identical.
2. Variables in, outputs out#
variable "region" {
description = "Where this runs"
type = string
default = "us-east-1"
}
variable "instance_type" {
description = "Size of the demo instance"
type = string
default = "t3.micro"
}A variable with no default is required, and Terraform refuses to run without
it. That is the correct choice for anything environment-specific.
output "instance_ip" {
description = "Public address of the demo instance"
value = aws_instance.demo.public_ip
}Outputs are the module's public surface. Anything a caller needs must leave through one — there is no reaching inside.
3. Resources#
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "demo" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
tags = {
Name = "tf-fundamentals"
Purpose = "learning"
}
}
resource "aws_s3_bucket" "demo" {
bucket = "tf-fundamentals-${random_id.suffix.hex}"
}
resource "random_id" "suffix" {
byte_length = 4
}A data block reads something that already exists; a resource block owns
something Terraform will create and destroy. Confusing the two is how people
accidentally destroy shared infrastructure.
Note aws_s3_bucket.demo depends on random_id.suffix without anyone saying
so. Terraform builds a dependency graph from the references themselves and
orders the work automatically.
4. Read the plan before you apply it#
terraform fmt -recursive
terraform validate
terraform planEvery resource gets a symbol, and the symbol is the whole message:
| Symbol | Meaning | How worried to be |
|---|---|---|
+ | create | Normal for new infrastructure |
~ | update in place | Usually safe |
- | destroy | Read carefully |
-/+ | destroy then recreate | Stop and read every line |
terraform applyThen run it again:
terraform apply
# No changes. Your infrastructure matches the configuration.That second run is the point of the whole tool. The configuration describes an end state, so applying it to a system already in that state does nothing. This is idempotency, and it is what makes it safe to run continuously.
5. The state file#
terraform state list
terraform state show aws_instance.demo | head -20
grep -o '"id": "i-[a-z0-9]*"' terraform.tfstate | head -1State is Terraform's memory: a map from your resource addresses to real AWS ids. Without it, Terraform cannot tell a resource it created from one it has never seen.
Two consequences to internalise now:
- It frequently contains secrets in plain text — an RDS password, a generated key — because the API returned them at creation. It never goes in Git.
- Losing it orphans everything it tracked. The resources keep running and billing; Terraform simply no longer knows about them. That is why the next lab moves it to a remote backend with locking.
When it goes wrong#
terraform apply says the bucket name is already taken
S3 bucket names are globally unique across every AWS account. That is what random_id is for — check it is actually being interpolated into the name.
The second apply shows changes when nothing changed
Something outside Terraform modified the resource — configuration drift. terraform plan shows exactly which attribute differs.
terraform destroy leaves the bucket behind
S3 refuses to delete a bucket with objects in it. Empty it first: aws s3 rm s3://<bucket> --recursive.
init fails with a provider checksum mismatch
The lock file was written on a different platform. terraform providers lock -platform=linux_amd64 -platform=windows_amd64 records both.
Clean up#
Run this even if you did not finish.
Destructive — This removes real resources. Check which environment you are in first.
terraform destroy -auto-approve
aws ec2 describe-instances --filters Name=instance-state-name,Values=running --query 'Reservations[].Instances[].InstanceId'
aws s3 ls | grep tf-fundamentals # must print nothingCost of this lab: Free tier — one t3.micro and an S3 bucket. Nothing here runs an hourly-billed resource beyond the instance itself.