How We Use Terraform

Terraform explained against real, working production code - every concept tied to a specific file in CSOH's own infrastructure. The whole site runs on four small Terraform stacks (AWS, GCP, Azure, Cloudflare) that you can read top to bottom. If you learn better from someone else's HCL than from the docs, start here.

· · Vendor-neutral · View source on GitHub

The honest version: Terraform is how you describe cloud infrastructure as text files instead of clicking around a console. You write what you want to exist - a bucket, a DNS record, an IAM role - and Terraform figures out the API calls to make reality match. Re-run it and it only changes what drifted. The mental model is small; the surface area is large. The fastest way to actually get it is to read a real, working configuration and trace what each block does - which is exactly what this page is. Every concept points at a specific file in our repo.

What you'll need: the Terraform CLI (or OpenTofu - see below), one cloud account, and comfort reading config files. No prior Terraform experience required.

On this page

  1. What Terraform actually is
  2. Why it's great (and where it bites)
  3. A note on licensing & OpenTofu
  4. Anatomy of a Terraform stack
  5. A tour of CSOH's four stacks
  6. Concepts that bite newcomers
  7. Security baseline
  8. Using our repo as a learning resource
  9. Further reading

What Terraform actually is

Terraform is an open-source infrastructure-as-code (IaC) tool from HashiCorp. You declare the cloud resources you want in .tf files written in HCL (HashiCorp Configuration Language), and Terraform talks to each cloud's API to create, update, or delete resources until the real world matches your code.

The loop has three verbs you'll use constantly:

The thing that makes it declarative: you don't write "create a bucket." You write "a bucket named X should exist," and Terraform decides whether that means create it, change it, or do nothing. Here's the absolute minimum - a single AWS S3 bucket:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "example" {
  bucket = "my-globally-unique-bucket-name"
}

terraform init && terraform apply, and the bucket exists. Change the code and apply again, and only the difference is applied. Delete the block and apply, and Terraform deletes the bucket. That shape - declare, plan, apply - stays the same whether you're managing one bucket or an entire multi-cloud edge.

Why it's great (and where it bites)

What's good

Where it bites

A note on licensing & OpenTofu

In 2023 HashiCorp moved Terraform from an open-source (MPL) license to the Business Source License (BSL). The community responded with OpenTofu, a fork now stewarded by the Linux Foundation that stays open-source and remains a near drop-in replacement (tofu in place of terraform). For a project like ours the two are interchangeable; everything on this page applies to both. We say "Terraform" generically throughout - if your org cares about the license, OpenTofu is the vendor-neutral choice, and switching is mostly a matter of swapping the binary.

Anatomy of a Terraform stack

The Terraform workflow: code to cloud, tracked by state Your HCL is read by terraform init, compared to remote state and the cloud by plan, then reconciled by apply, which updates both the cloud and the state file. From .tf files to live cloud - and how state keeps them in sync .tf files (HCL) terraform {} block providers · variables resources · outputs plan diff: code vs reality + create ~ change - destroy apply call cloud APIs make it so record in state live cloud S3 · Cloud Run Blob · Load Balancer DNS · IAM remote state (GCS bucket, locked + versioned) the JSON record mapping your code to real cloud objects read by plan · written by apply · shared by humans and CI
Code → plan → apply → cloud, with remote state as the memory in the middle. plan reads state to compute the diff; apply writes state after changing the cloud. Lose or unshare that state and Terraform forgets what it owns.

Open infra/terraform/aws/versions.tf in another tab and follow along - it's short and shows the three pieces that configure Terraform itself.

The terraform {} block - configuring the tool

This block configures Terraform itself, not any cloud. It pins versions and says where state lives, so every laptop and every CI run behaves identically:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"     # any 5.x, never 6.0
    }
  }

  backend "gcs" {            # where state is stored
    bucket = "csoh-org-495800-tfstate"
    prefix = "csoh/aws"
  }
}

Three things to notice: required_version refuses to run on an old CLI; the ~> ("pessimistic") constraint takes bug-fix releases but never a breaking major bump; and the backend says state lives in a Google Cloud Storage bucket. Yes - our AWS stack keeps its state in GCS. That's deliberate: one state store to secure instead of one per cloud (the rationale is commented at the top of the file).

Providers - the plugins that talk to each cloud

A provider is the plugin that translates HCL into a specific platform's API calls. You declare which ones you need (above) and configure them (below):

provider "aws" {
  region              = var.aws_region
  allowed_account_ids = [var.aws_account_id]   # safety tripwire
}

