Every cloud resource you have ever clicked into existence in a web console (a VM, a load balancer, a DNS record) can also be described in a text file and created by running one command. The file lives in git, gets reviewed in a pull request, and reproduces the same setup on a second account without anyone remembering which nine settings they changed by hand. That is what Terraform does.

What Terraform actually does

Terraform is an open-source tool from HashiCorp that turns infrastructure into configuration files, written in HCL (HashiCorp Configuration Language), and applies them against a cloud provider’s API. You write a resource block describing the S3 bucket, EC2 instance, or Cloudflare DNS record you want, run terraform apply, and Terraform works out the API calls needed to make that resource exist — then keeps track of it, so a second apply only changes what actually changed.

The model is declarative, not procedural: you describe the end state, not the steps to reach it. You don’t write “create a bucket, then set its policy, then enable versioning.” You write what the bucket should look like, and Terraform works out the order of operations, including what has to exist first when one resource depends on another.

Terraform itself knows nothing about AWS, Azure, or Cloudflare. That knowledge lives in providers: plugins that translate HCL resource blocks into calls against a specific API. The Terraform Registry lists thousands of them, official and community-maintained, covering everything from the big three clouds to GitHub repository settings and Datadog monitors. The same mechanism creates a managed Kubernetes cluster (google_container_cluster, aws_eks_cluster) or a single DNS record in front of a CDN. If a service has an API, there is a decent chance someone has already written a provider for it.

How terraform plan and apply work

A minimal working directory has at least two files.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# terraform.tf — which providers this configuration needs
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  required_version = ">= 1.5"
}

provider "aws" {
  region = "eu-west-1"
}
1
2
3
4
5
6
7
8
9
# main.tf — the actual resource
resource "aws_s3_bucket" "reports" {
  bucket = "acme-monthly-reports"

  tags = {
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

Four commands cover the everyday loop:

1
2
3
4
terraform init     # downloads the aws provider plugin, sets up the backend
terraform plan     # shows what would change, without touching anything
terraform apply    # asks for confirmation, then makes the API calls
terraform destroy  # tears down everything this configuration manages

terraform init reads required_providers and downloads the matching plugin binaries into .terraform/. You run it once per directory, and again whenever you add a provider or a module.

terraform plan is the command you will actually run most. It compares your .tf files, the recorded state, and the real infrastructure as the provider’s API reports it, then prints a diff: resources to create (+), change in place (~), or destroy (-). Nothing happens yet. That is what makes Terraform safe to point at production — you read the plan before anything is touched.

terraform apply re-runs the plan, shows it again, and waits for you to type yes before calling the provider APIs in dependency order. In CI you pass -auto-approve or, better, apply a plan file you already reviewed: terraform apply tfplan. The same pipeline that runs Build and Push a Docker Image with GitHub Actions on every merge can run terraform plan on a pull request and terraform apply once it merges to main.

What the state file is for

After the first apply, Terraform writes terraform.tfstate — a JSON file mapping every resource in your config to the real object it created, ID and all. This is not optional bookkeeping. It is how plan knows what already exists without re-scanning your entire AWS account on every run, and how Terraform connects aws_s3_bucket.reports in your code to bucket acme-monthly-reports-a8f3 in reality.

Two consequences follow directly.

State can contain secrets. A database password set as a resource argument ends up in plaintext in the state file, because Terraform needs the full resource attributes to detect drift. Never commit terraform.tfstate to git.

State needs to be shared and locked. The default is a local file, which works alone and breaks the moment a second person runs apply from their own laptop: now there are two sources of truth. The fix is a remote backend, with state stored in S3, Azure Blob, GCS, or Terraform Cloud, and locking so two applies cannot race each other.

1
2
3
4
5
6
7
8
9
terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "reports/terraform.tfstate"
    region       = "eu-west-1"
    use_lockfile = true
    encrypt      = true
  }
}

use_lockfile is the S3 backend’s native locking, generally available since Terraform 1.11 — it writes a lock file straight to the bucket, so a separate DynamoDB table for locking is no longer needed on current versions.

Once teammates point at the same backend, everyone’s plan reflects everyone else’s last apply.

How to reuse a configuration with variables and modules

Hardcoding eu-west-1 and a bucket name works for a five-line example, not for a real environment. Terraform separates the shape of the infrastructure from the values that change per environment.

1
2
3
4
5
6
7
8
9
# variables.tf
variable "environment" {
  type    = string
  default = "staging"
}

variable "bucket_name" {
  type = string
}
1
2
3
4
5
6
7
8
# main.tf, referencing the variable
resource "aws_s3_bucket" "reports" {
  bucket = var.bucket_name

  tags = {
    Environment = var.environment
  }
}
1
terraform apply -var="bucket_name=acme-prod-reports" -var="environment=production"

Or put the values in a terraform.tfvars file so you don’t retype flags every run. outputs.tf does the reverse. It surfaces a value Terraform computed, such as an instance’s public IP or a generated ARN, so another tool or another Terraform configuration can consume it:

1
2
3
output "bucket_arn" {
  value = aws_s3_bucket.reports.arn
}

Once a set of resources repeats across projects (a standard VPC, a standard web-app stack), wrap it in a module: a directory of .tf files referenced with a source argument, taking inputs and returning outputs like a function. Most teams end up with a handful of internal modules and a lot of thin configurations that call them with different variables.

When Terraform is the wrong tool

Terraform provisions resources; it does not configure what runs on them. It will create an EC2 instance, but installing packages, managing users, and keeping config in sync on that instance is a different job — traditionally Ansible’s, or a cloud-init script, or a container image the instance pulls and runs. Pushing application config through Terraform is the wrong direction. Creating the VPC through Ansible is the other wrong direction. Most real setups use both, and Terraform hands the instance’s IP straight to Ansible’s inventory via an output.

For a bucket you will delete in an hour, or a one-off test VM, the state file and the write-plan-apply cycle cost more than they save. The AWS CLI or the console is faster for something you don’t intend to keep or reproduce.

Terraform, OpenTofu, and Pulumi

Terraform is no longer the only tool that reads HCL. HashiCorp moved it off the open-source MPL license to the Business Source License in 2023, and the Linux Foundation now maintains OpenTofu, a fork that kept the MPL license and stayed compatible with Terraform’s HCL syntax and state format. Switching later is a configuration change, not a rewrite.

Pulumi goes the other way: instead of HCL you write infrastructure in TypeScript, Python, or Go. That matters if your team wants real control flow and unit tests around infrastructure logic rather than HCL’s narrower expressions.

How to try Terraform without a cloud account

Terraform’s safety net is plan, and you can exercise the whole loop for free before pointing it at anything billable. The random and local providers need no cloud credentials at all.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.0"
    }
  }
}

resource "random_pet" "name" {
  length = 2
}

output "generated_name" {
  value = random_pet.name.id
}

Run terraform init and terraform apply in an empty directory and you get a real state file and a real managed resource, with nothing to pay for. Do that once, read the plan output line by line until the +, ~, and - markers mean something to you, then point the same four commands at a provider that charges by the hour.