0) Root structure
Create one directory:
mkdir terraform-prod-challenges
cd terraform-prod-challenges
Final structure:
terraform-prod-challenges/
├── 00-baseline/
├── 01-drift-and-refresh/
├── 02-import-existing/
├── 03-rename-without-destroy-state-mv/
├── 04-address-change-count-to-foreach/
├── 05-prevent-destroy-safety/
├── 06-backend-change-reconfigure/
├── 07-state-locking-simulation/
├── 08-partial-apply-and-recovery/
├── 09-outputs-remote-state-contract/
└── 10-provider-version-lock-file/
Each folder is one “production challenge”.
00-baseline
Purpose: a simple baseline resource (local file) used across labs.
Structure
00-baseline/
├── main.tf
└── outputs.tf
00-baseline/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "app_config" {
filename = "${path.module}/app.conf"
content = "version=1\nowner=platform\n"
}
00-baseline/outputs.tf
output "config_path" {
value = local_file.app_config.filename
}
Run:
cd 00-baseline
terraform init
terraform apply
01-drift-and-refresh (drift happens in prod)
Challenge: manual change outside Terraform → “drift”.
Structure
01-drift-and-refresh/
├── main.tf
└── README.txt
01-drift-and-refresh/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "drift_demo" {
filename = "${path.module}/drift.txt"
content = "managed_by=terraform\nvalue=100\n"
}
01-drift-and-refresh/README.txt
Steps:
- Apply:
terraform init
terraform apply
Manually edit
drift.txtand changevalue=100tovalue=999.See drift:
terraform plan
Fix concept:
- Terraform will plan to revert manual edits back to desired state.
02-import-existing (prod reality: resources exist already)
Challenge: “Terraform didn’t create it, but we must manage it”.
We simulate import with a local_file that already exists.
Structure
02-import-existing/
├── main.tf
└── README.txt
02-import-existing/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
# We will import an existing file into this resource
resource "local_file" "import_me" {
filename = "${path.module}/existing.txt"
content = "this_will_become_managed\n"
}
02-import-existing/README.txt
Steps:
- Create the file BEFORE Terraform manages it:
echo "i already existed" > existing.txt
- Init:
terraform init
- Import:
terraform import local_file.import_me ./existing.txt
- Check plan:
terraform plan
Key production point:
- Import updates state, not your “history”.
- After import, you must ensure code matches the real object.
03-rename-without-destroy-state-mv (safe refactor)
Challenge: renaming a resource in code normally causes destroy/create.
Structure
03-rename-without-destroy-state-mv/
├── main.tf
└── README.txt
03-rename-without-destroy-state-mv/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "old_name" {
filename = "${path.module}/name.txt"
content = "hello\n"
}
03-rename-without-destroy-state-mv/README.txt
Steps:
- Apply:
terraform init
terraform apply
- Rename resource in code:
- Change
local_file.old_nametolocal_file.new_nameinmain.tf(only the label).
If you run
terraform plannow, Terraform thinks old was removed and new must be created.Correct fix (no destroy):
terraform state mv local_file.old_name local_file.new_name
- Plan/apply again:
terraform plan
terraform apply
04-address-change-count-to-foreach (classic prod breaking change)
Challenge: changing from count to for_each changes resource addresses.
Structure
04-address-change-count-to-foreach/
├── main.tf
└── README.txt
04-address-change-count-to-foreach/main.tf (version A: count)
terraform {
required_version = ">= 1.5.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
variable "names" {
type = list(string)
default = ["orders", "payments"]
}
resource "local_file" "svc" {
count = length(var.names)
filename = "${path.module}/${var.names[count.index]}.txt"
content = "service=${var.names[count.index]}\n"
}
04-address-change-count-to-foreach/README.txt
Steps:
- Apply (count version):
terraform init
terraform apply
- Now migrate to for_each (edit
main.tfto this):
resource "local_file" "svc" {
for_each = toset(var.names)
filename = "${path.module}/${each.value}.txt"
content = "service=${each.value}\n"
}
Now run plan → Terraform wants to recreate due to address change.
Fix with state mv (map count indices to keys):
terraform state mv 'local_file.svc[0]' 'local_file.svc["orders"]'
terraform state mv 'local_file.svc[1]' 'local_file.svc["payments"]'
- Plan/apply:
terraform plan
terraform apply
05-prevent-destroy-safety (prod guardrail)
Challenge: accidental delete from code.
Structure
05-prevent-destroy-safety/
├── main.tf
└── README.txt
05-prevent-destroy-safety/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "critical" {
filename = "${path.module}/critical.txt"
content = "do_not_delete\n"
lifecycle {
prevent_destroy = true
}
}
05-prevent-destroy-safety/README.txt
Steps:
- Apply:
terraform init
terraform apply
- Try to destroy:
terraform destroy
You should see a failure because prevent_destroy blocks it.
Production message:
- Use for state buckets, databases, critical IAM, etc.
06-backend-change-reconfigure (backend changes in prod)
Challenge: backend configuration changes require re-init.
This lab is executable with local backend, and includes the exact command used in production.
Structure
06-backend-change-reconfigure/
├── main.tf
└── README.txt
06-backend-change-reconfigure/main.tf
terraform {
required_version = ">= 1.5.0"
# For teaching: leave backend local so this lab always runs.
# In production, this would be an S3 backend.
}
resource "null_resource" "backend_note" {}
06-backend-change-reconfigure/README.txt
Production behavior:
- If backend changes, run:
terraform init -reconfigure
If migrating state to a new backend:
terraform init -migrate-state
07-state-locking-simulation (team conflict)
True DynamoDB locking needs AWS, but we can still teach the concept executable.
We simulate “someone is applying” by holding a long-running step.
Structure
07-state-locking-simulation/
├── main.tf
└── README.txt
07-state-locking-simulation/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
time = {
source = "hashicorp/time"
version = "~> 0.11"
}
}
}
resource "time_sleep" "simulate_long_apply" {
create_duration = "60s"
}
07-state-locking-simulation/README.txt
Steps:
- Terminal 1:
terraform init
terraform apply
- While it’s sleeping, in Terminal 2 run:
terraform apply
Teaching point:
- With local state you won’t get DynamoDB lock errors, but in production with S3+DynamoDB you do.
- Interview line: “We use DynamoDB locks to prevent concurrent apply and state corruption.”
08-partial-apply-and-recovery (apply fails mid-way)
Challenge: apply fails after some resources created.
We simulate failure using null_resource with a command that exits non-zero.
Structure
08-partial-apply-and-recovery/
├── main.tf
└── README.txt
08-partial-apply-and-recovery/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}
resource "null_resource" "step1" {}
resource "null_resource" "step2_fail" {
provisioner "local-exec" {
command = "echo 'simulating failure' && exit 1"
}
}
08-partial-apply-and-recovery/README.txt
Steps:
- Apply (it will fail):
terraform init
terraform apply
- Inspect what exists in state:
terraform state list
- Fix (for demo): change
exit 1toexit 0and apply again:
terraform apply
Production lesson:
- partial applies happen (permissions, API issues, timeouts)
- recovery is plan/apply once the root cause is fixed
09-outputs-remote-state-contract (breaking contract between teams)
Challenge: platform team changes an output name → app team breaks.
Structure
09-outputs-remote-state-contract/
├── platform/
│ ├── main.tf
│ └── outputs.tf
├── app/
│ ├── main.tf
│ └── README.txt
09-outputs-remote-state-contract/platform/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "platform" {
filename = "${path.module}/platform.txt"
content = "subnet_id=subnet-123\n"
}
09-outputs-remote-state-contract/platform/outputs.tf
output "subnet_id" {
value = "subnet-123"
}
09-outputs-remote-state-contract/app/main.tf
terraform {
required_version = ">= 1.5.0"
}
data "terraform_remote_state" "platform" {
backend = "local"
config = {
path = "../platform/terraform.tfstate"
}
}
resource "null_resource" "use_contract" {
triggers = {
subnet = data.terraform_remote_state.platform.outputs.subnet_id
}
}
09-outputs-remote-state-contract/app/README.txt
Steps:
- Run platform first:
cd platform
terraform init
terraform apply
- Run app:
cd ../app
terraform init
terraform apply
- Now BREAK the contract:
- In platform
outputs.tf, renamesubnet_idtopublic_subnet_id
- Apply platform again
- App will fail until updated to new output name
Production lesson:
- outputs are “API contracts” between teams
- version and communicate output changes
10-provider-version-lock-file (dependency lock in prod)
Challenge: provider versions drift across machines/CI.
Structure
10-provider-version-lock-file/
├── main.tf
└── README.txt
10-provider-version-lock-file/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
resource "random_id" "demo" {
byte_length = 4
}
output "id" {
value = random_id.demo.hex
}
10-provider-version-lock-file/README.txt
Steps:
- Init and observe lock file:
terraform init
ls -la .terraform.lock.hcl
- Explain:
- lock file pins provider versions used
- commit it in Git for consistent builds
run each folder independently:
cd 01-drift-and-refresh
terraform init
terraform apply
SOCIAL SHARE CARD GENERATOR