Infrastructure as Code
Describing cloud resources in files: declarative templates, Terraform and native tools, state, plans and reviews.
Why describe infrastructure in files?
Infrastructure as code (IaC) is introduced on the Network Automation page: the desired state of infrastructure is written in text files that a tool applies. In the cloud this idea is even more natural, because every resource — a virtual network, a virtual machine, a storage bucket, a firewall rule — is created through an API (application programming interface) anyway. Clicking through a web console calls the same API; IaC just calls it from a file you can keep.
Describing infrastructure in files gives you:
- Repeatability: the same files build a development, a test and a production environment that really are the same. A console-built environment is only as consistent as the person clicking.
- Review: a change is a diff that a colleague can read before it happens, exactly like application code.
- History: in version control, every commit records who changed what, when and why, and an older version can be restored.
- Documentation that cannot go stale: the files describe what exists, because they are what created it.
- Recovery: after a disaster, or in a new region, the same code rebuilds the environment in minutes instead of days.
The price is a learning curve, tooling to maintain, and the discipline to stop making manual changes. Teams that keep "just quickly fixing it in the console" lose most of the benefit.
This page is the cloud overview; Infrastructure as Code & Automation goes deeper into Terraform and OpenTofu, Ansible, secrets, testing and pipelines.
Declarative versus imperative
There are two ways to tell a machine what to build. An imperative script lists the steps: create a network, then create a subnet in it, then start a server. Run it twice and you may get two networks. A declarative configuration describes only the end state — "there is one network with this address range, containing one subnet" — and the tool compares that with what exists and works out the steps: create what is missing, change what differs, remove what should no longer exist. Running it twice changes nothing the second time; that property is called idempotence.
| Imperative | Declarative | |
|---|---|---|
| You write | The steps (how) | The end state (what) |
| Running it twice | Can create duplicates or fail | Second run changes nothing |
| Knows what exists | Only if you code the checks | Yes: compares desired with actual state |
| Examples | A shell script of command-line interface (CLI) commands, software development kit (SDK) code | Terraform, CloudFormation, Bicep, Kubernetes manifests |
The idea is the same as declarative programming in general. Most IaC today is declarative; imperative scripts are still fine for one-off tasks and for glue around the declarative tool.
Terraform
Terraform
is a declarative IaC tool from HashiCorp. You write configuration in
HCL (HashiCorp Configuration Language) in files ending in
.tf; the terraform command-line program
compares it with reality and calls the cloud APIs. It is popular
because one tool and one language cover all major clouds — Amazon
Web Services (AWS), Microsoft Azure and Google Cloud — and hundreds
of other services (Domain Name System providers, monitoring tools,
GitHub itself).
Providers, resources, variables and outputs
-
A provider is a plugin that knows one platform's
API:
hashicorp/aws,hashicorp/azurerm,hashicorp/google. Terraform downloads it from the Terraform Registry. -
A resource is one thing to manage, such as a
bucket or a virtual network. Its type (
aws_s3_bucket) comes from the provider; its local name (reports) is yours. - A variable is an input, so one configuration can serve several environments.
- An output is a value Terraform prints after a run and that other configurations or scripts can read, such as a generated name or address.
A small example
This configuration creates a private storage bucket in Amazon S3 (Simple Storage Service), with tags and versioning. The same pattern works for an Azure storage account or a Google Cloud Storage bucket with the matching provider.
terraform {
required_version = ">= 1.11"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
}
variable "region" {
type = string
description = "AWS Region to create the resources in"
default = "eu-west-1"
}
variable "environment" {
type = string
description = "Environment name: dev, test or prod"
default = "dev"
}
resource "aws_s3_bucket" "reports" {
bucket = "example-corp-reports-${var.environment}"
tags = {
Environment = var.environment
Owner = "platform-team"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_public_access_block" "reports" {
bucket = aws_s3_bucket.reports.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "reports" {
bucket = aws_s3_bucket.reports.id
versioning_configuration {
status = "Enabled"
}
}
output "bucket_name" {
description = "Name of the reports bucket"
value = aws_s3_bucket.reports.bucket
}
Block by block:
-
terraform { … }sets requirements. The~> 6.0constraint allows any 6.x version of the AWS provider but not 7.0, so a major upgrade with breaking changes never arrives by surprise. The exact versions chosen are recorded in.terraform.lock.hcl, which you do commit. -
provider "aws"configures the provider. There are no access keys in it: Terraform picks up credentials from the environment (a signed-in CLI session, a role on the machine, or the pipeline's short-lived credentials). -
The two
variableblocks declare inputs with a type, a description and a default.var.environmentreads one. -
aws_s3_bucket.reportsis the bucket. Its name uses string interpolation (${…}); bucket names are globally unique across all AWS customers, so real names need a distinctive prefix. Thetagsmap labels the resource for cost reports and ownership. -
aws_s3_bucket_public_access_blockblocks every form of public access. New buckets are private by default at the time of writing, but saying so in code makes the intent explicit and reviewable. -
aws_s3_bucket_versioningkeeps older versions of overwritten or deleted objects. -
aws_s3_bucket.reports.idis a reference: it tells Terraform that these two resources depend on the bucket, so it creates the bucket first. You never write the order yourself. -
output "bucket_name"prints the final name afterapply.
The workflow: init, plan, apply, destroy
-
terraform initprepares the working directory: downloads the providers and modules and connects to the state backend. Run it once, and again after changing those. -
terraform plancompares the configuration with the state and the real resources, and shows what it would create (+), change in place (~), replace (-/+) or destroy (-). It changes nothing. -
terraform applycarries out a plan. Given a saved plan file, it does exactly what was reviewed. -
terraform destroyremoves everything this configuration manages. Useful for temporary test environments; dangerous anywhere else.
This session needs an AWS account, so it is illustrative and shortened; the Infrastructure as Code topic shows recorded OpenTofu output.
$ terraform init
$ terraform plan -out=tfplan
$ terraform apply tfplan
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v6.x...
Terraform has been successfully initialized!
Terraform will perform the following actions:
# aws_s3_bucket.reports will be created
+ resource "aws_s3_bucket" "reports" {
+ arn = (known after apply)
+ bucket = "example-corp-reports-dev"
+ id = (known after apply)
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Owner" = "platform-team"
}
}
# aws_s3_bucket_public_access_block.reports will be created
# aws_s3_bucket_versioning.reports will be created
Plan: 3 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ bucket_name = "example-corp-reports-dev"
Saved the plan to: tfplan
aws_s3_bucket.reports: Creating...
aws_s3_bucket.reports: Creation complete after 2s [id=example-corp-reports-dev]
aws_s3_bucket_versioning.reports: Creating...
aws_s3_bucket_public_access_block.reports: Creating...
aws_s3_bucket_public_access_block.reports: Creation complete after 1s
aws_s3_bucket_versioning.reports: Creation complete after 2s
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
bucket_name = "example-corp-reports-dev"
Read the summary line of every plan before you approve it. A plan
that says 1 to destroy when you only meant to change a
tag is the moment to stop: some changes (such as renaming a
database) can only be made by replacing the resource, which deletes
the old one and its data.
How a plan is worked out
At its core a plan is a comparison of two descriptions: what the code says should exist, and what does exist. This small program does the same with two dictionaries:
desired = {
"bucket.reports": {"versioning": "Enabled", "tags": "env=prod"},
"bucket.logs": {"versioning": "Enabled", "tags": "env=prod"},
"network.main": {"cidr": "10.0.0.0/16"},
}
actual = {
"bucket.reports": {"versioning": "Suspended", "tags": "env=prod"},
"network.main": {"cidr": "10.0.0.0/16"},
"bucket.old-exports": {"versioning": "Enabled", "tags": "env=test"},
}
def make_plan(desired, actual):
"""Compare desired with actual state; return (action, name, changes)."""
plan = []
for name in sorted(desired.keys() | actual.keys()):
want, have = desired.get(name), actual.get(name)
if have is None:
plan.append(("+", name, []))
elif want is None:
plan.append(("-", name, []))
elif want != have:
changes = [
f"{key}: {have.get(key)!r} -> {want.get(key)!r}"
for key in sorted(want.keys() | have.keys())
if want.get(key) != have.get(key)
]
plan.append(("~", name, changes))
return plan
words = {"+": "created", "~": "updated in place", "-": "destroyed"}
plan = make_plan(desired, actual)
for action, name, changes in plan:
print(f"{action} {name} will be {words[action]}")
for change in changes:
print(f" {change}")
count = {a: sum(1 for p in plan if p[0] == a) for a in words}
print(f"Plan: {count['+']} to add, {count['~']} to change, {count['-']} to destroy.")
+ bucket.logs will be created
- bucket.old-exports will be destroyed
~ bucket.reports will be updated in place
versioning: 'Suspended' -> 'Enabled'
Plan: 1 to add, 1 to change, 1 to destroy.
network.main matches, so it does not appear at all:
that is idempotence. Terraform adds a dependency graph (so the
bucket is created before its settings), provider knowledge about
which changes need a replacement, and the state file to know which
real resources belong to this code.
State
Terraform state is
a JSON (JavaScript Object Notation) file
(terraform.tfstate) in which Terraform records which
real resource belongs to which block in the code — for example that
aws_s3_bucket.reports is the bucket named
example-corp-reports-dev — together with its last known
attributes. Without state, Terraform could not tell a bucket it
created from one someone else created, and would not know what to
destroy when you delete a block.
Remote state and locking
By default the state is a local file, which only works for one person on one machine. Teams use a remote backend such as an S3 bucket, an Azure Storage blob container, a Google Cloud Storage bucket or HCP Terraform (HashiCorp's hosted service; HCP stands for HashiCorp Cloud Platform). A good backend also locks the state during a run, so two people cannot apply at the same time and corrupt it.
terraform {
backend "s3" {
bucket = "example-corp-terraform-state"
key = "reports/dev.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}
encrypt turns on server-side encryption of the state
object; use_lockfile enables locking with a lock file
in the same bucket (added in Terraform 1.10, generally available
since 1.11, which also deprecated the DynamoDB table that older
setups used for this). The state bucket itself should be private,
versioned and readable by as few people as possible.
Modules
A Terraform module is a folder of Terraform files used as a reusable building block, with its own variables as inputs and outputs as results. Every configuration is technically a module (the root module); it becomes reusable when other configurations call it:
module "reports_bucket" {
source = "./modules/private-bucket"
name = "example-corp-reports-${var.environment}"
environment = var.environment
}
module "invoices_bucket" {
source = "./modules/private-bucket"
name = "example-corp-invoices-${var.environment}"
environment = var.environment
}
The module in ./modules/private-bucket would contain
the bucket, public access block and versioning resources from the
example above. Every bucket built with it is private and versioned
without anyone having to remember. Modules can also come from the
public Terraform Registry or a Git repository; pin those to a
version (version = "…" or a Git tag) and read them
before use, because a module can create anything.
Drift and how to detect it
Configuration drift is
the difference that grows between the code and the real
infrastructure when changes are made outside the code: a firewall
rule added in the console during an incident, a setting changed by a
script, a resource deleted by hand. Drift makes the next
apply unpredictable — it may quietly undo the emergency
fix — and makes the code an unreliable description.
-
terraform planreads the real resources and shows drift as changes it wants to make.terraform plan -refresh-onlyshows only what changed outside Terraform, without proposing to undo it. -
terraform plan -detailed-exitcodeexits with code 0 when there is nothing to do and 2 when there are changes, so a scheduled pipeline job can alert on drift every night. A complete nightly drift job is on IaC in Pipelines. - Native tools have the same feature: CloudFormation drift detection, and the what-if operation for Bicep.
When drift is found, decide which side is right: either apply the code (undo the manual change) or update the code to match reality (keep it). The long-term fix is to limit write access in the console, so the pipeline is the normal way to change things.
Native tools and OpenTofu
Each large provider has its own declarative IaC service. They only manage that provider's resources, but need no separate state storage: the provider keeps track of the deployment itself.
| Provider | Native tool | Language | Unit of deployment |
|---|---|---|---|
| AWS | AWS CloudFormation | JSON or YAML templates | Stack; previews with change sets |
| Azure | Azure Resource Manager (ARM) templates and Bicep | JSON (ARM); Bicep, a more readable language that compiles to ARM JSON | Deployment to a resource group or subscription; previews with what-if |
| Google Cloud | Infrastructure Manager (successor to Deployment Manager) | Terraform configurations | Deployment; previews |
Google ended support for its older Deployment Manager on 31 March 2026 and will shut it down in 2027; its successor, Infrastructure Manager, runs Terraform for you. There are also tools that let you write infrastructure in a general-purpose language — the AWS CDK (Cloud Development Kit) and Pulumi, for example — which still produce a declarative description underneath (compared on Other IaC Tools).
OpenTofu
In August 2023 HashiCorp moved Terraform from an open-source license
(Mozilla Public License 2.0) to the Business Source License, which
restricts some commercial use. In response, a community fork called
OpenTofu was started under the Linux Foundation,
keeping the open-source license; it is now a CNCF (Cloud Native
Computing Foundation) project. Its command is tofu, it
reads the same HCL files, and for most configurations it is a
drop-in replacement, although the two projects add different new
features over time. For learning, what you know about one applies to
the other.
Good habits
- Small changes. A plan with three changes is reviewable; a plan with three hundred is approved on trust.
-
Review the plan, not only the code. Run
planin the pipeline for every pull request and post the output for reviewers; apply only the reviewed plan after merging (see DevOps and CI/CD). -
No secrets in code. Passwords and keys come from
a secrets manager or the pipeline's secret store, and credentials
from roles and short-lived tokens, never from access keys in a
.tffile. Mark sensitive variables and outputs withsensitive = trueso they are hidden in output (they are still stored in state). Terraform 1.10+ ephemeral values and 1.11+ write-only arguments can keep a secret out of state altogether. -
Pin versions. Terraform itself
(
required_version), providers (~> 6.0, plus the committed lock file) and modules. - Separate environments. Use a separate state (and ideally separate accounts or subscriptions) for dev, test and production, so a mistake in one cannot touch the other.
- Least privilege for the pipeline. The identity that applies Terraform needs broad rights, so scope it per environment (separate roles for dev and prod), give it short-lived credentials through OpenID Connect (OIDC) federation rather than stored keys, run plan with a read-only role, and give humans read-only console access.
-
Format and validate.
terraform fmtandterraform validatein the pipeline catch style and syntax problems before review.