← Back

2026

Terraform: Infrastructure as Code

Clicking through cloud consoles works once. By the third environment, nobody is sure what is running where. Terraform lets you describe infrastructure in configuration files, review changes before they happen and apply them consistently every time.

The first time you provision cloud infrastructure by clicking through a web console, it works. The second time, for a different environment, you try to remember what you did and make small mistakes. The third time, a colleague does it and makes different small mistakes. By the time you have several environments, nobody is quite sure what is running where. Reproducing the production setup in a new region takes days and involves a lot of educated guessing.

Terraform is the tool most teams reach for when they decide that clicking through consoles is no longer acceptable. It lets you describe your infrastructure in configuration files, review changes before they happen, apply them consistently every time and track everything in version control. The mental shift is the same one that happened when teams started treating application code as something to be reviewed rather than typed directly into production servers.

What infrastructure as code actually means

Infrastructure as code means writing down what your infrastructure should look like in a format that a tool can read, validate and act on. The configuration is the source of truth. The actual resources running in your cloud account should be a reflection of it.

This sounds straightforward but the implications are significant. When the configuration lives in a repository, every change goes through a pull request. The diff shows exactly what will change before anything changes. Reviews happen on infrastructure the same way they happen on code. The history of what was deployed, when it was deployed and the reasoning behind it lives in commit messages rather than in someone's memory.

Terraform takes a declarative approach to this. You describe the end state you want. Terraform works out what actions are needed to get there from wherever things are right now. You do not write a script that says "create a server, then attach a disk, then add a firewall rule." You write a configuration that says "there should be a server with this disk and this firewall rule" and let Terraform figure out the steps.

Worth noting

Declarative does not mean Terraform is magic. It still runs API calls in sequence. Dependencies between resources determine the order. Understanding those dependencies matters when a plan does something unexpected or an apply gets stuck partway through.

Core concepts

Terraform has a small number of building blocks. Everything in a configuration is some combination of these.

Provider

A plugin that knows how to talk to a specific platform. AWS, Azure, Google Cloud, Kubernetes, GitHub and hundreds of others each have a provider. The provider translates your resource definitions into API calls against the target platform. You declare which providers your configuration needs in a required_providers block and Terraform downloads them during init.

Resource

The fundamental unit in Terraform. A resource represents one piece of infrastructure: a virtual machine, a DNS record, a database, a security group. You declare what properties it should have. Terraform figures out how to create it, update it or destroy it through the provider.

State

Terraform records what it has created in a state file. This is how it knows the difference between resources that already exist in the real world and resources that are new. Without state, every plan would look like a fresh deployment. State also stores the IDs that cloud providers assign to resources after creation, which Terraform needs to update or destroy them later.

Data source

A read-only lookup against an external system. A data source fetches existing information that your configuration needs but does not manage itself. You might use one to look up the latest AMI ID from AWS, fetch an existing VPC by tag, or query a secret from Vault. The result is available as an attribute you can reference like any other value.

Module

A directory of Terraform files treated as a single unit. Modules let you package a group of related resources, give them a clear interface through input variables and output values, then call that module from other configurations. The same module can be called multiple times with different inputs to produce multiple environments from a single definition.

Output

A value that a configuration exposes after apply. Outputs are used to extract information from a deployment, such as a load balancer's IP address or a database endpoint, so it can be used elsewhere. When a module is called from a parent configuration, its outputs become attributes on the module object that the parent can reference.

What a configuration looks like

Terraform uses its own configuration language called HCL (HashiCorp Configuration Language). It is designed to be readable by people who are not programmers while being precise enough for a tool to act on reliably. A simple AWS configuration gives a reasonable sense of what writing Terraform feels like.

Example: EC2 instance with a security group

terraform {

required_providers {

aws = {

source = "hashicorp/aws"

version = "~> 5.0"

}

}

}

resource "aws_security_group" "web" {

name = "web-sg"

vpc_id = var.vpc_id

ingress {

from_port = 443

to_port = 443

protocol = "tcp"

cidr_blocks = ["0.0.0.0/0"]

}

}

resource "aws_instance" "web" {

ami = data.aws_ami.ubuntu.id

instance_type = var.instance_type

vpc_security_group_ids = [aws_security_group.web.id]

}

output "public_ip" {

value = aws_instance.web.public_ip

}

The reference aws_security_group.web.id on the instance is how Terraform understands dependencies. Because the instance references the security group, Terraform creates the security group first. The dependency graph is derived from these references automatically. You rarely need to declare dependencies explicitly.

The core workflow

Working with Terraform follows a consistent pattern of three commands. Everything else is configuration. The workflow is the same whether you are managing two resources or two thousand.

terraform init

Downloads the providers your configuration declares into a local .terraform directory. Also initialises the backend if you have configured remote state. Run this once when you start working with a configuration and again whenever you add a new provider or change the backend.

terraform plan

Reads the current state, queries the real infrastructure to find any drift, then computes what changes are needed to reach your declared configuration. The output shows what will be created, changed or destroyed with a plus for additions, a tilde for updates and a minus for deletions. Nothing changes in the real world during plan.

terraform apply

Shows the plan one more time and asks for confirmation before making any changes. Once confirmed, it calls the provider APIs in the correct dependency order. As each resource is created or updated, the state file is updated. A partial apply leaves the state reflecting exactly what succeeded, so the next apply can continue from there.

