Learn Infrastructure as Code with Terraform, declarative vs imperative tools, providers, resources, variables, outputs, plan and apply, state and locking, drift, modules, IaC in CI/CD, and a real AWS web server example.

Bishal Biswas
Self Learner
The trouble with clicking
In Cloud Computing Basics you saw that you can create servers, networks and databases by clicking in a web console. For learning, that is fine. For anything real, it causes problems that grow over time:
- Nobody knows exactly what exists, or why. The person who set it up left last year.
- Environments drift apart. Staging was built by hand in March, production in June, and they are subtly different in ways nobody can list.
- Nothing is reviewed. One mistyped firewall rule can open a database to the internet, and no one sees it before it happens.
- Rebuilding is guesswork. If a region goes down or an account is deleted, recreating everything from memory takes days.
Servers built by hand like this are called snowflakes: each one unique, and impossible to recreate exactly.
What is Infrastructure as Code?
Infrastructure as Code (IaC) means describing your infrastructure (servers, networks, firewalls, databases, DNS records) in text files, and letting a tool create and update the real thing from them.
Because it is code, infrastructure gets every benefit you have built up in this course:
- it lives in Git, with a full history of who changed what and why (Version Control with Git);
- changes go through pull requests and review;
- a pipeline can check and apply it automatically (Continuous Integration);
- staging and production can be built from the same files, so they really match;
- rebuilding everything from scratch is one command.
Declarative vs imperative
There are two styles of IaC:
| Imperative | Declarative | |
|---|---|---|
| You write | The steps: “create a server, then open port 80” | The result: “there should be one server with port 80 open” |
| Running it twice | Might create two servers | Changes nothing the second time |
| Examples | Shell scripts calling cloud CLIs | Terraform, CloudFormation, Bicep, Kubernetes YAML |
Declarative tools work out the steps for you by comparing what you asked for with what exists, the same desired state idea you met in Why Kubernetes?. Running them again and again safely is called being idempotent.
The tools
| Tool | Language | Works with |
|---|---|---|
| Terraform | HCL | Almost everything: every major cloud, plus DNS, GitHub, monitoring tools and thousands more |
| OpenTofu | HCL | A community-maintained, open-source fork of Terraform; nearly identical to use |
| Pulumi | TypeScript, Python, Go, C#… | Many clouds, using a normal programming language |
| AWS CloudFormation / AWS CDK | YAML or JSON / programming languages | AWS only |
| Azure Bicep | Bicep | Azure only |
| Ansible | YAML | Configuring existing servers (installing software, editing files) |
Terraform is the most widely used, and the one you will most often see in job descriptions, so this lesson uses it. In 2023 HashiCorp moved Terraform to a more restrictive “business source” licence, and the community created OpenTofu as an open-source fork. For everything in this lesson, the two work the same way: replace terraform with tofu.
Provisioning vs configuration
Notice the last row of the table. There are two related jobs:
- Provisioning creates the infrastructure itself: “there is a server, in this network, with this firewall”. Terraform’s job.
- Configuration management sets up what runs inside servers: “nginx is installed and running”. Ansible’s job, or cloud-init on first boot, or simply a container image.
Here is a tiny Ansible playbook, so you recognise one when you see it:
- name: Web servers have nginx
hosts: web
become: true # use sudo
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Make sure nginx is running and starts at boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
It is declarative too: “nginx is present”, not “run apt install”. With containers, much of this work has moved into Dockerfiles, so many teams today use Terraform plus container images and need little configuration management.
Terraform concepts
Terraform files end in .tf and are written in HCL, the HashiCorp Configuration Language. The building blocks:
| Concept | What it is | Example |
|---|---|---|
| Provider | A plugin that knows how to talk to one platform’s API | hashicorp/aws, hashicorp/azurerm, cloudflare/cloudflare |
| Resource | Something Terraform creates and manages | aws_instance.web: a server |
| Data source | Something Terraform only reads | data.aws_ami.ubuntu: look up the newest Ubuntu image |
| Variable | An input, so the same code works for different cases | var.region, var.instance_type |
| Output | A value printed after apply, or passed to other code | The server’s public IP |
| State | Terraform’s record of what it created | terraform.tfstate |
Resources refer to each other by type.name.attribute, for example aws_security_group.web.id. Terraform reads those references to work out the right order: the security group must exist before the server that uses it.
Hands-on: your first Terraform project (free, no cloud account)
You can learn the whole Terraform workflow without a cloud account or any cost, using two simple providers: random (generates random values) and local (manages files on your computer). Install Terraform (or OpenTofu) from its official website, create an empty folder, and save this as main.tf:
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
variable "environment" {
type = string
default = "dev"
}
resource "random_pet" "server" {
length = 2
}
resource "local_file" "inventory" {
filename = "${path.module}/inventory-${var.environment}.txt"
content = "web server for ${var.environment}: ${random_pet.server.id}\n"
}
output "server_name" {
value = random_pet.server.id
}
random_pet invents a friendly name such as witty-falcon, the way some clouds name servers. local_file writes a file that uses it. Now the core workflow, the same four commands you will use on real clouds:
1. terraform init
Downloads the providers named in required_providers and prepares the folder. Run it once, and again whenever you add a provider. It also writes a .terraform.lock.hcl file recording exact provider versions: commit it, like package-lock.json.
2. terraform plan
Compares your code with what exists and shows what it would do, without changing anything:
Terraform will perform the following actions:
# local_file.inventory will be created
+ resource "local_file" "inventory" {
+ content = (known after apply)
+ filename = "./inventory-dev.txt"
...
}
# random_pet.server will be created
+ resource "random_pet" "server" {
+ id = (known after apply)
+ length = 2
+ separator = "-"
}
Plan: 2 to add, 0 to change, 0 to destroy.
Learn the symbols, because you will read a lot of plans: + create, ~ update in place, - destroy, and -/+ destroy and re-create (replace).
3. terraform apply
Shows the plan again, asks for confirmation, then does it:
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
server_name = "witty-falcon"
Run terraform apply again and it reports no changes: the real world already matches the code. That is idempotency.
Now change an input and look at the plan:
terraform plan -var="environment=staging"
# local_file.inventory must be replaced
-/+ resource "local_file" "inventory" {
~ filename = "./inventory-dev.txt" -> "./inventory-staging.txt" # forces replacement
...
Plan: 1 to add, 0 to change, 1 to destroy.
Terraform worked out that a file cannot be renamed in place, so it must be replaced. On a real cloud, “forces replacement” on a database or server means deleting and re-creating it, so always read plans carefully before typing yes.
4. terraform destroy
Deletes everything this project manages. On a real cloud, this is how you make sure test environments stop costing money.
State: Terraform’s memory
After apply, a file called terraform.tfstate appears. It maps each resource in your code to the real object it created (for example, aws_instance.web is server i-0abc123...). Terraform needs it to know what it manages and what to change.
State needs care:
- It can contain secrets, such as database passwords, in plain text. Never commit it to Git. Add
*.tfstate,*.tfstate.*and.terraform/to.gitignore. - A team must share one copy. If two people keep their own state files, each Terraform thinks it owns everything and they overwrite each other’s work.
- Two applies at once corrupt it, so state needs locking.
The answer is a remote backend: shared storage with locking, such as an S3 bucket (with use_lockfile), Azure Blob Storage, Google Cloud Storage, or HCP Terraform. The code panel shows an example in a comment.
Note: Never edit the state file by hand. When you do need to change it, for example to rename a resource without re-creating it, use commands such as
terraform state mv, ormovedblocks in your code.
Drift
Drift is when the real world no longer matches the code, usually because someone changed something by hand “just this once”. Every terraform plan checks the real resources first, so drift shows up as an unexpected change in the plan. In the local example, delete inventory-dev.txt and run terraform plan: Terraform notices it is gone and plans to create it again.
Many teams run terraform plan on a schedule purely to detect drift, and treat any unexpected change as something to investigate.
Modules and environments
A module is a reusable folder of Terraform code with inputs and outputs, like a function. A team might write a web-service module once and use it for every service:
module "recipe_api" {
source = "./modules/web-service"
name = "recipe-api"
instance_type = "t3.small"
}
The public Terraform Registry also has thousands of community modules, for example for building a complete VPC.
To run the same code for staging and production, keep a separate state for each, and pass different variables, often from a .tfvars file per environment:
terraform apply -var-file=staging.tfvars
IaC in the pipeline
Infrastructure changes deserve the same pipeline as application code:
pull request opened
├─ terraform fmt -check formatting
├─ terraform validate syntax and references
├─ security scan e.g. Trivy or Checkov: public buckets, open ports...
└─ terraform plan posted as a comment on the PR for reviewers to read
merge to main
└─ terraform apply the reviewed plan, applied by the pipeline (with approval for production)
Reviewers read the plan, not just the code: “this PR will destroy 1 database” is impossible to miss. Only the pipeline applies changes to shared environments, so nothing reaches production without review. For credentials, the pipeline should use short-lived OIDC access to the cloud rather than stored keys, as you will see in Configuration, Environments and Secrets.
A real web server on AWS
The code panel for this lesson is a complete Terraform configuration for a real server on AWS. It:
- looks up the newest official Ubuntu 24.04 image (a data source, owned by Canonical’s account);
- creates a security group allowing HTTP and HTTPS from anywhere and SSH only from your address;
- starts a small EC2 instance, set up on first boot by the cloud-init file from Cloud Computing Basics;
- tags everything with the project name, and prints the server’s IP and an SSH command.
To try it you need an AWS account, the AWS CLI logged in (aws configure, or aws sso login), and an SSH key. Save the code as main.tf. Next to it, save the cloud-init file as cloud-init.yaml.tftpl, changing its key line to:
- ${ssh_public_key}
templatefile() in main.tf fills that in with the contents of your public key file. Then:
terraform init
terraform plan -var="ssh_cidr=$(curl -s https://ifconfig.me)/32"
terraform apply -var="ssh_cidr=$(curl -s https://ifconfig.me)/32"
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
Outputs:
public_ip = "13.233.xx.xx"
ssh_command = "ssh deploy@13.233.xx.xx"
ssh_cidr has no default, so Terraform requires it: the $(curl ...) part fills in your current public IP, so only you can use SSH.
Note: This creates real resources that cost money while they exist, although a
t3.microis one of the cheapest. When you are finished, runterraform destroywith the same-varand check the AWS console to be sure.
Common misunderstandings
“IaC is just scripts that call the cloud”
A script describes steps and usually breaks when run twice. Declarative IaC describes the result, works out the steps, shows you a plan first, and is safe to run again and again.
“With IaC, I must never open the cloud console”
Looking is fine and often useful. Changing things by hand is the problem, because the next terraform apply will either undo your change or be confused by it.
“I can keep the state file in Git”
It may contain secrets, and Git has no locking, so two people applying at once will corrupt it. Use a remote backend with locking.
Quick recap
- Infrastructure as Code describes infrastructure in files, so it is versioned, reviewed, repeatable and rebuildable.
- Declarative tools such as Terraform describe the desired result and are idempotent.
- Terraform uses providers, resources, data sources, variables and outputs, written in HCL.
- The workflow is init, plan, apply, destroy; always read the plan, especially
-/+replacements. - State maps code to real resources: keep it in a remote backend with locking, never in Git.
- Detect drift with plans, reuse code with modules, and run IaC through a reviewed pipeline.
Next, you will learn how to give the same artifact different settings in each environment, safely, in Configuration, Environments and Secrets.
# main.tf: one Ubuntu web server on AWS, described as code.
# terraform init download the AWS provider
# terraform plan preview what will change
# terraform apply make it so (and "terraform destroy" when you are done)
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
# For a team, keep state in shared, locked storage instead of on your laptop:
# backend "s3" {
# bucket = "my-company-terraform-state"
# key = "recipe-api/prod.tfstate"
# region = "ap-south-1"
# use_lockfile = true
# }
}
# ---------- Inputs ----------
variable "region" {
description = "AWS region to create everything in"
type = string
default = "ap-south-1"
}
variable "instance_type" {
description = "Server size"
type = string
default = "t3.micro"
}
variable "ssh_cidr" {
description = "Only this address may use SSH, e.g. 203.0.113.7/32"
type = string
}
variable "public_key_path" {
description = "Your SSH public key, put on the server for the deploy user"
type = string
default = "~/.ssh/id_ed25519.pub"
}
provider "aws" {
region = var.region
default_tags {
tags = {
Project = "recipe-api"
ManagedBy = "terraform"
}
}
}
# ---------- Look up the newest official Ubuntu 24.04 image ----------
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical, the company behind Ubuntu
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}
# ---------- Firewall: web for everyone, SSH for one address ----------
resource "aws_security_group" "web" {
name = "recipe-api-web"
description = "HTTP and HTTPS from anywhere, SSH from one address"
}
resource "aws_vpc_security_group_ingress_rule" "http" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "tcp"
from_port = 80
to_port = 80
}
resource "aws_vpc_security_group_ingress_rule" "https" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "tcp"
from_port = 443
to_port = 443
}
resource "aws_vpc_security_group_ingress_rule" "ssh" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = var.ssh_cidr
ip_protocol = "tcp"
from_port = 22
to_port = 22
}
resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1" # all outgoing traffic (updates, packages)
}
# ---------- The server, set up on first boot by cloud-init ----------
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
ssh_public_key = trimspace(file(pathexpand(var.public_key_path)))
})
tags = {
Name = "recipe-api-web"
}
}
# ---------- Outputs: printed after apply ----------
output "public_ip" {
value = aws_instance.web.public_ip
}
output "ssh_command" {
value = "ssh deploy@${aws_instance.web.public_ip}"
}
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready