Infrastructure as code with Terraform
Learn Terraform hands-on: the init, plan, apply, destroy loop with a free local example, then a real S3 bucket on AWS with variables and outputs. Understand state, drift and how to read a plan safely.
In this tutorial you will learn Terraform by doing. First you will create a file on your own laptop with Terraform (no cloud account, no cost) to learn the workflow. Then you will create a real S3 bucket on AWS for the Brew & Bean website, using variables and outputs, and finally destroy it.
Prerequisites: a terminal, Terraform installed (the official install page covers Windows, macOS and Linux; check with terraform version), and for the second half an AWS account with the AWS CLI configured for a non-root IAM user or SSO profile. See *AWS core services* for that set-up.
Why infrastructure as code
If you create servers and buckets by clicking in a console, nobody can answer: what exactly did we build, how do we build it again, and who changed it? Infrastructure as code (IaC) means you describe your infrastructure in text files, keep them in Git, review changes in pull requests and let a tool apply them.
Think of a recipe card versus a cook who "just knows" the dish. The card lets a second cook make the same dish in another kitchen.
Terraform is the most widely used IaC tool. It works across clouds (AWS, Azure, Google Cloud, Kubernetes, GitHub and many more) through plugins called providers. Its language is called HCL (HashiCorp Configuration Language). The licence of Terraform changed in 2023 and a community fork called OpenTofu exists with almost the same commands; everything here applies to both, just replace terraform with tofu.
The core ideas
Terraform vocabulary
| Term | Meaning | Example |
|---|---|---|
| Provider | Plugin that talks to one platform's API | hashicorp/aws, hashicorp/local |
| Resource | One thing Terraform creates and manages | aws_s3_bucket |
| Data source | Read-only lookup of something that exists | latest AMI id |
| Variable | An input to your configuration | bucket_name |
| Output | A value printed after apply | the bucket's name or URL |
| State | A file where Terraform records what it created | terraform.tfstate |
| Plan | A preview of what apply will change | terraform plan |
Terraform is declarative: you write the end state you want ("one bucket with versioning on") and Terraform works out the steps. Run it twice and the second run does nothing, because reality already matches your files.
The workflow is always the same four commands: init (download providers), plan (preview), apply (do it) and destroy (remove it).
Part 1: your first config, no cloud needed
Create an empty folder tf-hello and a file main.tf. The local provider can create files on your own machine, which is a safe way to see the whole workflow.
terraform {
required_version = ">= 1.5"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "welcome" {
filename = "${path.module}/welcome.txt"
content = "Welcome to Brew & Bean. Open 8 am to 10 pm.\n"
}The terraform block pins the Terraform and provider versions. resource "local_file" "welcome" means: a resource of type local_file, which I will call welcome inside this configuration.
Try the workflow
- 1Run
terraform init. Terraform downloads thelocalprovider into a hidden.terraformfolder and creates.terraform.lock.hcl, which records exact provider versions. - 2Run
terraform plan. It prints+ createforlocal_file.welcomeand the summaryPlan: 1 to add, 0 to change, 0 to destroy.Nothing has happened yet. - 3Run
terraform apply. Typeyeswhen asked. Nowwelcome.txtexists. - 4Open
main.tf, change the text to sayOpen 7 am to 10 pm.and runterraform planagain. It shows the file will be replaced (a-/+line) with a diff of the content. - 5Run
terraform applyagain to apply the change. Run it one more time: it saysNo changes.That is the declarative idea. - 6Run
terraform destroyand typeyes. The file is removed.
Terraform will perform the following actions:
# local_file.welcome will be created
+ resource "local_file" "welcome" {
+ content = "Welcome to Brew & Bean. Open 8 am to 10 pm."
+ filename = "./welcome.txt"
...
}
Plan: 1 to add, 0 to change, 0 to destroy.State: the part beginners get wrong
After apply, look in the folder: there is a terraform.tfstate file. This is Terraform's memory. It maps each resource in your code to the real object it created. Without it, Terraform would not know that the bucket already exists and would try to create it again.
Three rules about state:
1. Never edit it by hand and never delete it while real resources exist.
2. It can contain secrets (passwords, keys) in plain text. Never commit it to Git. Add .terraform/ and *.tfstate* to .gitignore. Commit .terraform.lock.hcl.
3. In a team, use remote state (for example an S3 bucket with locking, Terraform Cloud or another backend) so everyone shares one state and two people cannot apply at the same time.
State is precious
If you lose the state file for real infrastructure, Terraform forgets it owns those resources. You would have to import them one by one with terraform import. Back up or use remote state for anything that matters.
Part 2: a real S3 bucket on AWS
Now a real resource. Create a folder tf-bucket with three files. Splitting a configuration into main.tf, variables.tf and outputs.tf is a convention, not a rule: Terraform reads every .tf file in the folder together.
variable "bucket_name" {
type = string
description = "Globally unique S3 bucket name, lowercase letters, numbers and hyphens"
}
variable "region" {
type = string
default = "ap-south-1"
}A variable without a default has no value yet, so Terraform will ask for it (or you can pass -var). ap-south-1 is the Mumbai region.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
}
resource "aws_s3_bucket" "site" {
bucket = var.bucket_name
tags = {
Project = "brew-and-bean"
Managed = "terraform"
}
}
resource "aws_s3_bucket_versioning" "site" {
bucket = aws_s3_bucket.site.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}aws_s3_bucket.site.id is a reference to another resource. Terraform sees it and creates the bucket first, then the versioning and access-block settings. You never write the order yourself. If terraform init rejects ~> 6.0, check the provider's page on the Terraform Registry for the current major version.
output "bucket_name" {
value = aws_s3_bucket.site.id
}
output "bucket_arn" {
value = aws_s3_bucket.site.arn
}Outputs are printed after apply and can be read later with terraform output.
Create the bucket
- 1Pick a bucket name nobody else has used, for example
brewbean-yourname-2026-demo. Bucket names are global across all AWS accounts. - 2Run
aws sts get-caller-identityto confirm Terraform will use the right account. - 3Run
terraform init, thenterraform plan -var="bucket_name=brewbean-yourname-2026-demo". ExpectPlan: 3 to add. - 4Run
terraform apply -var="bucket_name=brewbean-yourname-2026-demo"and typeyes. - 5Check it:
aws s3 lsshould list the bucket, andterraform outputprints your two outputs. - 6Run
terraform planagain with the same-var. It reports no changes.
tfvars
Instead of typing -var every time, create a file terraform.tfvars containing bucket_name = "brewbean-yourname-2026-demo". Terraform loads it automatically. Do not put secrets in files you commit.
Reading a plan carefully
The plan is your safety net. Learn its symbols:
Plan symbols
| Symbol | Meaning |
|---|---|
| + | Will be created |
| ~ | Will be changed in place |
| - | Will be destroyed |
| -/+ | Will be destroyed and created again (replace) |
Always read for - and -/+ on anything holding data, such as a database or a bucket. Changing a field that cannot be updated in place (a bucket name, a database engine) causes a replace, and replacing a database deletes its data. In team workflows the plan is posted on the pull request and a person approves it before apply.
Part 3: change and destroy
Change, then clean up
- 1Add another tag
Owner = "your-name"inmain.tfand run plan. You should see one~ update in-placefor the bucket. - 2Apply it. Then run
terraform state listto see the three resources Terraform tracks. - 3Run
terraform destroy -var="bucket_name=brewbean-yourname-2026-demo"and typeyes. The bucket is removed. A bucket with files in it cannot be destroyed unless you addforce_destroy = trueto the bucket resource first, which is a deliberate safety catch.
Be careful with real resources
terraform destroy and apply act on real resources and real money. Read the plan every time, use a separate AWS account or sandbox for learning, and never run Terraform against production from your laptop without review. Also keep AWS access keys out of .tf files: Terraform reads the same credentials as the AWS CLI (profile, SSO or environment variables).
Habits of good Terraform
Checklist
- Run
terraform fmtto format files andterraform validateto catch mistakes before planning. - Pin provider versions and commit
.terraform.lock.hcl. - Store state remotely with locking when more than one person works on it.
- Never commit state files or secret values.
- Tag every resource with project and owner so cost and clean-up are easy.
- Make changes only through code. A manual console change causes drift, and the next apply may undo it.
terraform planshows drift. - Split big systems into modules (reusable folders of resources) once you repeat yourself.
You are a senior DevOps engineer reviewing Terraform code for a beginner.
Review the configuration below for: security problems (public access, open security groups, hard-coded secrets), missing tags, missing version pinning, and anything that would cause a destructive replace. Explain each finding in one or two plain sentences and show the fixed code.
<terraform>
[paste your .tf files here]
</terraform>What to expect: A good answer points at specific lines, explains the risk and gives corrected HCL. Verify any suggested argument names against the provider documentation, because AI can invent arguments.
How this comes up in interviews
Common questions: What is Terraform state and why is it sensitive? What is the difference between plan and apply? What is drift? How is Terraform different from Ansible? (Terraform provisions infrastructure declaratively; Ansible mainly configures software on machines, often step by step.) How do you share state in a team? How would you create the same infrastructure for dev and production? (variables per environment and modules, with separate state for each.)
Try it: Add an environment variable
Add a variable environment (default dev) to tf-bucket, use it in the bucket tag as Environment = var.environment, and apply it with -var="environment=staging". Read the plan before you apply.
Key takeaways
- Terraform describes infrastructure in
.tffiles; you state the end result and Terraform works out the steps. - The loop is
init,plan,apply,destroy. Always read the plan before applying. - State records what Terraform manages. Keep it safe, never commit it, and use remote state with locking in teams.
- References between resources (like
aws_s3_bucket.site.id) set the creation order automatically. - Variables make configurations reusable; outputs expose values you need afterwards.
- Change infrastructure only through code to avoid drift, and watch for
-/+replaces on anything holding data.
Check yourself
Finishing the quiz marks the lesson as done.
1. What does terraform plan do?
2. Why should terraform.tfstate not be committed to Git?
3. You run terraform apply twice with no changes in between. What happens the second time?
4. In bucket = aws_s3_bucket.site.id, what does this reference do besides supplying a value?
5. A plan shows -/+ next to your database. What should you do?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.