
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Clicking through a cloud console to launch a server feels fast the first time and painful every time after — nobody remembers which checkbox mattered, and staging never quite matches production. Infrastructure as Code with Terraform replaces that guesswork with version-controlled files that describe exactly what your infrastructure should look like, so a single terraform apply rebuilds it identically every time. This guide is practical: you will install Terraform, provision a real AWS EC2 instance and security group, wire up variables, outputs, remote state with locking, and reusable modules. If you are already automating deploys with a GitLab CI/CD pipeline, this is the missing layer underneath it.
terraform init, plan, and apply to create them. Terraform tracks the real world in a state file, compares it to your desired state, and changes only what differs.What is Infrastructure as Code and why use Terraform?
Infrastructure as Code (IaC) is the practice of defining servers, networks, databases, and permissions in text files that live in version control, instead of configuring them by hand. Terraform, maintained by HashiCorp, is the most widely used IaC tool because it is declarative and provider-agnostic: you describe the end state you want, and Terraform figures out the API calls to reach it — across AWS, Azure, Google Cloud, Cloudflare, and hundreds of other providers, all with the same language.
The payoff is concrete:
- Repeatability — the same config produces the same infrastructure in dev, staging, and production.
- Reviewable change — infrastructure edits go through pull requests and
terraform plandiffs, not tribal memory. - Disaster recovery — your environment is a Git repo; you can rebuild a region from scratch.
- Documentation for free — the code is the single source of truth for what exists and why.
One clarification worth making early: Terraform provisions infrastructure (the server, the network, the firewall). Configuration-management tools such as Ansible configure what runs inside it. They complement each other — see the DevOps and cloud services page for how these layers fit together.
How do you install Terraform and configure a provider?
Terraform ships as a single binary. On macOS and Linux the tap or package manager is simplest; on any OS you can download the binary from HashiCorp directly. Verify the install, then confirm you are on a current release (Terraform 1.x is stable and backward-compatible within the 1.x line):
# macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Ubuntu / Debian
wget -O- https://apt.releases.hashicorp.com/gpg | \
gpg --dearmor | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
terraform version A provider is a plugin that teaches Terraform how to talk to a specific platform's API. You declare which providers a project needs — and pin their versions — in a terraform block, then configure each one. Here is the AWS provider, pinned so a colleague running init next month gets the same plugin:
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
} The ~> 5.0 constraint allows any 5.x version but blocks a breaking 6.0 upgrade until you opt in. Never hard-code AWS keys in the provider block — export AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as environment variables, or use an AWS profile, so credentials never land in Git.
How do you provision an EC2 instance and security group with Terraform?
Here is a real, minimal setup: a security group that allows SSH and HTTP, and an EC2 instance that uses it. Resources reference each other by type.name.attribute, which is how Terraform builds its dependency graph and knows the security group must exist before the instance.
# main.tf
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow SSH and HTTP"
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
ingress {
description = "HTTP"
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 = "web-sg" }
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
tags = { Name = "web-server" }
} Notice the data "aws_ami" block — a data source reads existing information (here, the latest Ubuntu 22.04 image ID) without creating anything, so you never hard-code an AMI that goes stale. Restricting var.ssh_cidr to your own IP rather than 0.0.0.0/0 is a basic but important hardening step.
How do variables and outputs make Terraform reusable?
Variables pull hard-coded values out of your resources so the same config runs in multiple environments. Outputs surface useful attributes — like the new server's public IP — after an apply. Define them in their own files for clarity:
# variables.tf
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "ap-south-1"
}
variable "instance_type" {
description = "EC2 instance size"
type = string
default = "t3.micro"
}
variable "ssh_cidr" {
description = "CIDR allowed to reach SSH"
type = string
}
# outputs.tf
output "public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
} Because ssh_cidr has no default, Terraform prompts for it — or you supply it in a terraform.tfvars file (kept out of Git) or on the command line with -var. This is how one codebase deploys a t3.micro in dev and a larger instance in production without editing the resource blocks. After apply, the public_ip output prints so you can immediately SSH in or hand it to the next tool.
How do you run terraform init, plan, apply, and destroy?
These four commands are the entire day-to-day loop. Run them from the directory holding your .tf files:
terraform init— downloads the declared providers and prepares the backend. Run it once per project and again whenever you change providers or backend config.terraform plan— computes the diff between your config and reality and prints exactly what it will create, change, or destroy. Nothing is applied. This is your review gate.terraform apply— shows the plan again and, after you typeyes, makes the changes and updates state.terraform destroy— tears down everything the config manages. Invaluable for spinning up a test environment and removing it to stop the billing clock.
terraform init
terraform fmt # auto-format your .tf files
terraform validate # check syntax and internal consistency
terraform plan -var="ssh_cidr=203.0.113.4/32"
terraform apply -var="ssh_cidr=203.0.113.4/32"
# when you are done with a throwaway environment
terraform destroy -var="ssh_cidr=203.0.113.4/32" Always read the plan before approving. A plan that reports it will destroy a resource you meant to merely change is the single most common way people accidentally delete a database — the diff is telling you exactly that before it happens.
How do remote state and state locking keep a team safe?
By default Terraform writes state to a local terraform.tfstate file. That works solo, but it is a disaster for a team: the file contains sensitive values, it is not shared, and two people running apply at once can corrupt it. The fix is a remote backend with state locking. On AWS the classic setup is an S3 bucket for the state plus a DynamoDB table for a lock:
# backend.tf
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "web/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
} With this in place, state lives encrypted in S3 and is shared by the whole team. When anyone runs plan or apply, Terraform writes a lock record to DynamoDB; a second person running apply at the same moment is blocked until the first finishes. That prevents the race condition that silently corrupts local state. (On newer AWS provider versions you can also use S3-native locking with use_lockfile = true, but the DynamoDB pattern is the most widely documented and battle-tested.)
How do modules help you reuse Terraform code?
A module is a folder of .tf files you can call from elsewhere with different inputs — the same idea as a function. Instead of copy-pasting the security-group-and-instance pair for every environment, wrap it in a module and call it once per environment. A module exposes inputs (its own variables.tf) and returns outputs, hiding the internals:
# modules/web-server/ (the reusable component)
# main.tf, variables.tf, outputs.tf
# environments/production/main.tf (the caller)
module "web" {
source = "../../modules/web-server"
instance_type = "t3.small"
ssh_cidr = "203.0.113.4/32"
}
output "prod_ip" {
value = module.web.public_ip
} You can source modules locally, from a Git repository, or from the public Terraform Registry — which hosts thousands of maintained modules for common patterns like a full AWS VPC. Well-factored modules are how small teams manage large estates without repetition. If you want a review of how your current infrastructure is organized, browse the DevOps case studies for real examples of module structure in production.
Conclusion
Infrastructure as Code with Terraform turns your cloud from a pile of hand-clicked settings into a reviewable, repeatable, version-controlled codebase: declare the desired state in HCL, let plan show you the diff, and let apply reconcile reality to match. Start small — put one EC2 instance and its security group under Terraform this week, then add remote state and modules as your team grows. If you would like this designed, hardened, and wired into your delivery pipeline, get in touch or explore the cloud and DevOps services to see how a proper IaC foundation is built.