Notice what's not here: credentials. They come from the environment - your local AWS profile, or in CI a short-lived OIDC token. That's why no secrets appear in the file. allowed_account_ids is a guardrail we love: if the credentials Terraform happens to be using point at the wrong account, the apply aborts before touching anything.

Variables, resources, and outputs

The other three building blocks, each in its own file by convention:

References between blocks (var.bucket_name, aws_s3_bucket.site.arn) do double duty: they pass values around and they tell Terraform the dependency order, so it builds the bucket before the role that points at it. You never sequence things by hand.

A tour of CSOH's four stacks

The entire site is static, served active/active from three clouds behind Cloudflare. That shape is described in How We Deploy; here we're reading the Terraform that builds it. The code lives in four independent stacks under infra/terraform/:

infra/terraform/
  aws/          S3 (private) + CloudFront/OAC + GitHub OIDC role
  azure/        Storage account + $web static website + federated cred
  gcp/          Cloud Run + Artifact Registry + Workload Identity Federation
  cloudflare/   Load Balancer + pool/monitor + header/redirect/cache rules

Each directory is its own stack with its own state - you cd in (or use -chdir) and run init/plan/apply there. All four keep state in the same GCS bucket under separate prefixes (csoh/aws, csoh/azure, csoh/prod, csoh/cloudflare): one secured store, no per-cloud bootstrapping.

Why split into four stacks instead of one?

A smaller blast radius. A bad apply to the Cloudflare stack can't accidentally delete the AWS bucket, because AWS isn't in that state file. Each cloud also authenticates differently, plans faster on its own, and can be destroyed independently. The trade-off is coordinating outputs between stacks by hand - which for a four-stack, solo-maintained repo is a fine price.

Keyless deploys: the security centerpiece

The file worth reading twice is aws/oidc.tf. It sets up deploys with no long-lived cloud secret in the repo at all. Instead, GitHub Actions proves its identity to AWS with a short-lived, signed OIDC token, and AWS hands back temporary credentials. The trust is scoped tighter than most people's first attempt:

condition {
  test     = "StringEquals"
  variable = "token.actions.githubusercontent.com:sub"
  values   = ["repo:${var.github_owner}/${var.github_repo}:environment:production"]
}

Read that condition out loud: only a workflow in this repo, running in the production environment, may assume the deploy role. A token from any other repo, any fork, or any other environment is rejected. The role's permissions are equally tight - write to one bucket and invalidate one CloudFront distribution, nothing else. So even if a token leaked, it could touch exactly one site's files and nothing else in the account. GCP does the same thing with Workload Identity Federation in gcp/wif.tf; Azure with a federated credential in azure/identity.tf.

A resource, start to finish

From aws/s3.tf, the private origin bucket and one of its settings:

resource "aws_s3_bucket" "site" {
  bucket = var.bucket_name
}

resource "aws_s3_bucket_versioning" "site" {
  bucket = aws_s3_bucket.site.id      # depends on the bucket above
  versioning_configuration {
    status = "Enabled"                # cheap rollback / forensics trail
  }
}

Two idioms worth internalizing. First, AWS bucket settings are split across several small resources (versioning, encryption, public-access block, policy) that each point back at the one bucket via bucket = aws_s3_bucket.site.id - that reference both targets the bucket and orders the work. Second, versioning here is a deliberate security choice: a bad publish can be rolled back object-by-object, and accidental deletes are recoverable.

Concepts that bite newcomers

1. State is shared, so it must be remote and locked

By default Terraform writes terraform.tfstate to your local disk. That's fine for a tutorial and a disaster for a team or CI: two applies at once race and corrupt it. The fix is a remote backend (our gcs backend) so state is centralized, and state locking so a second apply waits for the first to finish. Never commit a state file to git, and never let two people run apply against the same state simultaneously.

2. State can hold secrets - treat it as sensitive

Any secret a resource generates (a database password, an access key, a TLS private key) is stored in state in plaintext, even if you marked the output sensitive (that only hides it from the console). So the bucket holding state must be encrypted, access-controlled, and versioned - exactly the properties our GCS state bucket is configured with at bootstrap.

3. Always plan before apply

Read the plan. + is create, ~ is change-in-place, -/+ is destroy and recreate (downtime!), and - is destroy. The recreate and destroy lines are where production incidents come from. In CI, save the plan and apply that exact plan so what you reviewed is what runs.

4. Pin providers and commit the lock file