terraform destroy

The inverse of apply. Terraform reads the state file, works out what it manages and destroys all of it in reverse dependency order. Useful for tearing down ephemeral environments completely. The same dependency tracking that gets creation order right gets deletion order right too.

State and where to keep it

State is what makes Terraform useful across multiple runs. It is also the first thing that causes problems when a team tries to use Terraform together without thinking about it. The default is a local file, which works for one person on one machine. As soon as a second person runs apply, the local files diverge.

Remote backends solve this by storing state in a shared location with locking. When someone runs apply, they acquire a lock on the state file. Anyone else who tries to apply at the same time gets an error until the lock is released. This prevents concurrent applies from producing conflicting changes that result in corrupted state.

Local

The default. State is stored in a terraform.tfstate file in the working directory. Fine for learning but breaks in a team: two people running apply at the same time will corrupt the state file because there is no locking. The file also contains sensitive values in plaintext.

S3 with DynamoDB

The standard choice on AWS. State is stored in an S3 bucket with versioning enabled so you can recover from a bad apply. A DynamoDB table provides state locking, preventing concurrent applies from corrupting the file. Server-side encryption covers the sensitive values at rest.

Terraform Cloud

HashiCorp's hosted backend. State is stored and locked automatically. The UI shows plan output and apply history. Remote runs execute plans on HashiCorp's infrastructure rather than on your local machine. Also provides a private module registry for sharing modules across teams.

Azure Blob Storage / GCS

Equivalent options to S3 for teams on Azure or GCP. Both support state locking natively. The setup mirrors the S3 approach: a storage container with versioning and encryption, a backend configuration block pointing Terraform at it.

Worth noting

The state file contains sensitive values. Passwords, private keys and connection strings that Terraform needs to manage resources end up in state. Treat it accordingly: encrypted at rest, restricted access, never committed to a repository.

Variables and values

Terraform configurations should not have environment-specific values hardcoded. The same configuration should produce a development environment and a production environment by receiving different inputs. Three constructs handle this.

Input variable

variable "instance_type" { ... }

Declares a value that can be set from outside the configuration. Values come from a .tfvars file, environment variables prefixed with TF_VAR_, command-line flags or interactive prompts. Sensitive variables can be marked as such so their values are not printed in plan output.

Local value

locals { name = "..." }

A computed value that exists only within the current module. Useful for avoiding repetition when the same expression appears in multiple places. Locals are evaluated at plan time and do not accept external input.

Output value

output "endpoint" { value = ... }

A value the module exposes to callers. After a successful apply, outputs are printed to the terminal. In a root module they are also readable via terraform output. Child module outputs become attributes on the module call in the parent.

Modules

Every Terraform configuration is technically a module. The one you call directly from the command line is the root module. A child module is any directory of Terraform files called from within a configuration using a module block.

Modules solve the problem of repeating the same group of resources across environments. Instead of maintaining near-identical configurations for staging and production, you write the infrastructure once as a module, define what varies as input variables and call that module twice with different values. Both environments stay in sync because they share the same underlying definition.

Calling a module

module "app_staging" {

source = "./modules/app"

environment = "staging"

instance_type = "t3.small"

instance_count = 1

}

module "app_production" {

source = "./modules/app"

environment = "production"

instance_type = "t3.large"

instance_count = 3

}

The Terraform Registry hosts public modules for common infrastructure patterns. AWS VPC, EKS clusters, RDS instances and many other building blocks have well-maintained public modules that codify best practices. They are a reasonable starting point, though teams eventually write their own modules to express internal conventions that generic public modules cannot anticipate.

Workspaces

Workspaces let you use the same configuration with multiple separate state files. The default workspace is called default. You can create additional workspaces and switch between them. Within the configuration, terraform.workspace returns the current workspace name, which you can use to vary behaviour.

Workspaces are useful for short-lived environments: a feature branch gets its own workspace so a developer can test infrastructure changes without touching the shared staging environment. When the branch is done, destroy the workspace. They work less well as the primary mechanism for separating production from staging, because both environments share the same configuration files and it becomes easy to accidentally apply the wrong workspace. Separate root configurations with shared modules tend to be cleaner for long-lived environment separation.

Where it fits

Terraform sits at the layer below your application. It provisions the infrastructure that everything else runs on: the networks, the compute, the databases, the load balancers, the DNS records. It does not deploy your application code. That responsibility belongs to a deployment tool further up the stack. Terraform creates the cluster. Argo CD keeps the workloads running on it in sync with Git.

In a CI pipeline, Terraform typically runs in two stages. A plan stage runs on every pull request, posting the expected changes as a comment so reviewers can see what will happen before they approve the merge. An apply stage runs after merge, making those changes in the target environment. The pipeline is the mechanism that enforces the review step. Nobody runs apply from a local machine in production.

The argument for adopting it is not that clicking through cloud consoles is wrong as a starting point. It is that what you build by clicking is invisible, unrepeatable and fragile over time. Terraform makes infrastructure legible. Changes are reviewable before they happen. Environments can be rebuilt from scratch when something goes badly wrong. The history of what was built, when it changed and the reasoning behind it lives in the repository alongside everything else. That shift in how infrastructure is managed is the real benefit.