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:
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:
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:
output "bucket_arn" {
description = "ARN of the created bucket"
value = aws_s3_bucket.this.arn
}
output "bucket_id" {
value = aws_s3_bucket.this.id
}
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:
variable "bucket_name" {
description = "Globally unique S3 bucket name"
type = string
}
variable "environment" {
type = string
}
variable "enable_versioning" {
type = bool
default = false
}
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"
}
}
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:
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.
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):
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.
terraform {
backend "s3" {
bucket = "codeverse-terraform-state"
key = "dev/app-logs/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
$ 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:
$ 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.
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
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:
- Create
modules/s3-bucket/withmain.tf,variables.tfandoutputs.tf, moving the bucket and versioning resources from Week 9 in and parameterizingbucket_name,environment, andenable_versioning. - Create
environments/dev/main.tfthat calls the module withsource = "../../modules/s3-bucket"and passes concrete values for each variable. - Bootstrap a state bucket and a DynamoDB lock table with the AWS CLI, then add a
backend "s3"block toenvironments/dev/pointing at them with adev/app-logs/terraform.tfstatekey. - Run
terraform initand answeryesto migrate any existing local state, then confirm withterraform state listthat Terraform is reading from the remote backend. - Add a root-level output that exposes the module's
bucket_arn, runterraform apply, and confirm the state object exists in the state bucket withaws s3 ls s3://codeverse-terraform-state/dev/app-logs/.
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?
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?
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?
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?
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.