Week 10: Terraform Modules & Remote State

Week 9's single main.tf worked because exactly one person — you — ever ran apply against it with a state file sitting on your own laptop. That breaks the moment a second person joins: two laptops with two copies of "the truth" about the same infrastructure is a guaranteed conflict. This week fixes both halves of that problem — turning your S3 bucket configuration into a reusable module with proper inputs and outputs, and moving state into a remote backend with locking — so the shared infrastructure you'll build for Kubernetes starting in Week 11 can be safely touched by more than one person at once.

Module 7 of 22 Week 10 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Structure input variables and outputs to make a Terraform configuration reusable
  • Package a resource into a Terraform module and call it from a root configuration
  • Configure an S3 + DynamoDB remote backend and explain why state locking prevents concurrent-apply conflicts

1. Input Variables & Outputs

You already used a variable block in Week 9 to parameterize a bucket name. Variables can also declare a type, a default, and validation rules, which turns a typo into an immediate error instead of a confusing plan later:

variables.tf
variable "environment" {
  description = "Deployment environment name"
  type        = string
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

variable "enable_versioning" {
  description = "Whether to enable S3 bucket versioning"
  type        = bool
  default     = false
}

Values can come from a terraform.tfvars file, a -var flag, or an environment variable prefixed TF_VAR_ — useful for keeping a value out of a file entirely:

terraform.tfvars
environment       = "dev"
enable_versioning = true

Outputs are the reverse direction: values Terraform prints after apply, and — more importantly — values one module exposes so another module or the root configuration can consume them:

outputs.tf
output "bucket_arn" {
  description = "ARN of the created bucket"
  value       = aws_s3_bucket.this.arn
}

output "bucket_id" {
  value = aws_s3_bucket.this.id
}
Variables in, outputs out

Think of a Terraform module the same way you'd think of a function: variables are its parameters, outputs are its return value, and the resources inside are its implementation detail that callers shouldn't need to know about.

2. Writing a Reusable Module

A module is just a directory of .tf files with its own variables and outputs — every configuration you've written so far is technically the "root module". Pulling repeated infrastructure into its own module directory means you write the S3-bucket-with-versioning pattern once and reuse it for every bucket your team creates:

modules/s3-bucket/variables.tf
variable "bucket_name" {
  description = "Globally unique S3 bucket name"
  type        = string
}

variable "environment" {
  type = string
}

variable "enable_versioning" {
  type    = bool
  default = false
}
modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name

  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket_versioning" "this" {
  count  = var.enable_versioning ? 1 : 0
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = "Enabled"
  }
}
modules/s3-bucket/outputs.tf
output "bucket_arn" {
  value = aws_s3_bucket.this.arn
}

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

A root configuration then calls the module with a module block, pointing source at the module's path and passing its variables as arguments:

environments/dev/main.tf
module "app_logs" {
  source = "../../modules/s3-bucket"

  bucket_name       = "codeverse-app-logs-ada"
  environment       = "dev"
  enable_versioning = true
}

output "app_logs_bucket_arn" {
  value = module.app_logs.bucket_arn
}

The count = var.enable_versioning ? 1 : 0 trick is a common pattern for making a resource conditional — Terraform doesn't have an if statement, but a resource with count = 0 simply isn't created. Run terraform init again after adding a module block; it needs to fetch and register the module's contents just like a provider.

Don't over-abstract on day one

Extract a module once you've copy-pasted the same resource block a second or third time, not before — a module written too early, before you know which parts actually vary, tends to grow an awkward pile of variables nobody uses correctly.

3. Remote State Backends

Local state — a terraform.tfstate file sitting next to your .tf files — works for a single person experimenting, and stops working the instant a second person needs to run apply against the same infrastructure: there's no shared source of truth, no lock to stop two applies from racing, and the file itself would have to be emailed or committed to be shared at all.

A remote backend moves state into shared storage that everyone's Terraform client reads from and writes to. The standard AWS pattern pairs an S3 bucket (durable storage for the state file, with versioning so a bad state can be rolled back) with a DynamoDB table (a lock, so only one apply can run at a time):

terminal — bootstrap the backend's own infrastructure once
aws s3api create-bucket --bucket codeverse-terraform-state --region us-east-1
aws s3api put-bucket-versioning \
  --bucket codeverse-terraform-state \
  --versioning-configuration Status=Enabled

aws dynamodb create-table \
  --table-name terraform-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

This is one of the rare cases where creating infrastructure by hand (or in a tiny, separate Terraform config with its own local state) is correct — the backend that stores your state can't also store its own state, so it has to be bootstrapped outside the system it will end up supporting.