terraform init writes .terraform.lock.hcl, which records the exact provider versions and their checksums. Commit it. It's the supply-chain equivalent of a package-lock.json: it guarantees every machine and every CI run uses byte-identical provider binaries, and it'll catch a tampered provider via checksum mismatch. Ours are committed in each stack (e.g. aws/.terraform.lock.hcl).

5. Drift: the console fights your code

If someone changes a resource by hand in the cloud console, your code and reality "drift." The next plan shows it - Terraform wants to put things back the way your code says. The discipline: change infrastructure in the code, not the console. We even tag every AWS resource managedBy = "terraform" as a visible reminder.

6. count and for_each - don't copy-paste resources

Need three near-identical buckets? Don't write three blocks. for_each over a map (or count over a number) makes one block manage many instances. The gotcha: count indexes by position, so removing the middle item renumbers everything after it and Terraform plans destroy/recreate. Prefer for_each with stable keys when items can be added or removed.

7. terraform -chdir and per-directory state

Because our four stacks are separate directories, we drive them without cd-ing around: terraform -chdir=infra/terraform/aws plan. Each directory is an isolated root module with its own state and its own init. Forgetting which directory you're in is the most common "why is the plan empty?" moment.

8. import existing resources instead of recreating them

Already built something by hand and want Terraform to manage it? terraform import (or an import {} block) brings an existing cloud object under management without destroying it. The alternative - letting Terraform create a duplicate - is how people accidentally end up with two load balancers.

The state file is the most sensitive thing in your repo's orbit - it can contain secrets and it is the keys to your infrastructure. Secure it like production, because it is. - the rule we apply to all four CSOH stacks

Security baseline

Terraform is a security tool when you use it well and a footgun when you don't. From day one:

  1. Remote, encrypted, versioned, access-controlled state. No local state for anything real. Lock it. Assume it contains secrets, because it can.
  2. No secrets in .tf or .tfvars files. Pull credentials from the environment, a secrets manager, or - best of all - don't use long-lived credentials at all. Add *.tfvars and .terraform/ to .gitignore.
  3. Prefer keyless / OIDC for CI. Our deploys carry zero static cloud keys; GitHub federates short-lived tokens to each cloud. If you take one idea from this page, take this one. See oidc.tf.
  4. Least-privilege everything. The deploy role can write one bucket and invalidate one CDN - not "PowerUserAccess." Scope provider credentials and resource permissions to exactly the job.
  5. Pin provider versions and commit .terraform.lock.hcl. Supply-chain hygiene; checksums catch tampering.
  6. Scan your IaC. Static analyzers - Trivy, Checkov, tfsec - catch "this bucket is public" or "this SG is 0.0.0.0/0" before apply. Our deploy pipeline already runs Trivy on the GCP container image; IaC scanning is the same idea, one step earlier.
  7. Guardrails against the wrong account. allowed_account_ids (AWS) and equivalent checks abort an apply pointed at the wrong place. Cheap insurance.
  8. Review the plan like a code review. Treat a surprising -/+ or - the way you'd treat a surprising rm -rf in a PR.

Using our repo as a learning resource

The point of this page is that our Terraform is real, in-production, and heavily commented for beginners - every non-obvious line has a one-to-three-line explanation. A suggested reading order:

  1. Start with the tool config: aws/versions.tf. The terraform{} block, provider pinning, and the remote backend in one short file.
  2. Then the inputs: aws/variables.tf. How values are declared once and reused as var.x.
  3. Then a real resource: aws/s3.tf and cloudfront.tf. Resources, references, and how settings split across small blocks.
  4. Then the security set-piece: aws/oidc.tf. Trust policy vs permissions policy, scoped to one repo and environment.
  5. Finally the edge: the cloudflare/ stack - a different provider entirely (Load Balancer, pools, header/redirect/cache rules) driven by the same workflow. Proof that the shape transfers across clouds.

The infra/README.md ties it together: the architecture diagram, the bootstrap commands, the cutover/rollback plan, and the cost table (~$8-12/mo across three clouds). Fork the repo, swap in your own account IDs and bucket names, and you have a working multi-cloud static-hosting setup to learn from.

Further reading

Questions?

Bring them to Friday Zoom. Several regulars run nontrivial Terraform at work (multi-account AWS, GCP org policies, Terraform Cloud, Atlantis) and are happy to walk through state strategy, module design, or keyless CI. The meeting recaps surface plenty of IaC war stories worth learning from.