paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

August 02, 2024

Terraform Explained: What It Is, Why It Matters, and How (and When) to Use It

A practical, engineering-first guide to infrastructure as code with Terraform - from core concepts to production trade-offs

Introduction

Every production system eventually runs into the same problem: infrastructure grows faster than anyone's ability to track it by hand. A handful of manually-created cloud resources is manageable. A hundred resources spread across three environments, several teams, and multiple cloud accounts is not - not without a system for describing, versioning, and reproducing that infrastructure reliably. This is the problem that infrastructure as code (IaC) was built to solve, and Terraform, created by HashiCorp and first released in 2014, has become one of the most widely adopted tools in that space.

This article is a practical walkthrough of Terraform aimed at engineers who want to understand not just the syntax but the reasoning behind it: what problem it solves, how its core mechanics - providers, state, the dependency graph, and the plan/apply workflow - actually work, and where it fits (and doesn't fit) in a modern engineering organization. Along the way we'll look at real configuration patterns, discuss the trade-offs teams run into at scale, and cover the licensing shift that reshaped the Terraform ecosystem in 2023.

Context: The Problem Infrastructure as Code Solves

Before tools like Terraform existed, infrastructure was largely managed through a mix of manual console clicks, ad-hoc shell scripts, and internal wikis documenting "how we set up a new environment." This approach has a name in retrospect: it's sometimes called "ClickOps," and it doesn't scale. Manual provisioning is slow, inconsistent between environments, difficult to audit, and nearly impossible to reproduce exactly. If a production database configuration diverges slightly from staging because someone changed a setting through a web console six months ago and forgot to document it, the resulting bugs can be extraordinarily hard to trace.

Configuration management tools like Puppet, Chef, and Ansible partially addressed this by scripting the configuration of servers, but they were originally designed for a world of long-lived, mutable machines - not for provisioning the cloud resources themselves (VPCs, load balancers, managed databases, IAM policies) across providers like AWS, Azure, and Google Cloud. Cloud vendors responded with their own native IaC tools - AWS CloudFormation, Azure Resource Manager templates, Google Cloud Deployment Manager - but these are single-cloud by design, which becomes a real constraint the moment an organization uses more than one provider or needs to manage adjacent tools like GitHub, Datadog, or Cloudflare from the same workflow.

Terraform's core insight was to separate the declarative description of desired infrastructure state from the provider-specific logic needed to create it. A single Terraform configuration can provision an AWS VPC, a Cloudflare DNS record, and a Datadog monitor in the same apply, because each of these is handled by a plugin - a "provider" - that implements a common interface against the provider's API. This is the foundational reason Terraform is described as multi-cloud and provider-agnostic rather than tied to one vendor's ecosystem.

How Terraform Works Under the Hood

At its core, Terraform is built around four concepts: configuration files written in HashiCorp Configuration Language (HCL), a state file that records what Terraform believes exists in the real world, providers that translate configuration into API calls, and a dependency graph that determines execution order.

HCL is a declarative, JSON-superset language designed to be both human-readable and machine-parseable. A resource block describes a piece of infrastructure you want to exist; a data block reads information about infrastructure that already exists but isn't managed by this configuration; variable and output blocks define the interface of a module. Crucially, HCL describes desired end state, not a sequence of imperative steps. You don't tell Terraform "create a VPC, then create a subnet inside it" - you describe both resources and let Terraform infer the ordering from references between them.

That inference happens through the dependency graph. When one resource block references an attribute of another (for example, a subnet referencing a VPC's ID), Terraform records an implicit dependency edge. It then builds a directed acyclic graph (DAG) of every resource in the configuration and walks it to determine which resources can be created, updated, or destroyed in parallel, and which must wait on others. This graph-based execution model is why Terraform can safely parallelize the creation of dozens or hundreds of unrelated resources while still respecting hard dependencies - and it's also why circular references between resources produce a graph error rather than being silently resolved.

State is the part of Terraform that trips up newcomers most often, and it deserves careful attention. The state file (terraform.tfstate, in JSON format) is Terraform's record of the real-world resources it manages and the metadata needed to map them back to configuration blocks - cloud resource IDs, computed attributes, and so on. Every terraform plan works by comparing three things: the current configuration, the last known state, and (via provider API calls) the actual live infrastructure. The diff between these produces the plan - a proposed set of creates, updates, deletes, or in-place modifications - which is shown to the operator before anything is executed. This plan/apply separation, where changes are always previewed before they're made, is one of Terraform's most valuable safety properties and a major reason teams trust it enough to run it against production.

Practical Implementation: Configuration Patterns That Matter

Understanding the concepts matters less than seeing how they compose in a real configuration. Consider a minimal but realistic setup: an AWS S3 bucket for application logs, configured with versioning and a lifecycle rule, defined as a reusable module.

# modules/log_bucket/main.tf
variable "bucket_name" {
  type        = string
  description = "Globally unique name for the log bucket"
}

variable "retention_days" {
  type    = number
  default = 90
}

resource "aws_s3_bucket" "logs" {
  bucket = var.bucket_name

  tags = {
    ManagedBy = "terraform"
    Purpose   = "application-logs"
  }
}

resource "aws_s3_bucket_versioning" "logs" {
  bucket = aws_s3_bucket.logs.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "logs" {
  bucket = aws_s3_bucket.logs.id

  rule {
    id     = "expire-old-logs"
    status = "Enabled"

    expiration {
      days = var.retention_days
    }
  }
}

output "bucket_arn" {
  value = aws_s3_bucket.logs.arn
}

This module is then consumed by an environment-specific root configuration, which supplies the variables and wires the module's output into other resources - for instance, an IAM policy granting a service role write access to that bucket's ARN. This separation between reusable modules and thin environment configurations is the pattern most production Terraform codebases converge on, because it lets teams promote a tested module from staging to production without rewriting logic, only variables.

State should almost never live on a local disk in a team setting, because concurrent applies from two engineers against the same local state file will corrupt it. Terraform supports remote backends - Terraform Cloud/HCP Terraform, or self-managed options like an S3 bucket paired with a DynamoDB table for state locking - specifically to solve this. A typical backend configuration looks like:

terraform {
  backend "s3" {
    bucket         = "acme-terraform-state"
    key            = "networking/prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

The DynamoDB table here isn't decorative - it implements a distributed lock so that a second terraform apply started while another is in progress fails fast instead of racing against the first and corrupting state. For teams that prefer a general-purpose programming language over HCL, HashiCorp also maintains Terraform CDK (cdktf), which lets you define the same resource graph in TypeScript, Python, Go, Java, or C#, and synthesizes it down to the same Terraform JSON configuration format under the hood:

import { Construct } from "constructs";
import { App, TerraformStack } from "cdktf";
import { S3Bucket } from "@cdktf/provider-aws/lib/s3-bucket";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";

class LogBucketStack extends TerraformStack {
  constructor(scope: Construct, id: string, retentionDays: number) {
    super(scope, id);

    new AwsProvider(this, "aws", { region: "us-east-1" });

    new S3Bucket(this, "logs", {
      bucket: `acme-app-logs-${id}`,
      tags: { ManagedBy: "cdktf", Purpose: "application-logs" },
    });
  }
}

const app = new App();
new LogBucketStack(app, "staging", 30);
new LogBucketStack(app, "production", 90);
app.synth();

This is a legitimate escape hatch for teams with strong preferences for type-checked, testable configuration code, but it's worth noting explicitly: CDKTF still produces Terraform's own state and still runs terraform plan/apply underneath, so all of the state and locking considerations above still apply.

Trade-offs and Common Pitfalls

Terraform's declarative model is also the source of its sharpest pitfalls, and the most consequential one is state drift. If someone modifies a Terraform-managed resource outside of Terraform - through the cloud console, a CLI command, or another automation tool - the state file no longer reflects reality. The next plan will either try to "correct" the manual change (potentially undoing something intentional) or, worse, produce a confusing diff that obscures a genuinely dangerous change buried among noise. Terraform provides terraform import and, more recently, import blocks and terraform plan -generate-config-out to bring unmanaged resources under management, but there's no substitute for the organizational discipline of treating the Terraform configuration as the only path to change.

A second, more structural trade-off is the tension between blast radius and modularity. A single Terraform state file covering an entire account's networking, compute, and data layer means one careless apply can touch everything at once - but splitting state too aggressively into many small configurations creates its own cost: cross-stack references become brittle, typically implemented via remote state data sources or hardcoded outputs, and a simple change can require applies across several repositories in the right order. Most mature Terraform setups land somewhere in the middle, splitting state along ownership and blast-radius boundaries (networking, IAM, per-service infrastructure) rather than by resource type or environment size alone.

It's also worth being direct about the 2023 licensing change, since it materially affects tooling decisions today: HashiCorp moved Terraform from the open-source Mozilla Public License (MPL 2.0) to the Business Source License (BUSL), restricting certain competitive commercial uses. In response, a group of companies and contributors forked the last MPL-licensed version of Terraform into OpenTofu, which is now a Linux Foundation project maintaining an open-source, largely command-compatible alternative. Teams evaluating Terraform today should be aware that this fork exists and factor licensing terms into vendor and tooling decisions, particularly if they are building a commercial product on top of the engine itself rather than simply using it to manage their own infrastructure.

Best Practices for Production Terraform

The single highest-leverage practice is running Terraform exclusively through CI/CD rather than from engineers' laptops. A pipeline that runs terraform plan on every pull request and posts the diff as a comment, then runs terraform apply only after human review and merge, turns infrastructure changes into the same reviewable, auditable process teams already use for application code. This also removes the class of bugs caused by different engineers running different local Terraform or provider versions against the same state.

Version pinning deserves the same rigor. Both the Terraform CLI version and every provider version should be pinned in a required_providers block with narrow version constraints, and the resulting lock file (.terraform.lock.hcl) should be committed to version control. Provider releases occasionally introduce breaking changes to resource schemas; an unpinned provider that auto-upgrades in CI can turn a routine apply into an unplanned incident.

Finally, invest in policy-as-code and automated testing earlier than feels necessary. Tools like Open Policy Agent (via Sentinel in Terraform Cloud, or standalone OPA/Conftest checks in CI) can enforce organizational rules - no public S3 buckets, mandatory tagging, approved instance types - as automated gates rather than relying on manual review to catch them every time. Combined with terraform validate and terraform plan output diffing in CI, this closes most of the gap between "looks right in review" and "is actually safe to apply."

When (and When Not) to Use Terraform

Terraform earns its adoption cost when infrastructure needs to be reproducible across environments, when more than one cloud or SaaS provider is involved, or when the team is large enough that undocumented manual changes become a genuine risk. It's also a strong fit when infrastructure changes need the same review and audit trail as application code - which, for most regulated or security-conscious organizations, is not optional but a requirement.

It's less clearly the right tool for a single-developer side project standing up one small server, where the overhead of state management and remote backends may exceed the benefit, at least initially. It's also worth distinguishing Terraform's job from Kubernetes-native tools: Terraform is well suited to provisioning the Kubernetes cluster itself and the cloud resources around it, but once workloads are running inside that cluster, tools purpose-built for that layer - Helm, Kustomize, or GitOps controllers like Argo CD - are generally a better fit than trying to manage every Kubernetes manifest through Terraform's kubernetes provider.

Key Takeaways

Conclusion

Terraform's lasting contribution isn't any single feature - it's the discipline it imposes: infrastructure described as versioned, reviewable code, changes previewed before they happen, and a single source of truth for what should exist. That discipline is what turns infrastructure management from an error-prone, tribal-knowledge process into something closer to normal software engineering.

None of this makes Terraform a universal answer. Like any tool, it has a cost - learning the state model, building CI pipelines, deciding how to split configurations - and that cost is only worth paying once the underlying problem (inconsistent, unreproducible infrastructure managed by hand) is actually present. For most teams operating at more than trivial scale, though, that threshold is crossed early, which is why Terraform, and the broader infrastructure-as-code discipline it represents, has become close to a default expectation in professional cloud engineering.

References

A quick flashcard preview - one example of each question type in this post. This is a demo of what you'll find in the full quiz app.

Flashcard preview - 1 / 9Multiple Choice

multiple choice - advanced - auto-graded

A production S3 bucket's lifecycle rule is deleted manually through the AWS console rather than through Terraform. What condition does this create?

Choose an answer