# Infrastructure as code with Terraform

> Manage cloud infrastructure with Terraform: resources and the dependency graph, remote state, modules, environments, refactoring and a CI workflow.

DevSecOps Engineer roadmap · Run the platform · 19 min read · https://levelcode.org/roadmaps/devsecops/terraform

Clicking through a cloud console works for one experiment and fails for everything after it: nobody can review the change, reproduce it in another account, or say why a security group allows port 22 from anywhere. **Infrastructure as code** (IaC) describes infrastructure in files that live in Git, are reviewed in pull requests and are applied by a tool. Terraform (and its open-source fork OpenTofu, which uses the same language and commands) is the most widely used. This lesson teaches Terraform properly, from the language to state, modules and environments, so the [IaC security lesson](https://levelcode.org/roadmaps/devsecops/iac-security) can focus on securing it.

## Why infrastructure as code

- **Review**: every change is a diff in a pull request, so a teammate (and an automated scanner) sees "this opens port 22 to the internet" *before* it happens.
- **Reproducibility**: staging and production are built from the same code, so "works in staging" means something.
- **History and rollback**: Git records who changed what and why; reverting a commit and applying reverts the infrastructure.
- **Drift detection**: the tool can tell you when someone changed something by hand.

Terraform is **declarative**: you describe the end state ("a bucket with versioning on"), not the steps. Terraform works out what to create, change or destroy to get there.

## The language: providers, resources and references

A Terraform configuration is a directory of `.tf` files written in HCL. **Providers** are plugins that talk to an API (AWS, Google Cloud, Azure, Cloudflare, GitHub, Kubernetes). **Resources** are things the provider manages.

```hcl
terraform {
  required_version = ">= 1.11"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

resource "aws_s3_bucket" "reports" {
  bucket = "acme-reports-prod"
}

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

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
}
```

`aws_s3_bucket.reports.id` is a **reference**. It does two jobs: it passes the bucket's ID into the other resources, and it tells Terraform that they **depend** on the bucket. Terraform builds a dependency graph from references and creates independent resources in parallel and dependent ones in order. You almost never need `depends_on`; if you do, it's usually a hidden dependency worth commenting.

The rest of the language:

```hcl
variable "environment" {
  type        = string
  description = "Deployment environment: staging or prod."
  validation {
    condition     = contains(["staging", "prod"], var.environment)
    error_message = "environment must be staging or prod."
  }
}

locals {
  name_prefix = "acme-${var.environment}"
  common_tags = { environment = var.environment, managed-by = "terraform" }
}

data "aws_caller_identity" "current" {} # reads, never creates

output "reports_bucket_arn" {
  value = aws_s3_bucket.reports.arn
}
```

- **Variables** are inputs; give them types, descriptions and validation.
- **Locals** are named expressions, for values you'd otherwise repeat.
- **Data sources** read existing things (the current account ID, an AMI, a VPC managed elsewhere) without managing them.
- **Outputs** expose values to the user or to other configurations.

Pin provider versions (`~> 6.0` allows 6.x but not 7.0) and commit the generated `.terraform.lock.hcl`, which records exact versions and checksums so every machine uses identical provider binaries.

## Plan and apply

```bash
terraform init               # download providers, set up the state backend
terraform plan -out=tfplan   # compare code with reality, save the proposed changes
terraform apply tfplan       # apply exactly what was planned
```

Read plans carefully. The symbols tell you what will happen: `+` create, `~` update in place, `-` destroy, and `-/+` **destroy and recreate**, which for a database means data loss. Some attribute changes force replacement; the plan marks them with `# forces replacement`.

```text
  # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
      ~ identifier = "orders-db" -> "orders-database" # forces replacement
```

Applying a saved plan file guarantees that what you reviewed is what runs; if the state has changed since the plan was made, Terraform refuses to apply it as stale. Running `terraform apply` without a plan computes a fresh one, which may differ from what you reviewed.

## State: Terraform's memory

Terraform maps each resource in your code to a real object through its **state**: a JSON document recording IDs and attributes of everything it manages. Without state, Terraform can't know that `aws_s3_bucket.reports` *is* the bucket `acme-reports-prod`.

State is the part of Terraform that teams most often get wrong:

- **Store it remotely, with locking.** A local `terraform.tfstate` on a laptop can't be shared, and two people applying at once corrupt it. Use a remote backend (an S3 bucket with `use_lockfile = true`, Google Cloud Storage, Azure Blob, or HCP Terraform) so state is shared and only one apply runs at a time.
- **It contains secrets.** Generated passwords and keys appear in state in plain text. Encrypt the backend, restrict who can read it as strictly as production credentials, and enable versioning on the state bucket so a corrupted state can be restored. Better still, keep secrets out of state: let the service own them (`manage_master_user_password = true` on an RDS instance stores the password in Secrets Manager), or use write-only arguments such as `password_wo` (Terraform 1.11+), which are sent to the provider but never saved.
- **Never edit it by hand.** Use the commands: `terraform state list`, `terraform state show <address>`, and the refactoring blocks below.

**Drift** is when reality no longer matches state, usually because someone changed a resource in the console. `terraform plan -refresh-only` shows drift without proposing changes; a normal `plan` proposes changing reality back to the code. Running a scheduled drift check in CI and alerting on differences catches manual changes early.

## Modules: reusable building blocks

A **module** is a directory of Terraform files with variables as inputs and outputs as its interface. Every configuration is already a module (the *root* module); you create child modules to reuse patterns.

```text
modules/
  private-bucket/
    main.tf        # bucket, versioning, public access block, encryption
    variables.tf   # name, tags, retention_days
    outputs.tf     # arn, id
envs/
  staging/main.tf
  prod/main.tf
```

```hcl
# envs/prod/main.tf
module "reports_bucket" {
  source         = "../../modules/private-bucket"
  name           = "acme-reports-prod"
  retention_days = 365
}

module "uploads_bucket" {
  source         = "../../modules/private-bucket"
  name           = "acme-uploads-prod"
  retention_days = 30
}
```

Good modules encode your organisation's defaults: a `private-bucket` module that always blocks public access and enables encryption means nobody can forget to. That's one of the most effective security controls IaC gives you. Keep modules small and focused, version shared modules (a Git tag in `source`, or a registry version), and avoid modules that wrap a single resource without adding anything.

To create several similar resources, use `for_each` with a map, not `count`:

```hcl
module "buckets" {
  source   = "../../modules/private-bucket"
  for_each = { reports = 365, uploads = 30, exports = 7 }

  name           = "acme-${each.key}-prod"
  retention_days = each.value
}
```

With `count`, resources are addressed by position (`[0]`, `[1]`); removing the first item shifts the rest and Terraform plans to destroy and recreate them. `for_each` addresses them by key (`["uploads"]`), so removing one touches only that one.

## Environments

Staging and production must have **separate state**, so a mistake in staging can't touch production. The most common and clearest layout is a **directory per environment**, each a small root module calling shared modules with different inputs, each with its own backend key (`orders/staging.tfstate`, `orders/prod.tfstate`), and ideally its own cloud account and credentials.

Terraform **workspaces** (`terraform workspace new staging`) also give separate state from one directory, but every environment then shares the same code and backend configuration, and it's easy to apply in the wrong workspace. Prefer directories for environments; workspaces suit short-lived copies such as per-branch preview stacks.

Split large configurations into several smaller ones (network, data, application), each with its own state. A plan over 2,000 resources is slow, hard to review, and gives every change a large blast radius. Share values between them with outputs and data sources, not by copying IDs.

## Refactoring safely

Renaming a resource in code looks to Terraform like "delete the old one, create a new one". For a database, that's an outage. Tell Terraform it's the same object with a `moved` block (if you renamed or added a `module` call, run `terraform init` again first, or `plan` fails with "Module not installed"):

```hcl
moved {
  from = aws_s3_bucket.reports
  to   = module.reports_bucket.aws_s3_bucket.this
}
```

To bring an existing, hand-made resource under Terraform, use an `import` block (Terraform 1.5+), then run `plan` until it shows no changes:

```hcl
import {
  to = aws_s3_bucket.legacy_logs
  id = "acme-legacy-logs"
}
```

And protect resources whose loss would be a disaster:

```hcl
resource "aws_db_instance" "main" {
  # ...
  deletion_protection = true # enforced by AWS itself
  lifecycle {
    prevent_destroy = true   # Terraform refuses any plan that destroys it
  }
}
```

## Terraform in CI

Applying from laptops doesn't scale: credentials spread, applies aren't reviewed and nobody knows what ran. A standard pipeline:

1. On every pull request: `fmt -check`, `validate`, security scanning, and `plan`. Post the plan output to the pull request for review.
2. On merge to main: `plan` again and apply that saved plan. The pull request's plan is usually stale by then, because other changes may have merged first. Use short-lived cloud credentials from **OIDC federation**, never long-lived access keys stored as secrets (the [cloud IAM lesson](https://levelcode.org/roadmaps/devsecops/cloud-iam) shows how).
3. Production applies run in a protected environment that requires approval.
4. A scheduled job runs `plan -detailed-exitcode` (exit code 2 means changes are pending) to detect drift.

> [!WARNING]
> Give the plan job read-only credentials and the apply job write credentials, and never run `plan` with production credentials on pull requests from forks. Providers and `external` data sources execute code during `plan`.

## Practice

Write a small Terraform project for an "orders" service with two environments:

1. A `modules/private-bucket` module that creates a bucket with versioning, a public access block, server-side encryption and a lifecycle rule expiring noncurrent versions after `retention_days`. It outputs the bucket's `arn` and `id`.
2. `envs/staging` and `envs/prod` root modules, each with its own backend key, creating `reports` and `uploads` buckets via `for_each`.
3. Rename the module call from `buckets` to `storage` in prod without replacing the buckets.
4. Run `terraform validate` and `terraform plan` (against a sandbox account, or LocalStack if you have no cloud account).

**Solution**

```hcl
# modules/private-bucket/variables.tf
variable "name" {
  type = string
}

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

variable "tags" {
  type    = map(string)
  default = {}
}
```

```hcl
# modules/private-bucket/main.tf
resource "aws_s3_bucket" "this" {
  bucket = var.name
  tags   = var.tags
}

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

resource "aws_s3_bucket_public_access_block" "this" {
  bucket                  = aws_s3_bucket.this.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  bucket = aws_s3_bucket.this.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
    bucket_key_enabled = true # far fewer KMS requests, so far lower cost
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "this" {
  bucket = aws_s3_bucket.this.id
  rule {
    id     = "expire-noncurrent"
    status = "Enabled"
    filter {}
    noncurrent_version_expiration {
      noncurrent_days = var.retention_days
    }
  }
  depends_on = [aws_s3_bucket_versioning.this] # configure versioning before rules about noncurrent versions
}
```

```hcl
# modules/private-bucket/outputs.tf
output "arn" {
  value = aws_s3_bucket.this.arn
}

output "id" {
  value = aws_s3_bucket.this.id
}
```

```hcl
# envs/prod/main.tf
terraform {
  required_version = ">= 1.11"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 6.0" }
  }
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "orders/prod.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }
}

provider "aws" {
  region = "eu-west-1"
}

module "storage" {
  source   = "../../modules/private-bucket"
  for_each = { reports = 365, uploads = 30 }

  name           = "acme-orders-${each.key}-prod"
  retention_days = each.value
  tags           = { environment = "prod", service = "orders" }
}

moved {
  from = module.buckets
  to   = module.storage
}
```

`envs/staging/main.tf` is identical except for the backend `key` (`orders/staging.tfstate`), the names (`-staging`) and shorter retention. After renaming the module call, run `terraform init` (Terraform installs modules per call name), then `terraform plan`: it reports each bucket's resources as `has moved to module.storage[...]`, with `Plan: 0 to add, 0 to change, 0 to destroy`.

Two details in the module are worth knowing. S3 already encrypts every new object with S3-managed keys by default; choosing `aws:kms` means access to the key is controlled by IAM and every use is logged in CloudTrail, and the bucket key keeps the KMS bill down. The `depends_on` is one of the rare legitimate uses: the lifecycle configuration doesn't reference the versioning resource, yet its rule is about noncurrent versions, which only exist once versioning is on. The AWS provider's documentation recommends exactly this ordering.

## Key takeaways

- IaC makes infrastructure reviewable, reproducible and auditable; Terraform compares declared resources with reality and plans the difference.
- References build the dependency graph; read every plan for `-/+` replacements before applying, and apply saved plans.
- State maps code to real resources: keep it remote, locked, encrypted, versioned and tightly access-controlled.
- Use modules to encode secure defaults, `for_each` over `count`, and a separate directory and state per environment.
- Refactor with `moved` and `import` blocks, protect critical resources with `prevent_destroy`, and apply only from CI with OIDC credentials.

## Quiz

1. A plan shows -/+ next to your production database with '# forces replacement'. What does that mean?

   A. The database will be updated in place
   B. Terraform will destroy the database and create a new one, losing its data unless you act
   C. Only the tags will change
   D. The plan is invalid

   **Answer: B.** -/+ means destroy then create. Some attributes, such as an identifier, can't be changed in place. Stop and find another way (or a migration plan) before applying.

2. Why use for_each with a map instead of count for a set of similar buckets?

   A. count is deprecated
   B. for_each is faster to plan
   C. Resources are addressed by key, so removing one doesn't shift the others and trigger replacements
   D. count can't be used with modules

   **Answer: C.** count addresses resources by index; deleting an early item renumbers the rest, which Terraform sees as changes or replacements. Keys stay stable.

3. You rename resource aws_s3_bucket.reports to aws_s3_bucket.report_files in code. How do you avoid Terraform deleting and recreating the bucket?

   A. Add a moved block from the old address to the new one
   B. Edit terraform.tfstate by hand
   C. Add depends_on
   D. Run terraform apply twice

   **Answer: A.** A moved block tells Terraform the object at the old address is now at the new one, so the plan shows a move instead of destroy-and-create. Editing state by hand is error-prone.

4. Which statement about Terraform state is true?

   A. It only contains resource names, so it's safe to commit
   B. Terraform can rebuild it from the code at any time
   C. Workspaces store it in Git
   D. It can contain secrets in plain text, so it needs an encrypted, access-controlled, locked backend

   **Answer: D.** State records resource attributes, including generated passwords and keys. Treat the backend like production credentials, and never commit state to Git.
