Infrastructure as Code with Terraform: A Practical Guide (2026)

Khimananda Oli 9 min read Database
Infrastructure as Code with Terraform: A Practical Guide (2026)

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.

write.tf config (HCL)planpreview diffapplyprovision cloudstaterecord of truth
The core Terraform workflow: write declarative HCL, plan to preview the diff, apply to provision, and the state file records what now exists.

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 plan diffs, 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.

desired stateyour .tf config1 instance, t3.smallweb-sg: 22, 80real statestate file + cloud1 instance, t3.microweb-sg: 22, 80terraformplan / applydiff = change instance_type micro to small (only that changes)
Reconciliation in action: Terraform compares your desired configuration to the recorded real state and applies only the difference — nothing else is touched.

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:

  1. terraform init — downloads the declared providers and prepares the backend. Run it once per project and again whenever you change providers or backend config.
  2. 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.
  3. terraform apply — shows the plan again and, after you type yes, makes the changes and updates state.
  4. 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.)

Dev Aapply (holds lock)Dev Bapply (waiting)DynamoDBlock tableS3 bucketshared stateLock is held by Dev A; Dev B is queued until it releases — state never corrupts.
Remote state with locking: the S3 backend shares encrypted state while the DynamoDB lock serializes concurrent applies across the team.

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.

Frequently Asked Questions

Infrastructure as Code with Terraform is the practice of defining cloud resources — servers, networks, firewalls — in declarative HCL files kept in version control. Terraform reads those files and makes the necessary API calls to create, update, or delete resources so reality matches your configuration.

The Terraform CLI is open source and free. You only pay for the cloud resources it provisions, plus optional paid tiers of HCP Terraform (formerly Terraform Cloud) if you want managed state, run pipelines, and team governance features.

Terraform uses HCL, the HashiCorp Configuration Language. It is a declarative language designed to be human-readable: you describe the desired end state, and Terraform determines the order of operations needed to reach it.

terraform plan computes and prints the changes needed to match your configuration without touching anything, acting as a review gate. terraform apply shows that same plan and, after you confirm, actually creates, modifies, or destroys resources and updates the state file.

A provider is a plugin that lets Terraform manage a specific platform's API, such as AWS, Azure, Google Cloud, or Cloudflare. You declare required providers and pin their versions in a terraform block, and terraform init downloads them before you plan or apply.

The state file is Terraform's record of the real resources it manages and their current attributes. Terraform compares it against your configuration to compute diffs. It can contain sensitive values, so it should be stored in a secure remote backend, never committed to Git.

Remote state stores the state file in a shared backend such as an S3 bucket, HCP Terraform, or Azure Blob Storage instead of on your laptop. This lets a whole team work from one authoritative state, keeps secrets out of Git, and enables locking to prevent concurrent corruption.

State locking prevents two people from running terraform apply against the same state at the same time, which could corrupt it. With an S3 backend, a DynamoDB table records a lock so the second apply waits until the first releases it. It is essential for any team using shared state.

Configure the AWS provider, then declare an aws_instance resource with an AMI and instance_type, referencing an aws_security_group for network rules. Run terraform init, plan, then apply. Use a data source to fetch the latest AMI ID rather than hard-coding one that will go stale.

A variable is an input that parameterizes your configuration, letting the same code run in different environments. An output surfaces a resource attribute — such as a server's public IP — after apply, so you can read it or pass it to other tooling.

A module is a reusable folder of Terraform files that accepts inputs and returns outputs, much like a function. You call it from other configurations with different values, avoiding copy-paste. Modules can be sourced locally, from Git, or from the public Terraform Registry.

Run terraform destroy from the project directory. Terraform shows every resource it will remove and asks for confirmation. It is especially useful for tearing down throwaway test environments so you stop paying for them once you are finished.

No. The state file often contains secrets like passwords and private keys, and committing it invites merge conflicts and corruption. Use a remote backend such as S3 or HCP Terraform, and add terraform.tfstate and .tfvars files to your .gitignore.

They solve different problems. Terraform provisions infrastructure — the servers, networks, and firewalls. Ansible configures what runs inside those servers. Many teams use Terraform to build the environment and Ansible or cloud-init to configure it, so the tools complement rather than replace each other.

Set required_version in the terraform block and a version constraint like "~> 5.0" for each provider in required_providers. Pinning ensures every teammate and CI run uses compatible versions, so an unexpected upgrade never introduces breaking changes into your infrastructure.