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.0 constraint 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 variable blocks declare inputs with a type, a description and a default. var.environment reads one.
  • aws_s3_bucket.reports is the bucket. Its name uses string interpolation (${…}); bucket names are globally unique across all AWS customers, so real names need a distinctive prefix. The tags map labels the resource for cost reports and ownership.
  • aws_s3_bucket_public_access_block blocks 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_versioning keeps older versions of overwritten or deleted objects.
  • aws_s3_bucket.reports.id is 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 after apply.

The workflow: init, plan, apply, destroy

  1. terraform init prepares the working directory: downloads the providers and modules and connects to the state backend. Run it once, and again after changing those.
  2. terraform plan compares the configuration with the state and the real resources, and shows what it would create (+), change in place (~), replace (-/+) or destroy (-). It changes nothing.
  3. terraform apply carries out a plan. Given a saved plan file, it does exactly what was reviewed.
  4. terraform destroy removes 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 plan reads the real resources and shows drift as changes it wants to make. terraform plan -refresh-only shows only what changed outside Terraform, without proposing to undo it.
  • terraform plan -detailed-exitcode exits 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 plan in 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 .tf file. Mark sensitive variables and outputs with sensitive = true so 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 fmt and terraform validate in the pipeline catch style and syntax problems before review.