backend.tf
terraform {
  backend "s3" {
    bucket         = "codeverse-terraform-state"
    key            = "dev/app-logs/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}
terminal — migrate existing local state to the new backend
$ terraform init
Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend
  to the newly configured "s3" backend.

  Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

The key is the state file's path inside the bucket — using a different key per environment (dev/app-logs/terraform.tfstate, prod/app-logs/terraform.tfstate) is how you keep dev and prod state fully isolated from each other while reusing the same bucket and module.

4. Team Collaboration: Locking & Avoiding Concurrent Applies

With the DynamoDB table wired up, Terraform automatically acquires a lock at the start of every plan or apply and releases it when the command finishes. If a teammate tries to run apply while yours is still in progress, they get a clear error instead of two processes corrupting the same state file:

terminal
$ terraform apply
Error: Error acquiring the state lock

Lock Info:
  ID:        7f3a2b1c-9e4d-4a2f-8b3e-2d1c4f5a6b7c
  Path:      codeverse-terraform-state/dev/app-logs/terraform.tfstate
  Operation: OperationTypeApply
  Who:       ada@ci-runner
  Created:   2026-08-03 14:22:10 UTC

Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and
try again.

You wait for the lock to clear naturally in almost every case. terraform force-unlock <LOCK_ID> exists for the rare case where a process crashed mid-apply and left a stale lock behind — but running it while an apply is genuinely still in progress is exactly how two concurrent applies corrupt state, so treat it as a last resort you confirm with your team first, not a routine command.

The rest of safe collaboration is process, not tooling: infrastructure changes go through a pull request like any other code change (the same Git branching workflow from Week 2), a CI job runs terraform plan and posts the output as a comment for review, and only a merged, approved PR triggers the actual apply — mirroring the required-reviewer pattern from Week 7's GitHub Environments, just gating infrastructure changes instead of application deploys.

Nobody should run apply from their laptop against shared state

Once a backend is shared, route every real apply through CI with a fixed identity and a plan artifact reviewers actually read — a laptop apply bypasses review, may use stale local .tf files, and makes "who changed this and why" much harder to answer than a merged PR does.

5. Hands-on Exercise

Hands-on

Turn Week 9's bucket into a module with remote state

Refactor last week's single-file configuration into a reusable module driven by a root config that stores its state remotely with locking.

Requirements:

  1. Create modules/s3-bucket/ with main.tf, variables.tf and outputs.tf, moving the bucket and versioning resources from Week 9 in and parameterizing bucket_name, environment, and enable_versioning.
  2. Create environments/dev/main.tf that calls the module with source = "../../modules/s3-bucket" and passes concrete values for each variable.
  3. Bootstrap a state bucket and a DynamoDB lock table with the AWS CLI, then add a backend "s3" block to environments/dev/ pointing at them with a dev/app-logs/terraform.tfstate key.
  4. Run terraform init and answer yes to migrate any existing local state, then confirm with terraform state list that Terraform is reading from the remote backend.
  5. Add a root-level output that exposes the module's bucket_arn, run terraform apply, and confirm the state object exists in the state bucket with aws s3 ls s3://codeverse-terraform-state/dev/app-logs/.
Hint

The backend block can't reference variables or use interpolation — the bucket, key, and dynamodb_table values in a backend "s3" block must be literal strings, since Terraform has to know where to find state before it can evaluate anything else in the configuration.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What's the practical difference between a Terraform variable and an output?

A variable is an input the caller supplies before Terraform runs — like a function parameter — while an output is a value Terraform computes and exposes after applying, either printed to the terminal or consumed by whatever called the module. Together they're what let a module be reused without anyone having to read or edit the resources inside it.

Q2

Why doesn't local Terraform state scale to a team, even a small one?

Each teammate's local state file becomes its own out-of-sync copy of "the truth" the moment someone else applies a change, there's no mechanism to stop two people from running apply at the same time and corrupting the file, and sharing it at all would mean emailing a JSON file around or committing a file that often contains plaintext secrets. A remote backend gives everyone one shared, lockable source of truth instead.

Q3

What does the DynamoDB table in an S3 + DynamoDB backend actually prevent?

It holds a lock record for the state file, so if a second plan or apply starts while one is already running, Terraform refuses it with a clear "Error acquiring the state lock" instead of letting two processes read and write the same state file simultaneously. Without it, two concurrent applies could interleave their writes and leave the state file corrupted or inconsistent with real infrastructure.

Q4

Why is running terraform force-unlock risky, and when is it actually appropriate?

It manually removes the lock without checking whether the operation that created it is actually finished, so running it against a lock held by a genuinely in-progress apply defeats the entire point of locking and can let two applies write state at once. It's appropriate only when you've confirmed with your team that the process holding the lock crashed or was killed and really is gone — not as a routine way to get past an inconvenient wait.