Lab notes 1–18¶
Chronological notes from working each concept against a real GCP project: the code as it was written, the commands as they were run, what was verified in the console, and what broke. Some later labs are still marked not yet run — that's the honest state, not an oversight.
These ran against acme-sampleapp, not 02-gcp-terraform/
The code in these labs belongs to the
sandbox repo, not to the
core GCP root documented on the previous page. The concepts are the
same and the mapping table
links them row by row, but don't expect the file paths here to exist under
02-gcp-terraform/.
The order runs roughly easiest to hardest: state, then dependencies, then the repetition mechanics, then modules and workspaces, then the operational skills (import, drift, aliasing) that only matter once something has already gone wrong.
Lab 1 — Local State¶
Concept: Terraform maintains a state file (terraform.tfstate) mapping your config's
resource addresses to real GCP resource IDs. Without it, Terraform has no memory of what it
owns — it can't tell "already exists, managed by me" from "already exists, not mine."
Code (already had):
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
provider "google" {
project = var.project_id
region = "us-central1"
}
resource "google_service_account" "gsa" {
account_id = "acme-web-app"
display_name = "My Test App"
}
Commands run:
terraform init
terraform plan
terraform apply
terraform state list
terraform state show google_service_account.gsa
Verified: SA acme-web-app visible in IAM & Admin → Service Accounts.
Key takeaway: If state is deleted but the real resource still exists, Terraform does
not search GCP to reconcile — it just tries to create again, and the GCP API rejects it
with "already exists." Adoption is always explicit (import), never automatic.
Lab 2 — Remote State (GCS Backend)¶
Concept: Local state is a single point of failure and has no locking — bad for teams. Remote state moves the ledger to a GCS bucket, with locking and (if enabled) versioning.
Setup:
gsutil mb -l us-central1 gs://YOUR-UNIQUE-BUCKET-NAME-tfstate
gsutil mb -l us-central1 gs://terraform-learning-tfstate
Code added to terraform {} block:
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
backend "gcs" {
bucket = "YOUR-UNIQUE-BUCKET-NAME-tfstate"
prefix = "acme-sampleapp/state"
}
}
Commands run:
Verified: .tfstate object appeared under the prefix in the GCS bucket; updates on
subsequent apply runs.
Key takeaway: init connects to the backend before any resource is touched — this is
why the backend bucket can't be created by the same config that uses it as a backend
(chicken-and-egg: init needs the bucket to already exist). Bucket must be created out of
band (manually or via a separate bootstrap config).
Lab 3 — Implicit Dependency (IAM Binding)¶
Concept: Terraform infers ordering automatically when one resource references another
resource's attribute — no depends_on needed.
Code added:
resource "google_project_iam_member" "sa_sql_client" {
project = var.project_id
role = "roles/cloudsql.client"
member = "serviceAccount:${google_service_account.gsa.email}"
}
Commands run:
Verified: IAM & Admin → IAM → acme-web-app@... shows Cloud SQL Client role.
Key takeaway: Referencing google_service_account.gsa.email inside this block is what
creates the implicit dependency — Terraform must create the SA first to know its real email
before building the IAM member string. This also drives destroy order (reverse of create
order).
Lab 4 — count (indexed multiple resources)¶
Concept: count creates N copies of a resource block, addressed by numeric index.
Code:
variable "environments" {
type = list(string)
default = ["dev", "staging"]
}
resource "google_service_account" "env_sa" {
count = length(var.environments)
account_id = "acme-${var.environments[count.index]}"
display_name = "SA for ${var.environments[count.index]} env"
}
Commands run:
Verified: acme-dev, acme-staging SAs created; state shows
google_service_account.env_sa[0] and [1].
Key takeaway: Each index is a distinct resource instance in state. Removing an item from the middle of the list shifts every subsequent index — Terraform sees this as destroy+recreate for shifted resources, not just the removed one. (This is the flaw Lab 5 fixes.)
Lab 5 — for_each (keyed multiple resources)¶
Concept: for_each addresses resource instances by stable key instead of fragile index —
avoids the count-shift problem.
Code (replaced Lab 4 block):
variable "environments" {
type = set(string)
default = ["dev", "staging"]
}
resource "google_service_account" "env_sa" {
for_each = var.environments
account_id = "acme-${each.value}"
display_name = "SA for ${each.value} env"
}
Commands run:
Test performed: Removed "dev" from the set, leaving ["staging"], then re-ran
terraform plan.
Verified: Only env_sa["dev"] was marked for destroy — env_sa["staging"] was
untouched. Confirms for_each isolates changes per-key instead of shifting indices.
Key takeaway: Prefer for_each over count whenever items can be added/removed from
the middle of a collection. Reserve count for simple "N identical copies" or conditional
0/1 patterns.
Lab 6 — State Surgery: mv, rm, import¶
Concept: Commands for reconciling config/state/reality when they drift apart — renaming resources, orphaning them intentionally, and re-adopting existing cloud resources.
6a — Rename without destroy:
# after renaming resource label gsa -> backend_sa in .tf code
terraform state mv google_service_account.gsa google_service_account.backend_sa
terraform plan # should show no changes
6b — Remove from state (real resource untouched):
terraform state rm google_service_account.backend_sa
terraform state list
terraform plan # now wants to "create" — will fail, already exists
6c — Re-adopt via import:
terraform import google_service_account.backend_sa \
projects/YOUR_PROJECT_ID/serviceAccounts/acme-web-app@YOUR_PROJECT_ID.iam.gserviceaccount.com
terraform plan # should show no changes again
Verified: Status — done, results confirmed working as expected.
Key takeaway: state mv = rename in Terraform's ledger without touching real infra.
state rm = forget a resource (real thing untouched, now unmanaged). import = bind an
existing real resource to a config address. These three are the core toolkit for fixing
drift between code, state, and reality without destructive recreation.
Lab 7 — Data Sources¶
Concept: data blocks read existing resources (created outside this config, or by
another config) without managing their lifecycle — no create/update/destroy, just read.
Resolved during plan via a direct API call.
Code:
data "google_project" "current" {
project_id = var.project_id
}
output "project_number" {
value = data.google_project.current.number
}
data "google_compute_network" "default" {
name = "default"
}
output "default_network_self_link" {
value = data.google_compute_network.default.self_link
}
Commands run:
terraform plan
terraform apply
terraform output project_number
terraform output default_network_self_link
Verified:
project_number = "999543194632"
default_network_self_link = "https://www.googleapis.com/compute/v1/projects/my-devops-journey-502420/global/networks/default"
Key takeaway: Data sources show as reads in plan, never as create/destroy. Use them
to reference infra you don't own/manage in this config (existing networks, projects, IAM
policies, etc.) instead of hardcoding IDs.
Lab 8 — locals (computed/derived values)¶
Concept: locals compute a value once inside the config and let you reuse it —
unlike variable, they're never set from outside (no .tfvars, no CLI flags), only
derived from other values.
Code:
locals {
name_prefix = "acme"
common_labels = {
project = var.project_id
managed_by = "terraform"
environment = "sandbox"
}
}
resource "google_service_account" "gsa" {
account_id = "${local.name_prefix}-web-app"
display_name = "My Test App"
}
resource "google_storage_bucket" "demo" {
name = "${local.name_prefix}-demo-${var.project_id}"
location = "US"
labels = local.common_labels
uniform_bucket_level_access = true
}
Commands run:
Error hit + fixed: Error 412: constraints/storage.uniformBucketLevelAccess — org
policy blocks legacy ACL buckets. Fixed by explicitly setting
uniform_bucket_level_access = true. Same category as the earlier disabled-SA-key-creation
policy — an org policy constraint, not a Terraform bug. When apply fails with
Error 412 + constraints/..., the fix is almost always one extra field matching the
policy's requirement.
Verified: Bucket created with project, managed_by, environment labels visible in
console under Cloud Storage → bucket → Labels tab.
Key takeaway: locals reduce repeated expressions across resources. Refactoring an
existing resource to reference a local instead of a literal/inline expression should
produce a no-op plan if the computed value is identical — a good way to confirm a
refactor didn't accidentally change real infrastructure.
Lab 9 — Modules (packaging reusable infra)¶
Concept: A module is a directory of .tf files treated as a reusable unit — inputs in,
resources created, outputs out. The main working directory is always the "root module";
anything under it referenced via module "name" { source = "./path" } is a child module.
Structure:
acme-sampleapp/
├── main.tf (root — calls the module)
├── variables.tf
├── outputs.tf
└── modules/
└── service_account/
├── main.tf
├── variables.tf
└── outputs.tf
test/modules/service_account/variables.tf:
variable "account_id" {
type = string
}
variable "display_name" {
type = string
}
variable "project_id" {
type = string
}
test/modules/service_account/main.tf:
resource "google_service_account" "this" {
project = var.project_id
account_id = var.account_id
display_name = var.display_name
}
test/modules/service_account/outputs.tf:
output "email" {
value = google_service_account.this.email
}
output "account_id" {
value = google_service_account.this.account_id
}
Root main.tf addition:
module "backend_sa" {
source = "test/modules/service_account"
project_id = var.project_id
account_id = "${local.name_prefix}-web-app-v2"
display_name = "My Test App v2 (via module)"
}
output "backend_sa_email" {
value = module.backend_sa.email
}
Commands run:
Verified: terraform state list includes
module.backend_sa.google_service_account.this — module-created resources get a
module.<name>. prefix in state addressing, distinguishing them from root-level resources.
Key takeaway: Modules are just parameterized, reusable resource groupings. Inputs come
in via variable blocks (set by the caller), outputs go out via output blocks (read by
the caller as module.<name>.<output>). State addressing nests under module.<name>..
Lab 10 — Workspaces (multi-environment state isolation)¶
Concept: Workspaces let one config manage multiple isolated state files — same
code, separate state per environment — without duplicating .tf files.
Commands:
terraform workspace list # shows "default" (used so far)
terraform workspace new dev
terraform workspace new staging
terraform workspace list # * marks current workspace
terraform workspace select dev
Code — reference terraform.workspace (built-in, no variable needed):
resource "google_storage_bucket" "workspace_demo" {
name = "${local.name_prefix}-${terraform.workspace}-demo-${var.project_id}"
location = "US"
uniform_bucket_level_access = true
labels = local.common_labels
}
Commands run:
terraform plan
terraform apply
terraform workspace select staging
terraform plan # wants to CREATE again — staging has its own empty state
terraform apply
terraform state list # only shows staging's resources
terraform workspace select dev
terraform state list # only shows dev's resources
Verified: Two separate buckets created — acme-dev-demo-... and
acme-staging-demo-... — each tracked only in its own workspace's state.
Key takeaway: Each workspace = same code, fully separate state. Switching workspaces
switches which state file plan/apply reads and writes — resources in one workspace are
invisible to another. Real-world caveat: workspaces don't let you vary provider config
(e.g. different GCP projects per environment) cleanly, so most production repos use
separate backend prefixes/directories per environment instead. Workspaces are worth
knowing for the isolation concept, but not always the production pattern.
Lab 11 — Import + Drift Detection¶
Concept: Three-part lab covering:
- (a) Terraform is blind to resources it doesn't manage,
- (b) drift detection reverts manual changes on managed resources back to match code,
- (c) import binds an existing unmanaged resource into state.
Part A — Unmanaged resource (created outside Terraform):
Expected: plan shows no changes — bucket isn't in config/state, so it's invisible to Terraform.Part B — Drift on a managed resource:
Manually edit a label (e.g. environment) on an existing Terraform-managed bucket
(from Lab 8/10) via console, then:
terraform plan # shows an update reverting the label back to code's value
terraform apply # reverts it in the real bucket
Part C — Import the manually-created bucket:
resource "google_storage_bucket" "manual_import" {
name = "acme-manual-bucket-YOUR_PROJECT_ID"
location = "US"
uniform_bucket_level_access = true
}
terraform import google_storage_bucket.manual_import acme-manual-bucket-YOUR_PROJECT_ID
terraform plan
location mismatch (created as us-central1, code says US) — a good
demonstration that import only binds state; it does not validate or correct your HCL
against reality. Any mismatch surfaces as a plan diff you must resolve yourself.
Status: Not yet run — pending your results.
Key takeaway (expected): Unmanaged = invisible to Terraform (no drift shown at all).
Managed = drift-detected and auto-corrected toward code on next apply. import only
populates state — you're responsible for making the HCL attributes actually match the real
resource, or the very next plan will show a diff.
Lab 12 — dynamic Blocks¶
Concept: dynamic generates repeated nested blocks (not top-level resources) from a
list/map — e.g. multiple lifecycle rules inside one bucket, or multiple bindings inside one
IAM policy — instead of writing each nested block by hand.
Code — multiple lifecycle rules on a bucket, generated from a list:
locals {
lifecycle_rules = [
{ age = 30, storage_class = "NEARLINE" },
{ age = 90, storage_class = "COLDLINE" },
{ age = 365, storage_class = "ARCHIVE" },
]
}
resource "google_storage_bucket" "lifecycle_demo" {
name = "${local.name_prefix}-lifecycle-${var.project_id}"
location = "US"
uniform_bucket_level_access = true
dynamic "lifecycle_rule" {
for_each = local.lifecycle_rules
content {
condition {
age = lifecycle_rule.value.age
}
action {
type = "SetStorageClass"
storage_class = lifecycle_rule.value.storage_class
}
}
}
}
Commands to run:
To verify: Cloud Storage → bucket → Lifecycle tab → confirm 3 rules present, matching the 30/90/365-day transitions.
Status: Not yet run — pending your results.
Key takeaway (expected): dynamic "<block_name>" + for_each + content {} is the
pattern for generating repeated nested blocks — different from resource-level for_each
(Lab 5), which generates repeated whole resources. Use dynamic when the repetition is
inside one resource, not across separate resource instances.
Lab 13 — for_each Over Modules¶
Concept: Apply for_each to a module call itself (not just a resource) — one module
invocation per key, instead of one hardcoded module block per instance.
Code — replace the single Lab 9 module call with a keyed set:
variable "service_accounts" {
type = map(object({
account_id = string
display_name = string
}))
default = {
dev = {
account_id = "acme-dev-app"
display_name = "Dev App SA"
}
staging = {
account_id = "acme-staging-app"
display_name = "Staging App SA"
}
}
}
module "app_sa" {
source = "test/modules/service_account"
for_each = var.service_accounts
project_id = var.project_id
account_id = each.value.account_id
display_name = each.value.display_name
}
output "app_sa_emails" {
value = {for k, m in module.app_sa : k => m.email}
}
Commands to run:
To verify: terraform state list shows module.app_sa["dev"].google_service_account.this
and module.app_sa["staging"].google_service_account.this. Console shows both SAs created.
Status: Not yet run — pending your results.
Key takeaway (expected): Module state addressing nests the for_each key the same way
resource for_each does: module.<name>["<key>"]. The output uses a for expression to
build a map from each module instance's own output — a common pattern for surfacing
per-instance values when using for_each over modules.
Lab 14 — Provider Aliasing (Multi-Region / Multi-Project)¶
Concept: A second provider "google" block with an alias lets you target a different
region/project from within the same config, and explicitly route specific resources to it
via provider = google.<alias>.
Code:
provider "google" {
alias = "us_east"
project = var.project_id
region = "us-east1"
}
resource "google_storage_bucket" "east_region_demo" {
provider = google.us_east
name = "${local.name_prefix}-east-${var.project_id}"
location = "US-EAST1"
uniform_bucket_level_access = true
}
Commands to run:
To verify: Cloud Storage → bucket → confirm location is US-EAST1, distinct from your
other buckets (created via the default, unaliased us-central1 provider).
Status: Not yet run — pending your results.
Key takeaway (expected): Without provider = google.<alias> on a resource, it uses the
default (unaliased) provider block. Aliasing is the mechanism behind real multi-region or
multi-project setups (e.g. a GKE cluster's primary and DR region, or resources split across
two GCP projects) — one config, multiple provider configurations, explicit routing per
resource.
Lab 15 — terraform.tfvars + Variable Precedence¶
Concept: Move hardcoded values into a .tfvars file instead of relying on default,
and observe Terraform's variable precedence order in practice.
Code — terraform.tfvars:
../.gitignore addition (if any value here were ever sensitive):
Commands to run — prove precedence:
terraform plan
# uses terraform.tfvars value
terraform plan -var="project_id=override-project"
# CLI flag wins — highest precedence
echo 'project_id = "auto-project"' > prod.auto.tfvars
terraform plan
# *.auto.tfvars wins over plain terraform.tfvars
Status: Not yet run — pending your results.
Key takeaway (expected): This lab directly demonstrates, hands-on, the precedence order
already documented in Theory §7: CLI flag > *.auto.tfvars >
terraform.tfvars > variable { default }. Running each command in sequence and watching
which value plan resolves to converts that memorized ordering into observed behavior.
Lab 16 — Lifecycle Meta-Arguments¶
Concept: lifecycle {} block controls how Terraform handles specific resources outside
normal create/update/destroy logic — guarding against destroys, or telling Terraform to stop
fighting over fields changed outside its control.
Code — prevent_destroy:
resource "google_service_account" "gsa" {
account_id = "${local.name_prefix}-web-app"
display_name = "My Test App"
lifecycle {
prevent_destroy = true
}
}
Commands to run:
terraform apply # apply with the guard in place
terraform destroy # should fail with an explicit prevent_destroy error
Code — ignore_changes (for a field modified outside Terraform, e.g. by another
automation adding labels):
resource "google_storage_bucket" "demo" {
name = "${local.name_prefix}-demo-${var.project_id}"
location = "US"
uniform_bucket_level_access = true
labels = local.common_labels
lifecycle {
ignore_changes = [labels]
}
}
Commands to run:
terraform apply
# manually add/change a label via console
terraform plan # should show NO changes now, despite drift on `labels`
Status: Not yet run — pending your results.
Key takeaway (expected): prevent_destroy is a safety rail for critical resources —
terraform destroy (or a plan that would replace the resource) errors out instead of
proceeding. ignore_changes tells Terraform to stop treating a specific field as
drift — useful when something outside Terraform (another automation, a human process)
legitimately owns that one field. Contrast with Lab 11 Part B, where drift was corrected —
ignore_changes is how you'd deliberately opt a field out of that correction behavior.
Lab 17 — Provisioners & null_resource (brief)¶
Concept: local-exec/remote-exec provisioners run arbitrary shell commands as part of
apply. Modern Terraform guidance treats these as a last resort/code smell — most cases are
better solved with cloud-init/startup scripts or dedicated config-management tools. Worth
recognizing since they appear in older or real-world repos (including the security exploit
pattern already covered in Theory §16
— a malicious PR using local-exec to exfiltrate state).
Code — minimal recognition example, not recommended for real use:
resource "null_resource" "example" {
provisioner "local-exec" {
command = "echo 'This runs on apply, outside any real GCP resource'"
}
}
Commands to run:
To verify: the echoed string appears in your terminal output during apply.
Status: Not yet run — pending your results.
Key takeaway (expected): null_resource + a provisioner isn't tied to any real cloud
resource — it's a way to run arbitrary local/remote commands as a side effect of apply. This
is exactly the mechanism a malicious CI/CD PR could abuse (see theory reference, GitOps
security section) — good to recognize both as a legacy pattern and as an attack vector, not
to reach for by default in new code.
Lab 18 — Capstone: Apply to Real acme-sampleapp Repo¶
Concept: Take every pattern practiced in the sandbox (Labs 1–17 — remote state, implicit
dependencies, count/for_each, data sources, locals, modules, workspaces, state surgery,
dynamic blocks, provider aliasing, variable precedence, lifecycle guards) and apply them to
the actual GKE/Cloud SQL/Workload Identity resources in your production learning repo,
rather than sandbox examples.
Suggested approach:
1. Re-read acme-sampleapp's existing modules/resources with fresh eyes — identify which
sandbox pattern each part maps to (e.g. its locals block ↔ Lab 8, its Consul data
blocks ↔ Lab 7, any for_each over environments ↔ Lab 5/13).
2. Pick one real, low-risk resource in the repo (not GKE/Cloud SQL directly — something
like an IAM binding or a bucket) and practice a full state-surgery cycle on it (Lab 6)
in a sandboxed workspace/environment, not production.
3. Identify one place in the repo that could benefit from a dynamic block or
lifecycle { ignore_changes } guard, and propose (don't necessarily apply) the change.
Status: Not started — this is the wrap-up milestone once Labs 11–17 are complete.