Skip to content
EgyKode
Guided lab

Terraform Fundamentals

45 minBeginner

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#

hcl
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.

Terminal
terraform init

init 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#

hcl
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.

hcl
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#

hcl
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#

Terminal
terraform fmt -recursive
terraform validate
terraform plan

Every resource gets a symbol, and the symbol is the whole message:

SymbolMeaningHow worried to be
+createNormal for new infrastructure
~update in placeUsually safe
-destroyRead carefully
-/+destroy then recreateStop and read every line
Terminal
terraform apply

Then run it again:

Terminal
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#

Terminal
terraform state list
terraform state show aws_instance.demo | head -20
grep -o '"id": "i-[a-z0-9]*"' terraform.tfstate | head -1

State 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.

DestructiveThis removes real resources. Check which environment you are in first.

Terminal
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 nothing

Cost of this lab: Free tier — one t3.micro and an S3 bucket. Nothing here runs an hourly-billed resource beyond the instance itself.

The concept behind it

Ready to try it without help?Do the challenge