In 06-05 everything was said except the essential part. You know what the problem is — AlpinaShop's infrastructure exists only because Marta ran the right commands, and nobody would know how to reproduce it — you know what infrastructure as code is, you understand state and you know the migration procedure step by step. What is missing is the tool to do it with.

This lesson brings it, and with it the module closes. By the end, AlpinaShop's VPC, its subnets, its firewall rules and its buckets will be written in files that get reviewed in a pull request, applied from a pipeline and reproduced in another region by changing one variable. And the answer to "how long does it take to rebuild the environment?" will go from "nobody knows" to "about twenty minutes".

A word about scope: this lesson teaches enough Terraform to manage AlpinaShop's infrastructure with good judgement. Terraform is worth a whole course of its own, and what is covered here is what gets used 95 % of the time, flagging what is left out.

Contents

  1. Why Terraform won
  2. The concepts: provider, resource, data source, variable, output and dependencies
  3. State: what it contains and why it never goes into Git
  4. The remote backend in Cloud Storage
  5. The workflow: init, validate, plan, apply, destroy
  6. How to read a plan
  7. AlpinaShop's real code: the network and the bucket
  8. Variables, tfvars and environments
  9. Modules: writing the red-alpinashop module
  10. Public registry modules, with judgement
  11. Importing what already exists
  12. Terraform in CI/CD: plan on every pull request
  13. What Terraform should NOT manage
  14. prevent_destroy and the danger of terraform destroy
  15. Infrastructure Manager and alternatives
  16. AlpinaShop's final state

  1. Why Terraform won

In 06-05 you saw why Deployment Manager lost. The other side of the coin is why Terraform won, and it is not down to technical merit alone:

Reason Detail
Multi-provider GCP, AWS, Azure, Kubernetes, GitHub, Cloudflare, Datadog, PostgreSQL… with one language
Module ecosystem Thousands of reviewed and maintained public modules, many of them by Google
Maturity Since 2014, with the odd cases already solved and documented
Community Examples, books, courses and — very importantly — professionals who already know it
Adopted by Google itself Infrastructure Manager is managed Terraform
Readable language HCL is clearer than nested YAML or JSON

The first row matters more than it seems even for somebody who only uses GCP. AlpinaShop's real infrastructure is not just GCP: there is a DNS record at the domain registrar, repositories and branch protections on GitHub, perhaps an external CDN tomorrow. With Terraform, all of that is declared in the same place with the same flow, and dependencies between providers work exactly as they do within one.

A note on the licence, because one has to be honest: in 2023 HashiCorp moved Terraform from an open source licence to the Business Source License, which prompted the appearance of OpenTofu, a fork under an open licence managed by a foundation. For normal use — a company managing its own infrastructure — the BSL imposes no practical restriction, and both tools are compatible at the level of code and of state. It is worth knowing about; for AlpinaShop it changes nothing.

  1. The concepts: provider, resource, data source, variable, output and dependencies

Terraform is written in HCL (HashiCorp Configuration Language), and it has few kinds of block. With these six you do almost everything.

Provider (provider): the plugin that knows how to talk to an API. It is declared with its version pinned:

terraform {
  required_version = ">= 1.9"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.0"     # allows 6.x, does not jump to 7.x
    }
  }
}

provider "google" {
  project = var.proyecto
  region  = var.region
}

Pinning the provider version is not optional. Without version, Terraform downloads the latest available, and a major version can change the behaviour of existing resources. The ~> 6.0 syntax allows minor and patch updates but not the jump to 7, which is where the breaking changes are.

Resource (resource): something Terraform creates and manages.

resource "google_compute_network" "principal" {
  #        └── type                  └── LOCAL name, only within Terraform
  name                    = "alpinashop-vpc"   # the real name in GCP
  auto_create_subnetworks = false
}

The distinction between the local name (principal) and the name attribute (alpinashop-vpc) is confusing at first. The local one is the label other parts of the code use to reference the resource; the name attribute is what appears in the GCP console.

Data source (data): queries something that exists but that Terraform does not manage.

data "google_project" "actual" {}

data "google_compute_image" "debian" {
  family  = "debian-12"
  project = "debian-cloud"     # public Google image
}

The difference from a resource is total: a data only reads, it never creates or modifies. It is used to obtain the project number, the latest image of an operating system or a resource created by another team.

Variable (variable): a parameterisable input.

variable "entorno" {
  description = "Deployment environment"
  type        = string

  validation {
    condition     = contains(["dev", "prod"], var.entorno)
    error_message = "The environment must be 'dev' or 'prod'."
  }
}

variable "cidr_web" {
  description = "CIDR range of the web subnet"
  type        = string
  default     = "10.10.0.0/24"
}

The validation block is the equivalent of Deployment Manager's schemas from 06-05, and it produces a clear error before anything is touched.

Output (output): a value exposed when the run finishes.

output "id_red" {
  description = "Identifier of the main VPC"
  value       = google_compute_network.principal.id
}

output "password_bd" {
  value     = google_sql_user.app.password
  sensitive = true      # not printed to the console
}

sensitive = true stops the value appearing in the output. It does not encrypt it or hide it from the state, which is an important distinction and one we come back to in section 3.

Dependencies. Most are implicit: they arise from referencing one resource from another.

resource "google_compute_subnetwork" "web" {
  name          = "sn-web-euw1"
  ip_cidr_range = var.cidr_web
  region        = var.region
  # This reference DECLARES that the subnet depends on the network
  network       = google_compute_network.principal.id
}

Terraform builds a graph, creates the network first and then the subnet, and parallelises everything that has no dependencies between the parts. Explicit ones with depends_on are the last resort, for dependencies that are not expressed through data:

resource "google_compute_instance" "app" {
  # ...
  # The API must be enabled first, but that is not reflected in any attribute
  depends_on = [google_project_service.compute]
}

Tip: use depends_on as little as possible. A depends_on that could be replaced by a reference is a missed opportunity for the code to express the real relationship.

  1. State: what it contains and why it never goes into Git

In 06-05 state was explained conceptually. Here comes the practical part, which is where the real problems live.

The terraform.tfstate file is a JSON containing, for each managed resource: its local name, its type, its real identifier in GCP and all of its attributes as they were at the last operation.

{
  "version": 4,
  "terraform_version": "1.9.5",
  "serial": 42,
  "lineage": "8f3e...",
  "resources": [
    {
      "mode": "managed",
      "type": "google_sql_user",
      "name": "app",
      "instances": [{
        "attributes": {
          "name": "catalogo",
          "instance": "alpinashop-pedidos",
          "password": "P4ssw0rdInPlainText..."
        }
      }]
    }
  ]
}

Look at that last line. The password is in plain text in the state file. It is not a configuration mistake: it is the normal behaviour. Terraform stores every attribute of every resource, and some attributes are secrets. The same happens with generated keys, tokens and certificates.

Hence the four rules of state:

Rule Reason
Never into Git It contains plain-text secrets, and Git does not forget (06-02)
A shared remote backend If everybody has their own local copy, the team treads on each other
With locking Two simultaneous applys corrupt the state
With versioning A corrupted or deleted state is recovered from an earlier version

And an important consequence that follows from all this: access to the state is as sensitive as access to production. Whoever can read the state bucket can read the passwords Terraform manages. That is why section 13 insists that Terraform must not manage secret values.

The .gitignore is not negotiable:

*.tfstate
*.tfstate.*
*.tfstate.backup
.terraform/
.terraform.lock.hcl.bak
crash.log
*.tfvars.secret
override.tf

With one deliberate exception: .terraform.lock.hcl DOES go into Git. That file pins the exact provider versions and their checksums, and versioning it guarantees that the whole team and the pipeline use exactly the same thing. It is the equivalent of a package-lock.json.

  1. The remote backend in Cloud Storage

The backend defines where the state lives. For GCP, Cloud Storage.

First the bucket is created, and this is the only thing done by hand in the whole process — the chicken-and-egg problem:

gcloud storage buckets create gs://alpinashop-terraform-estado \
  --project=alpinashop-cicd \
  --location=europe-west1 \
  --uniform-bucket-level-access \
  --public-access-prevention

# VERSIONING: essential, it allows a corrupted state to be recovered
gcloud storage buckets update gs://alpinashop-terraform-estado --versioning

# Restricted access: whoever reads this reads secrets
gcloud storage buckets add-iam-policy-binding gs://alpinashop-terraform-estado \
  --member='group:[email protected]' \
  --role=roles/storage.objectAdmin \
  --project=alpinashop-cicd

And it is declared in the code:

terraform {
  backend "gcs" {
    bucket = "alpinashop-terraform-estado"
    prefix = "prod/red"       # a different path per environment and component
  }
}

The prefix matters more than it seems: it separates states. prod/red, prod/datos, dev/red are independent states, and that separation limits the blast radius: a mistake while operating the network cannot affect the database's state.

Locking works on its own, with no configuration. Terraform creates a .tflock object while it operates; if somebody else launches apply at the same time, they get a clear error instead of corrupting the state:

Error: Error acquiring the state lock
Lock Info:
  ID:        b3d4e5f6
  Who:       [email protected]
  Created:   2026-08-05 18:42:11 UTC

If a process dies leaving the lock in place — a cancelled build, for instance — it is released with terraform force-unlock ID. It is a command to use carefully: only when you are sure there is no operation in progress.

  1. The workflow: init, validate, plan, apply, destroy

flowchart LR
    A[init<br/>download providers<br/>configure backend] --> B[validate<br/>syntax and types]
    B --> C[plan<br/>WHAT IS GOING TO HAPPEN]
    C --> D{Is the plan<br/>right?}
    D -->|No| E[Fix the code]
    E --> C
    D -->|Yes| F[apply<br/>execute]
    F --> G[Infrastructure<br/>updated]
# 1. init: downloads providers and configures the backend. Run at the start
#    and every time providers or modules change.
terraform init

# 2. fmt and validate: canonical formatting and syntax and type checking.
#    Fast, they touch nothing, ideal for a pre-commit hook (06-02).
terraform fmt -recursive
terraform validate

# 3. plan: THE MOST IMPORTANT OPERATION. Compares code, state and reality.
terraform plan -out=plan.tfplan

# 4. apply: executes the saved plan. With the file, it applies EXACTLY
#    what you reviewed; without it, it recalculates and could do something else.
terraform apply plan.tfplan

# Utilities
terraform show                 # view the state in readable form
terraform state list           # list managed resources
terraform output id_red        # query an output

The -out=plan.tfplan detail deserves emphasis because it is the difference between reviewing and trusting. Without it, terraform apply recalculates the plan at that moment; if something changed in between, it applies something different from what was reviewed. With the file, it applies exactly what was approved. In a pipeline it is mandatory.

And terraform destroy destroys everything in the state. Its legitimate use is an ephemeral test environment. In production it is the tool's most dangerous command, and section 14 explains how to protect yourself.

  1. How to read a plan

Knowing how to read a plan is the most important skill in this lesson. A badly reviewed apply is an incident.

The four symbols:

Symbol Meaning Level of attention
+ Create a new resource Check that it is what you expect
~ Modify in place Read which field changes
- Destroy High: why is it disappearing?
-/+ Destroy and recreate MAXIMUM: stop and understand why

And the summary line at the end:

Plan: 3 to add, 2 to change, 1 to destroy.

The golden rule is the -/+ one. A replacement happens when an attribute changes that the GCP API does not allow to be modified in place. Terraform, with no other option, destroys and creates. And on some resources that is catastrophic:

  # google_sql_database_instance.pedidos must be replaced
-/+ resource "google_sql_database_instance" "pedidos" {
      ~ region = "europe-west1" -> "europe-west4" # forces replacement
    }

That plan would destroy AlpinaShop's orders database with all its data. Terraform says so clearly — # forces replacement — but an automatic or distracted apply executes it without hesitation.

Resource Is a -/+ acceptable? Why
Firewall rule Yes It is recreated in seconds
Instance template Yes They are immutable by design
Subnet No It disconnects everything inside it
Cloud SQL NEVER without an explicit plan Data loss
Bucket with contents NEVER Loss of objects
GKE cluster No Complete downtime

What to do about an unexpected -/+, in this order: read which attribute is forcing it — Terraform marks it with # forces replacement; decide whether that change is really necessary; if it is, look for a way without destroying — create the new resource, migrate and delete the old one; and if it is not, fix the code. Never apply without having understood the cause.

Another case that causes confusion: plans that never come out empty even when you change nothing. They are usually down to an attribute that GCP normalises — a list it reorders, a default value it fills in — and they are solved either by writing the real value into the code or, when the provider behaves incorrectly, with lifecycle { ignore_changes = [...] }. That second option is a patch and is best used sparingly: each ignore_changes is a part of the infrastructure Terraform stops watching.

  1. AlpinaShop's real code: the network and the bucket

Now the real code, explained line by line.

# ============================================================
# red.tf — AlpinaShop's network
# ============================================================

resource "google_compute_network" "principal" {
  name = "alpinashop-vpc"

  # false = subnets created by us, with the ranges we decide on.
  # With true, GCP would create an automatic subnet in EVERY region (03-01).
  auto_create_subnetworks = false

  # REGIONAL: routes only propagate within the region. Enough
  # for AlpinaShop and with less surface than GLOBAL.
  routing_mode = "REGIONAL"

  description = "AlpinaShop main VPC - managed with Terraform"
}

resource "google_compute_subnetwork" "web" {
  name          = "sn-web-euw1"
  ip_cidr_range = var.cidr_web
  region        = var.region

  # Reference: creates the implicit dependency with the network
  network = google_compute_network.principal.id

  # CRITICAL: lets VMs WITHOUT a public IP reach Google's APIs
  # (Cloud Storage, Secret Manager, Logging). Without it, half of module 3 breaks.
  private_ip_google_access = true

  # Flow logs with sampling: network diagnosis without runaway cost (06-06)
  log_config {
    aggregation_interval = "INTERVAL_5_SEC"
    flow_sampling        = 0.25
    metadata             = "INCLUDE_ALL_METADATA"
  }
}

resource "google_compute_subnetwork" "datos" {
  name                     = "sn-datos-euw1"
  ip_cidr_range            = var.cidr_datos
  region                   = var.region
  network                  = google_compute_network.principal.id
  private_ip_google_access = true
}
# ============================================================
# firewall.tf — Firewall rules
# ============================================================

resource "google_compute_firewall" "permitir_salud" {
  name    = "fw-permitir-salud"
  network = google_compute_network.principal.name

  allow {
    protocol = "tcp"
    ports    = ["8080"]
  }

  # FIXED ranges of Google's health-check probes (03-02).
  # They are not arbitrary addresses: if they are removed, hc-catalogo marks
  # every instance as unhealthy and the load balancer stops sending traffic.
  source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
  target_tags   = ["catalogo-web"]

  description = "Allows the hc-catalogo probes towards /salud on 8080"
}

resource "google_compute_firewall" "permitir_sql_interno" {
  name    = "fw-permitir-sql-interno"
  network = google_compute_network.principal.name

  allow {
    protocol = "tcp"
    ports    = ["5432"]
  }

  # Only from the web subnet: the database is exposed to nothing else
  source_ranges = [var.cidr_web]
  target_tags   = ["base-datos"]

  description = "PostgreSQL reachable exclusively from the web subnet"
}

# Deny and LOG the rest of the incoming traffic.
# Priority 65000: evaluated last, after all the permissive ones.
resource "google_compute_firewall" "denegar_resto" {
  name     = "fw-denegar-resto"
  network  = google_compute_network.principal.name
  priority = 65000
  deny { protocol = "all" }
  source_ranges = ["0.0.0.0/0"]

  # Denied attempts are logged: the basis for investigating (06-06)
  log_config { metadata = "INCLUDE_ALL_METADATA" }
}
# ============================================================
# almacenamiento.tf — Catalogue bucket
# ============================================================

resource "google_storage_bucket" "catalogo" {
  name     = "alpinashop-catalogo"
  location = var.region

  # Uniform access: IAM only, no per-object ACL. Far simpler to audit.
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning { enabled = true }

  # Lifecycle: old versions drop a class and are deleted (02-02)
  lifecycle_rule {
    condition {
      age                = 30
      with_state         = "ARCHIVED"
      num_newer_versions = 3
    }
    action { type = "Delete" }
  }

  lifecycle_rule {
    condition { age = 90 }
    action {
      type          = "SetStorageClass"
      storage_class = "NEARLINE"
    }
  }

  labels = {
    entorno      = var.entorno
    equipo       = "infraestructura"
    centro-coste = "tienda"
    aplicacion   = "catalogo"
  }

  # Protection: stops a 'terraform destroy' deleting the bucket (section 14)
  lifecycle {
    prevent_destroy = true
  }
}

Notice two things about this code that are the whole point of the exercise. First, the comments explain the why, not the what: that 35.191.0.0/16 are Google's probes, that private_ip_google_access breaks half of module 3 if it is missing. That knowledge lived in Marta's head and now lives in the repository. And second, the entorno, equipo, centro-coste and aplicacion labels are the ones from the whole course: being code, they stop being applied "when somebody remembers" and get applied every time.

  1. Variables, tfvars and environments

The goal is for alpinashop-dev and alpinashop-prod to share the same code with different values, solving the configuration drift from 06-05.

# variables.tf — the same one for both environments
variable "proyecto" {
  description = "GCP project ID"
  type        = string
}

variable "entorno" {
  description = "Environment: dev or prod"
  type        = string
  validation {
    condition     = contains(["dev", "prod"], var.entorno)
    error_message = "The environment must be 'dev' or 'prod'."
  }
}

variable "region" {
  type    = string
  default = "europe-west1"
}

variable "cidr_web" {
  type    = string
  default = "10.10.0.0/24"
}

variable "cidr_datos" {
  type    = string
  default = "10.20.0.0/24"
}

variable "tamano_instancia_sql" {
  description = "Cloud SQL machine type"
  type        = string
  default     = "db-g1-small"     # the cheap value as the default
}
# entornos/dev.tfvars
proyecto             = "alpinashop-dev"
entorno              = "dev"
cidr_web             = "10.110.0.0/24"     # different ranges: avoids overlaps
cidr_datos           = "10.120.0.0/24"
tamano_instancia_sql = "db-g1-small"
# entornos/prod.tfvars
proyecto             = "alpinashop-prod"
entorno              = "prod"
cidr_web             = "10.10.0.0/24"
cidr_datos           = "10.20.0.0/24"
tamano_instancia_sql = "db-custom-4-15360"
terraform plan -var-file=entornos/dev.tfvars -out=dev.tfplan
terraform apply dev.tfplan

Folders per environment versus workspaces, which is the structural decision that has to be taken:

Aspect Folders per environment Workspaces
Structure entornos/dev/, entornos/prod/ One directory, several states
State A different backend per folder Same backend, different prefixes
Divergence between environments Possible and visible Hard
Risk of hitting the wrong environment Low: you are in another folder High: it is one command
Code duplication Some, mitigated with modules None
Recommendation For prod and dev Ephemeral, very similar environments

AlpinaShop chooses folders per environment, and the main reason is operational safety: with workspaces, the only difference between applying in development and applying in production is having run terraform workspace select prod beforehand, and it is far too easy to forget. With folders, each one has its own backend and its own provider, and applying in production requires physically being in the production directory. It is a small barrier that avoids the most expensive mistake.

The resulting structure of the alpinashop-infra repository:

alpinashop-infra/
├── modulos/
│   └── red-alpinashop/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── entornos/
│   ├── dev/
│   │   ├── main.tf          # invokes the modules
│   │   ├── backend.tf       # prefix = "dev/"
│   │   └── dev.tfvars
│   └── prod/
│       ├── main.tf
│       ├── backend.tf       # prefix = "prod/"
│       └── prod.tfvars
└── paneles/                 # the JSON files from 06-04

  1. Modules: writing the red-alpinashop module

A module is a directory of Terraform code invoked with parameters. It is the reuse mechanism.

# modulos/red-alpinashop/variables.tf
variable "nombre_red"  { type = string }
variable "region"      { type = string }
variable "cidr_web"    { type = string }
variable "cidr_datos"  { type = string }
variable "entorno"     { type = string }

variable "habilitar_nat" {
  description = "Create Cloud NAT for egress without a public IP"
  type        = bool
  default     = true
}
# modulos/red-alpinashop/main.tf
resource "google_compute_network" "esta" {
  name                    = var.nombre_red
  auto_create_subnetworks = false
  routing_mode            = "REGIONAL"
}

resource "google_compute_subnetwork" "web" {
  name                     = "sn-web-${substr(var.region, 0, 8)}"
  ip_cidr_range            = var.cidr_web
  region                   = var.region
  network                  = google_compute_network.esta.id
  private_ip_google_access = true
}

resource "google_compute_subnetwork" "datos" {
  name                     = "sn-datos-${substr(var.region, 0, 8)}"
  ip_cidr_range            = var.cidr_datos
  region                   = var.region
  network                  = google_compute_network.esta.id
  private_ip_google_access = true
}

# The router and the NAT are only created if asked for: count with a condition
resource "google_compute_router" "router" {
  count   = var.habilitar_nat ? 1 : 0
  name    = "${var.nombre_red}-router"
  region  = var.region
  network = google_compute_network.esta.id
}

resource "google_compute_router_nat" "nat" {
  count  = var.habilitar_nat ? 1 : 0
  name   = "${var.nombre_red}-nat"
  router = google_compute_router.router[0].name
  region = var.region

  nat_ip_allocate_option             = "AUTO_ONLY"
  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"

  log_config {
    enable = true
    filter = "ERRORS_ONLY"     # errors only: the full volume is enormous
  }
}
# modulos/red-alpinashop/outputs.tf
output "id_red"      { value = google_compute_network.esta.id }
output "nombre_red"  { value = google_compute_network.esta.name }
output "id_sn_web"   { value = google_compute_subnetwork.web.id }
output "id_sn_datos" { value = google_compute_subnetwork.datos.id }

And how it is used from each environment:

# entornos/prod/main.tf
module "red" {
  source = "../../modulos/red-alpinashop"

  nombre_red    = "alpinashop-vpc"
  region        = var.region
  cidr_web      = var.cidr_web
  cidr_datos    = var.cidr_datos
  entorno       = "prod"
  habilitar_nat = true
}

# The module's outputs are consumed like any other value
resource "google_compute_firewall" "permitir_salud" {
  name          = "fw-permitir-salud"
  network       = module.red.nombre_red
  source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
  target_tags   = ["catalogo-web"]
  allow {
    protocol = "tcp"
    ports    = ["8080"]
  }
}

The three rules of a good module:

  1. One clear responsibility. red-alpinashop does the network. A module that creates the network, the database and the load balancer is a monolith by another name.
  2. Parameterise what varies, fix what does not. private_ip_google_access = true is fixed on purpose: it is not a decision each environment should take, it is an AlpinaShop rule.
  3. Expose the outputs others need. A module whose outputs are not enough forces people to break its encapsulation.

And a warning about over-abstraction, which is the most common mistake when people discover modules: a module with twenty variables to cover every imaginable case is harder to use than writing the resource directly. Start with flat code, extract a module when you repeat it for the second time, and not before.

  1. Public registry modules, with judgement

The Terraform Registry hosts thousands of public modules, and Google maintains a collection of GCP modules of notable quality.

module "red" {
  source  = "terraform-google-modules/network/google"
  version = "~> 9.0"        # ALWAYS pin the version

  project_id   = var.proyecto
  network_name = "alpinashop-vpc"

  subnets = [
    {
      subnet_name           = "sn-web-euw1"
      subnet_ip             = "10.10.0.0/24"
      subnet_region         = "europe-west1"
      subnet_private_access = "true"
    },
  ]
}

The criteria for deciding whether to use a public module or write your own:

Criterion In favour of using it Against
Who maintains it Google, HashiCorp, a known organization An anonymous individual
Activity Recent commits, issues attended to Not updated in two years
Complexity it saves Resources with many interrelated pieces A trivial resource
Fit with your case It covers what you need It forces you to contort yourself
Readability You can read its code Five levels of abstraction

The rule I use: the more complex the resource, the more a public module pays off. A module for creating a VPC with two subnets saves little and adds an external dependency; a module for setting up a Shared VPC with hierarchical rules, or a hardened GKE cluster with all its best practices, saves weeks of work and of mistakes.

And two serious warnings. Always pin the version with version = "~> X.Y": without it, a terraform init in the pipeline can pull a new version of the module that changes production resources without anybody having decided so. And a public module is third-party code with permissions over your infrastructure: for modules from unknown organizations, read it first, or fork it into your own repository. It is exactly the same software supply chain reasoning as in 06-01.

  1. Importing what already exists

This is the moment to apply the procedure from 06-05 with the real tool. Everything Marta created by hand in modules 2 and 3 has to become managed without destroying or recreating anything.

The modern way, with declarative import blocks:

# importaciones.tf — a temporary file, deleted when the migration is over
import {
  to = google_compute_network.principal
  id = "projects/alpinashop-prod/global/networks/alpinashop-vpc"
}

import {
  to = google_compute_subnetwork.web
  id = "projects/alpinashop-prod/regions/europe-west1/subnetworks/sn-web-euw1"
}

import {
  to = google_compute_firewall.permitir_salud
  id = "projects/alpinashop-prod/global/firewalls/fw-permitir-salud"
}

import {
  to = google_storage_bucket.catalogo
  id = "alpinashop-prod/alpinashop-catalogo"
}

And the trick that saves most of the manual work:

# Generates the HCL corresponding to the imported resources
terraform plan -generate-config-out=generado.tf

Terraform writes into generado.tf the configuration of the resources declared in the import blocks, read from reality. Then it has to be cleaned — remove computed fields, replace literals with references, add comments — exactly as explained in 06-05.

The complete procedure, with the cycle that has to be respected:

# 1. Import and generate
terraform plan -generate-config-out=generado.tf

# 2. Review and clean generado.tf, fold it into the final files

# 3. Apply: registers the resources in the state WITHOUT modifying them
terraform apply

# 4. THE SUCCESS CRITERION
terraform plan
# → "No changes. Your infrastructure matches the configuration."

# 5. Delete importaciones.tf, which is no longer needed

Tips so the migration does not get stuck:

  • Three at a time, not fifty at a time. A plan with fifty imported resources runs to hundreds of lines and the important differences get lost. Import a small group, verify the empty plan, move on.
  • Start with the harmless. Firewall rules and empty buckets first; Cloud SQL last.
  • Everything in alpinashop-dev first. A mistake there costs nothing.
  • prevent_destroy before the first apply on any resource containing data.
  • A -/+ always stops you. The rule from section 6, applied with even more reason during an import.

The identifier format varies by resource and is documented at the end of each one's page in the google provider. It is the most tedious detail, and there is no shortcut.

  1. Terraform in CI/CD: plan on every pull request

Here the whole module converges: the repository from 06-02, the pipeline from 06-01 and the infrastructure as code from 06-05.

The flow AlpinaShop puts in place:

flowchart TD
    A[Marta opens a PR<br/>on alpinashop-infra] --> B[Cloud Build:<br/>fmt, validate, plan]
    B --> C[The plan is posted<br/>as a PR comment]
    C --> D{Review:<br/>security CODEOWNERS}
    D -->|Changes requested| A
    D -->|Approved| E[Merge to main]
    E --> F[apply trigger<br/>with --require-approval]
    F --> G{Marta approves}
    G -->|Yes| H[terraform apply]
    G -->|No| I[Nothing is applied]

The plan pipeline, which runs on every pull request:

# cloudbuild-plan.yaml
steps:
  - name: 'hashicorp/terraform:1.9'
    id: 'formato-y-validacion'
    entrypoint: 'sh'
    dir: 'entornos/prod'
    args:
      - '-c'
      - |
        terraform fmt -check -recursive -diff || {
          echo "The code is not formatted. Run: terraform fmt -recursive"
          exit 1
        }
        terraform init -input=false
        terraform validate

  - name: 'hashicorp/terraform:1.9'
    id: 'plan'
    waitFor: ['formato-y-validacion']
    entrypoint: 'sh'
    dir: 'entornos/prod'
    args:
      - '-c'
      - |
        terraform plan -input=false -no-color \
          -var-file=prod.tfvars -out=/workspace/prod.tfplan | tee /workspace/plan.txt

        # Safety guard: warn if the plan destroys anything
        if grep -qE '^Plan:.*to destroy' /workspace/plan.txt; then
          echo "=========================================="
          echo "WARNING: this plan DESTROYS resources."
          echo "Mandatory review by gcp-seguridad@."
          echo "=========================================="
          grep -E '^\s+#.*(destroyed|must be replaced)' /workspace/plan.txt
        fi

  - name: 'gcr.io/cloud-builders/curl'
    id: 'publicar-en-pr'
    waitFor: ['plan']
    entrypoint: 'bash'
    args: ['-c', 'scripts/comentar-pr.sh /workspace/plan.txt']

artifacts:
  objects:
    location: 'gs://alpinashop-artefactos/planes/$BUILD_ID/'
    paths: ['prod.tfplan', 'plan.txt']

options:
  logging: CLOUD_LOGGING_ONLY

The step that posts the plan as a pull request comment is the one that changes the team's culture. Without it, reviewing an infrastructure change forces you to read HCL and imagine the effect. With it, the reviewer sees exactly which resources are created, modified and destroyed, without running anything. It is the literal application of the principle from 06-05: review infrastructure the way you review code.

The apply pipeline, with manual approval:

# cloudbuild-apply.yaml
steps:
  - name: 'hashicorp/terraform:1.9'
    entrypoint: 'sh'
    dir: 'entornos/prod'
    args:
      - '-c'
      - |
        terraform init -input=false
        terraform apply -input=false -auto-approve /workspace/prod.tfplan
timeout: '3600s'

Notice that it applies the plan file generated in the PR, not a recalculated one. That is what guarantees that exactly what was approved gets executed. And the -auto-approve is not dangerous here precisely because of that: the human approval already happened, in the --require-approval trigger from 06-01.

And with no key at all. The pipeline's service account authenticates with Cloud Build's identity; if the apply ran in GitHub Actions, it would be with the Workload Identity Federation from 06-02. At no point in this flow does a JSON credentials file exist.

The permissions of the apply service account deserve a note: it is the most powerful account in the whole infrastructure, because it can modify networks, IAM and databases. It should be scoped to the resources it manages, not be Editor on the project, and its use should be audited. It is the same warning as in 06-01 raised a level.

  1. What Terraform should NOT manage

Knowing what to leave out is as important as knowing what to manage. The rule, already stated in 06-05: Terraform manages the shape, not the contents.

Do not manage Why Who manages it
Secret values They would end up in plain text in the state Secret Manager, value created outside (03-06)
A bucket's objects They are data, they change constantly The application
DB rows and schema Versioned migrations The app's pipeline (06-01)
A MIG's instances The MIG manages those itself The autoscaler
Pods and Deployments A different lifecycle Manifests and GKE (02-05)
Tables created by pipelines They are born and die on their own Dataflow, BigQuery (04-02)
ML models and artefacts The pipeline produces them Vertex AI (05-07)

The first row is the most important and the most often breached. Look at the difference:

# BAD: the password ends up in plain text in the state file
resource "google_sql_user" "app" {
  name     = "catalogo"
  instance = google_sql_database_instance.pedidos.name
  password = "P4ssw0rd-real"     # ← in the .tfstate, in plain text
}

# GOOD: Terraform creates the secret's CONTAINER; the value is set outside
resource "google_secret_manager_secret" "db_password" {
  secret_id = "db-password-catalogo"
  replication {
    user_managed {
      replicas { location = "europe-west1" }
    }
  }
}
# And the value is set with gcloud or from the application:
#   gcloud secrets versions add db-password-catalogo --data-file=-

And an intermediate case worth resolving properly: a randomly generated password. random_password also ends up in the state, so the right solution is for the provider to generate it — Cloud SQL can do that — or an external process, with Terraform only creating the container.

The question that settles the doubts: does this change on its own, or does it change because somebody decides it? What changes on its own — data, autoscaled instances, pipeline tables — is not Terraform's. What changes because somebody decides it, is.

  1. prevent_destroy and the danger of terraform destroy

terraform destroy destroys everything in the state. It is a legitimate operation for ephemeral environments and a catastrophe in production, and there are three ways of running it by accident: typing it in the wrong directory, an apply with an unreviewed -/+, and deleting resources from the code without realising that deleting them means destroying them.

The first-line protection, prevent_destroy, on anything containing data:

resource "google_sql_database_instance" "pedidos" {
  name             = "alpinashop-pedidos"
  database_version = "POSTGRES_15"
  region           = var.region

  settings {
    tier              = var.tamano_instancia_sql
    availability_type = var.entorno == "prod" ? "REGIONAL" : "ZONAL"

    backup_configuration {
      enabled                        = true
      point_in_time_recovery_enabled = true
      start_time                     = "03:00"
    }
  }

  # Cloud SQL API's own protection, independent of Terraform
  deletion_protection = true

  lifecycle {
    # Any plan that destroys this resource FAILS with an explicit error
    prevent_destroy = true
  }
}

With prevent_destroy = true, a plan that tries to destroy the resource does not run:

Error: Instance cannot be destroyed
Resource google_sql_database_instance.pedidos has lifecycle.prevent_destroy set,
but the plan calls for this resource to be destroyed.

It is an error from the tool, not a warning. To really destroy it you have to edit the code, remove the protection and apply again: two deliberate steps instead of one accidental one.

AlpinaShop's five layers of protection, defence in depth:

Layer What it protects Against what
prevent_destroy Resources with data Accidental destroy and -/+
deletion_protection Cloud SQL and GKE Deletion by any route, including the console
Plan review in the PR Everything Human error, with another pair of eyes
Manual approval of the apply Production Applying something unreviewed
Restrictive IAM Production People who should not being able to apply

And a clarification about the reach of prevent_destroy: it protects the resource, not the state. If somebody deletes the block from the code, Terraform does not see it and protects nothing. The real protection comes from layers 3, 4 and 5 together.

  1. Infrastructure Manager and alternatives

Running Terraform yourself means managing the state bucket, the locking, the versions and the pipeline's permissions. Infrastructure Manager is Google's managed service that does all of that by running Terraform underneath:

gcloud infra-manager deployments apply \
  projects/alpinashop-prod/locations/europe-west1/deployments/red-prod \
  --service-account=projects/alpinashop-prod/serviceAccounts/[email protected] \
  --git-source-repo=https://github.com/alpinashop/alpinashop-infra \
  --git-source-directory=entornos/prod \
  --git-source-ref=main \
  --input-values=entorno=prod
Aspect Your own Terraform Infrastructure Manager
State Your bucket, your responsibility Managed
Locking Automatic in GCS Managed
Authentication The pipeline's service account Native to GCP
History In Cloud Build In the service itself
Multi-provider Yes Yes, but designed for GCP
Pipeline flexibility Total Lower

For AlpinaShop, which already has the pipeline from 06-01 working and wants full control over the flow, its own Terraform is the choice. Infrastructure Manager is excellent for teams that prefer not to manage state and live exclusively on GCP.

The alternatives to Terraform itself, to have the complete map:

Tool Model Strong at Weak at
Terraform / OpenTofu Declarative HCL Standard, ecosystem, multi-provider Your own state, HCL limited as a language
Pulumi Python, TypeScript, Go Real languages, loops and types Smaller ecosystem, risk of code that is too clever
Config Connector Kubernetes CRD Continuous reconciliation, GitOps Requires a cluster; GCP only
Crossplane Kubernetes CRD Multi-provider, internal platforms Complex, for large organizations
CDK for Terraform Languages on top of Terraform Combines both worlds An extra layer of indirection

Recommendation for AlpinaShop: Terraform, for the reason the lesson opens with — the ecosystem and the people who already know it. And an observation about Pulumi worth making: writing infrastructure in a general-purpose language is tempting and carries a real risk, because it is easy to write infrastructure that is too clever. HCL's limitation is partly a virtue: it forces the infrastructure to be readable by somebody who did not write the code.

  1. AlpinaShop's final state

It is worth looking at the before and after, because the journey has been a long one:

Aspect Before module 6 Now
Code Dani's laptop and Drive GitHub, reviewed, with branch protection
Deployment docker build by hand, latest tag Cloud Build, image tagged with the SHA
Tests "Optional depending on the rush" Mandatory, they block the merge
Production kubectl set image from a laptop Promotion with manual approval
Events Not implemented Cloud Functions, with idempotency and DLQ
Knowing whether it works A customer's email Alerts in 5 minutes, dashboard, uptime checks
Diagnosing Searching for text in logs Structured logs, traces, 17 minutes to the cause
Infrastructure Marta's terminal history Versioned, reviewed and reproducible HCL
Rebuilding the environment Nobody knows terraform apply, about twenty minutes
Drift between environments Real and unknown The same code, different tfvars
Who can deploy Only Dani Anyone opens a PR; the right people approve

That change in the last row is the module's cultural summary. The knowledge has left people's heads and entered the repository.

Common Mistakes and Tips

Pushing the .tfstate to Git. It contains plain-text secrets and Git does not forget. A remote backend in Cloud Storage with versioning, and .gitignore from the first commit.

Not pinning the provider or module versions. An init in the pipeline can pull a new version that changes production resources without anybody having decided so. version = "~> 6.0" always.

Running apply without a plan file. It recalculates the plan and can apply something different from what was reviewed. plan -out=file and apply file.

Ignoring a -/+. It is the most expensive mistake in the tool. A replacement on Cloud SQL or on a bucket with contents destroys data. Stop and work out which attribute is forcing it.

Managing secrets with Terraform. The value ends up in plain text in the state. Terraform creates the container in Secret Manager; the value is set outside.

Deleting resources from the code thinking they "stop being managed". Deleting them from the code means destroying them. To stop managing without destroying: terraform state rm.

Using workspaces to separate production from development. The only barrier is remembering workspace select, and one day somebody will not remember. Folders per environment.

Creating modules too early. A module with twenty variables to cover every case is worse than flat code. Extract a module the second time you repeat something.

Importing fifty resources at once. The resulting plan is unreadable and the dangerous differences get lost. Three at a time, verifying the empty plan.

Forgetting prevent_destroy on resources with data. It is two lines and it can save the database.

Carrying on touching things by hand. Infrastructure as code's original sin. As soon as the code and reality diverge, the tool stops being trustworthy and you are back where you started.

A final tip: start with the new stuff. If migrating everything that exists looks unmanageable, adopt the rule that from today, everything new is created with Terraform, and import the old as you touch it. In a few months most of it will be managed without ever having run a migration project.

Exercises

Exercise 1: write a reusable module

Write a bucket-alpinashop module that creates a Cloud Storage bucket with AlpinaShop's conventions: uniform access, public access prevention, configurable versioning, the four mandatory labels (entorno, equipo, centro-coste, aplicacion), a parameterisable lifecycle rule and prevent_destroy for production. Write its variables with validation, its outputs, and the block that invokes it to create alpinashop-datalake in production. Explain what you decided to parameterise and what you decided to fix, and why.

Exercise 2: analyse a dangerous plan

A pull request on alpinashop-infra produces this plan:

  # google_compute_firewall.permitir_ssh will be destroyed
  - resource "google_compute_firewall" "permitir_ssh" { ... }

  # google_compute_subnetwork.datos must be replaced
-/+ resource "google_compute_subnetwork" "datos" {
      ~ ip_cidr_range = "10.20.0.0/24" -> "10.20.0.0/22" # forces replacement
    }

  # google_sql_database_instance.pedidos will be updated in-place
  ~ resource "google_sql_database_instance" "pedidos" {
      ~ settings {
          ~ tier = "db-custom-4-15360" -> "db-custom-2-7680"
        }
    }

  # google_storage_bucket.datalake will be created
  + resource "google_storage_bucket" "datalake" { ... }

Plan: 1 to add, 1 to change, 1 to destroy, 1 to replace.

Analyse each change, classify it by risk, say whether you would approve the PR and what you would ask the author for. Indicate which is the most dangerous and why.

Exercise 3: design the complete infrastructure-as-code flow

Marta asks you to design AlpinaShop's infrastructure workflow end to end, assuming the migration from 06-05 is already complete. Define: the repository structure, the backend configuration and the separation of states, the Cloud Build pipelines needed, the IAM permissions of each service account involved, the branch protections and CODEOWNERS, and the emergency procedure for when something has to be changed in production at 3 in the morning with the pipeline down. Justify the security decisions.

Solutions

Solution 1

# modulos/bucket-alpinashop/variables.tf
variable "nombre" {
  description = "Bucket name, globally unique"
  type        = string
  validation {
    condition     = can(regex("^alpinashop-[a-z0-9-]+$", var.nombre))
    error_message = "The name must start with 'alpinashop-' and use lower case."
  }
}

variable "entorno" {
  type = string
  validation {
    condition     = contains(["dev", "prod"], var.entorno)
    error_message = "The environment must be 'dev' or 'prod'."
  }
}

variable "equipo"       { type = string }
variable "centro_coste" { type = string }
variable "aplicacion"   { type = string }

variable "region" {
  type    = string
  default = "europe-west1"
}

variable "versionado" {
  description = "Enable object versioning"
  type        = bool
  default     = true
}

variable "dias_a_nearline" {
  description = "Days after which to move to NEARLINE; 0 disables the rule"
  type        = number
  default     = 90
  validation {
    condition     = var.dias_a_nearline >= 0
    error_message = "It must be 0 or a positive number of days."
  }
}

variable "dias_borrado" {
  description = "Days after which to delete; 0 = never delete"
  type        = number
  default     = 0
}
# modulos/bucket-alpinashop/main.tf
resource "google_storage_bucket" "esta" {
  name     = var.nombre
  location = var.region

  # FIXED: they are AlpinaShop policy, not per-bucket decisions
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning { enabled = var.versionado }

  dynamic "lifecycle_rule" {
    for_each = var.dias_a_nearline > 0 ? [1] : []
    content {
      condition { age = var.dias_a_nearline }
      action {
        type          = "SetStorageClass"
        storage_class = "NEARLINE"
      }
    }
  }

  dynamic "lifecycle_rule" {
    for_each = var.dias_borrado > 0 ? [1] : []
    content {
      condition { age = var.dias_borrado }
      action { type = "Delete" }
    }
  }

  # Cleanup of old versions if versioning is on
  dynamic "lifecycle_rule" {
    for_each = var.versionado ? [1] : []
    content {
      condition {
        with_state         = "ARCHIVED"
        num_newer_versions = 3
        age                = 30
      }
      action { type = "Delete" }
    }
  }

  labels = {
    entorno      = var.entorno
    equipo       = var.equipo
    centro-coste = var.centro_coste
    aplicacion   = var.aplicacion
  }

  lifecycle {
    prevent_destroy = true
  }
}
# modulos/bucket-alpinashop/outputs.tf
output "nombre" { value = google_storage_bucket.esta.name }
output "url"    { value = google_storage_bucket.esta.url }
output "id"     { value = google_storage_bucket.esta.id }
# entornos/prod/almacenamiento.tf
module "bucket_datalake" {
  source = "../../modulos/bucket-alpinashop"

  nombre          = "alpinashop-datalake"
  entorno         = "prod"
  equipo          = "datos"
  centro_coste    = "analitica"
  aplicacion      = "datalake"
  versionado      = true
  dias_a_nearline = 60
  dias_borrado    = 0        # the data lake does not delete itself
}

What I parameterised and why:

Element Decision Reason
Name, labels Parameter Different in every bucket, obviously
Versioning Parameter A bucket of ephemeral logs does not need it
Lifecycle days Parameter The data lake and the images have different patterns
Region Parameter with a default Almost always europe-west1, but it can vary
Uniform access FIXED A security policy, not an option
Public access prevention FIXED The same: there is never a reason to disable it
prevent_destroy FIXED See the discussion below

The design principle: you parameterise what legitimately varies between cases; you fix what is an organization decision. Turning public_access_prevention into a variable would open the door to somebody, in a hurry, creating a public bucket "just for a test". If at some point a genuinely public bucket were needed — a static site — it is declared with google_storage_bucket directly and that exceptional case gets a specific review. A module also serves to make difficult what should not be done.

On prevent_destroy, a debatable decision worth reasoning through. I have fixed it to true always, rather than parameterising it per environment. The reason is that prevent_destroy does not accept expressions: the lifecycle block cannot use variables, so prevent_destroy = var.entorno == "prod" gives a syntax error. It is a real limitation of Terraform. The options are fixing it to true for everything — which forces a manual step to delete a test bucket, annoying but safe — or creating two modules. I have chosen the former: the annoyance of deleting a development bucket by hand is far smaller than the risk of destroying a production one.

And a warning about dynamic: dynamic blocks are powerful and they make the code harder to read. With three conditional lifecycle rules, the module is starting to approach the limit of what is reasonable. If they grew to eight, it would be better to expose a list-of-rules variable and let whoever uses it declare them explicitly.

Solution 2

Change-by-change analysis:

# Change Risk Diagnosis
1 - destroy fw-permitir-ssh Medium It may be intentional and good, or an oversight
2 -/+ replace sn-datos CRITICAL It destroys the database's subnet
3 ~ reduce Cloud SQL's tier High It does not destroy data, but it degrades production
4 + create the datalake bucket Low A new resource, with no side effects

Change 1 — destroying the SSH rule. It is the only change that could be an improvement: if that rule allowed SSH from broad ranges, removing it is exactly what 03-01 recommends. But a - destroy in a plan always calls for asking why it is disappearing. There are two possible causes with opposite implications: that the author deliberately deleted it from the code, or that they deleted it by accident while editing the file. What to ask for: that the pull request description explain it, and confirmation that nobody depends on that access — a supplier, a backup process, a support access route. If nobody knows, the safe alternative is to restrict the source rather than remove the rule, and observe for a week with the firewall logs from 06-06 before retiring it.

Change 2 — the subnet replacement. This is the one that gets the PR rejected.

Widening 10.20.0.0/24 to 10.20.0.0/22 is an apparently reasonable operation — more available addresses — and the GCP API does not allow a subnet's range to be changed in place when there are resources inside it. Terraform, with no other option, proposes destroying it and creating it.

What would happen on applying: destroying sn-datos-euw1 affects everything that lives in it, starting with the alpinashop-pedidos Cloud SQL instance and its private IP. In the best case, the operation fails halfway because GCP refuses to delete a subnet with resources in it, and the state is left inconsistent — Terraform believes it has started an operation it cannot finish. In the worst case, if something had been deletable, the private IPs would change and everything that references them would stop working. In neither case is the result acceptable.

And there is an alternative that makes the replacement completely unnecessary, and it is what should be proposed to the author: GCP allows an existing subnet's range to be widened without recreating it, using gcloud compute networks subnets expand-ip-range. The operation is non-destructive and only allows widening, never narrowing:

gcloud compute networks subnets expand-ip-range sn-datos-euw1 \
  --region=europe-west1 --prefix-length=22 --project=alpinashop-prod

Afterwards ip_cidr_range is updated in the code and terraform plan comes out empty, because reality already matches. It is a case where the right operation is done outside Terraform and the code simply reflects it, and it is worth acknowledging: forcing the tool to do something the API does not support well is worse than making a documented exception.

Change 3 — reducing Cloud SQL's tier. It does not destroy data and that is why a lot of people would wave it through. But going from db-custom-4-15360 to db-custom-2-7680 halves the CPU and memory of the production database, and it has two immediate consequences: an instance restart, that is to say, a few minutes of service interruption, and appreciably lower capacity for the same traffic.

The questions for the author: is it intentional or is it the wrong tfvars? And if it is intentional, is it justified with data? This is where 06-04 comes into play: if Cloud SQL's CPU has been under 20 % for months, it is a perfectly reasonable saving. If it is at 60 %, halving the capacity is provoking the next incident. A sizing change without the metric to back it up is guesswork. And in any case, it must be applied in a maintenance window, not on a Tuesday at eleven in the morning.

A reasonable suspicion, incidentally: that this change comes from having applied dev.tfvars to production by mistake, because db-custom-2-7680 looks very much like the development value. It is worth checking.

Change 4 — creating the bucket. The only harmless one. You check that it has uniform access, public access prevention, the four labels and its lifecycle, and that is that.

Would I approve the PR? No. And what I would ask for, in this order:

  1. Split the pull request into three. This PR mixes a security improvement, a network change, a sizing change and a new resource. They are four different intentions, with different risks and different moments to apply. A PR with a single intention gets reviewed well, approved quickly and reverted cleanly if it goes wrong.
  2. Take the subnet widening out of Terraform, do it with expand-ip-range and update the code afterwards.
  3. Justify the tier change with the CPU metric for the last two months, and schedule it in a maintenance window.
  4. Explain in the description why the SSH rule is disappearing and confirm that nobody depends on it.

The most dangerous is change 2, no argument, and for a reason worth spelling out: it is the only irreversible one. Change 3 is undone by going back to the previous tier; change 1 is undone by recreating the rule; change 4 is undone by deleting the bucket. A destroyed resource does not come back, and it takes with it the private IP addresses of everything it contained.

And the procedural lesson: this plan is exactly the argument in favour of posting the plan as a pull request comment. Reading only the modified HCL, the change from /24 to /22 looks like a minor one-character edit. It is the plan that reveals that this character destroys the database's subnet.

Solution 3

Structure of the alpinashop-infra repository:

alpinashop-infra/
├── modulos/
│   ├── red-alpinashop/
│   ├── bucket-alpinashop/
│   └── servicio-web/
├── entornos/
│   ├── dev/
│   │   ├── backend.tf        # prefix = "dev/"
│   │   ├── main.tf
│   │   ├── red.tf
│   │   ├── datos.tf
│   │   └── dev.tfvars
│   └── prod/
│       ├── backend.tf        # prefix = "prod/"
│       └── ... (same structure)
├── paneles/                  # JSON files from 06-04
├── alertas/                  # policies from 06-04
├── cloudbuild-plan.yaml
├── cloudbuild-apply.yaml
├── .pre-commit-config.yaml
├── CODEOWNERS
└── .gitignore

Backend and separation of states. The alpinashop-terraform-estado bucket in alpinashop-cicd, with versioning, uniform access and public access prevention. And states separated by environment and by domain:

Prefix Contains Reason for the separation
prod/red VPC, subnets, firewall, NAT, DNS Changes rarely, high blast radius
prod/datos Cloud SQL, buckets, BigQuery Contains data: maximum protection
prod/cómputo MIG, GKE, load balancer Changes often
prod/observabilidad Dashboards, alerts, sinks Changes a lot, zero risk
dev/* The same for development Total isolation

The separation of states is a security decision, not an organisational one. With a single state, any operation on any resource locks everything and a mistake has total reach. With separate states, touching a Cloud Monitoring dashboard cannot affect the database even in the worst case.

Pipelines:

Pipeline Trigger What it does Approval
infra-plan-dev PR against main fmt, validate, plan for dev, comment on the PR No
infra-plan-prod PR against main The same for prod No
infra-apply-dev Push to main apply in dev with the PR's plan No: automatic
infra-apply-prod Manual after the merge apply in prod Yes, --require-approval

Development being applied automatically is deliberate: it is how you guarantee that alpinashop-dev always reflects main, which is exactly what prevents the drift from 06-05. Production requires approval because of the impact.

IAM permissions:

Account Roles Scope Justification
sa-tf-plan viewer + storage.objectViewer on the state prod and dev Read-only: a plan modifies nothing
sa-tf-apply-dev Scoped editor alpinashop-dev only No access to production
sa-tf-apply-prod Specific roles per service alpinashop-prod only Never owner
People in gcp-infra@ viewer + roles/iam.serviceAccountTokenCreator prod No direct write access

The most important security decision: sa-tf-plan is read-only. The plan runs on every pull request, and anyone can open a pull request — including somebody with bad intentions who modifies cloudbuild-plan.yaml in their branch. If that account had write permissions, opening a PR would be enough to modify production. It is exactly the anti-pattern explained in 06-01, and here it is even more serious because we are talking about infrastructure.

The second: people have no direct write access in production. sa-tf-apply-prod is impersonated with serviceAccountTokenCreator, which is recorded in the audit logs with the person's name. It is the literal application of 03-04 and of "nobody is owner of prod".

Branch protection and CODEOWNERS:

# CODEOWNERS for alpinashop-infra
*                          @alpinashop/infraestructura
/entornos/prod/            @alpinashop/infraestructura @alpinashop/seguridad
/entornos/prod/red.tf      @alpinashop/seguridad
/entornos/*/iam.tf         @alpinashop/seguridad
/modulos/                  @alpinashop/infraestructura @alpinashop/seguridad

On main: direct pushes forbidden, a minimum of one approval (two for entornos/prod/), CI green mandatory, approvals that expire when new changes arrive, linear history, no exceptions for administrators and secret scanning.

The emergency procedure, which is the part of the exercise most people get wrong.

The naive answer is "give Marta permanent emergency permissions". It is a bad one: a permanent permission ends up being used outside emergencies and erodes everything else. The right answer has four elements:

1. Emergency access with logged impersonation. Marta belongs to a gcp-emergencia@ group that can impersonate sa-tf-apply-prod without going through the pipeline. It is not a hidden permission: it is a documented, audited, named route.

gcloud config set auth/impersonate_service_account \
  [email protected]
cd entornos/prod
terraform plan -var-file=prod.tfvars -out=emergencia.tfplan   # ALWAYS PLAN
terraform apply emergencia.tfplan

2. The plan is not skipped even in an emergency. It is the first thing people propose dropping in a hurry, and it is exactly the other way round: at 3 in the morning, sleepy and under pressure, is precisely when you most need to see what is going to be destroyed. It costs twenty seconds and it stops one incident becoming two.

3. An automatic alert when the emergency access is used. With the Pub/Sub sink from 06-06, any impersonation of sa-tf-apply-prod outside the pipeline publishes a message that reaches the security channel at the time. Not to prevent it, but so that everybody knows it happened.

4. Mandatory regularisation within 24 hours. The emergency change was made with main's code, so the state and the code match only if the change was made by editing the code. If it was done with gcloud by hand, there is drift. In either case, within the following 24 hours a pull request has to be opened documenting the change and proving with an empty plan that code and reality match again. Without that step, the first emergency is the beginning of the return to chaos, because from then on nobody trusts that the code is the truth.

And the reflection that closes the exercise. A well-designed emergency procedure does not remove the protections: it replaces them with traceability. In normal conditions, the protection is preventive — review, approval, scoped permissions. In an emergency, the protection is detective — immediate alert, audit record, mandatory regularisation. What must never exist is a route with neither of the two, because that route becomes the usual one.

Conclusion

AlpinaShop has its infrastructure written, versioned, reviewable and reproducible. The question that opened 06-05 — how long it takes to rebuild the environment — now has an answer: about twenty minutes and a terraform apply.

You know why Terraform won: multi-provider, a module ecosystem, maturity, community, and the definitive acknowledgement that Infrastructure Manager is Terraform managed by Google. With the honest note about the licence and OpenTofu.

You have mastered the concepts: provider with its version pinned because not doing so is letting an update decide for you; resource with its local name distinct from the real name; data source that only reads; variable with validation; output with sensitive, knowing that it hides but does not encrypt; and implicit dependencies arising from the references, with depends_on as the last resort.

You genuinely understand state: what it contains — including plain-text passwords — why it never goes into Git, and the four rules that follow from that. You know how to set up the backend in Cloud Storage with versioning, automatic locking and prefixes that separate states to limit the blast radius. And you know that .terraform.lock.hcl does go into Git.

You handle the initvalidateplanapply flow, with the -out=file that guarantees exactly what was reviewed gets applied. And above all you know how to read a plan: +, ~, - and the -/+ that must always stop you, with the table of which resources a replacement is acceptable on and which ones it means losing data on.

You have AlpinaShop's real code in HCL — the VPC, the subnets with private_ip_google_access, the firewall rules with Google's probe ranges explained in a comment, the bucket with its lifecycle and its four labels — and with it the knowledge that lived in Marta's head has moved to the repository. You know how to parameterise with variables and tfvars so that alpinashop-dev and alpinashop-prod share code, and why AlpinaShop chooses folders per environment over workspaces: because a workspace's only barrier is remembering to select it.

You know how to write a module with its three rules — one responsibility, parameterise what varies and fix what is policy, expose the necessary outputs — and when to consume public registry modules, with the rule that the more complex the resource the more it pays off, and the two warnings: pin the version, and a third-party module is code with permissions over your infrastructure.

You know how to import what already exists with declarative import blocks and -generate-config-out, three at a time, verifying the empty plan, starting with the harmless and leaving Cloud SQL until last. You have Terraform in the pipeline: an automatic plan on every pull request posted as a comment — which changes the team's culture, because the reviewer sees the effect without running anything — apply only after approval and applying the already reviewed plan, and without a single JSON key in the whole flow.

You know what Terraform should not manage — secret values, data, ephemeral objects — with the rule that sums it up: manage the shape, not the contents, and the question that settles the doubts: does this change on its own, or because somebody decides it? And you have the five layers of protection against accidental deletion, with prevent_destroy and deletion_protection as the first two and human review as the one that really matters.

Finally, you know Infrastructure Manager as a managed way of running it and the map of alternatives — Pulumi, Config Connector, Crossplane, CDKTF — with the observation that HCL's limitation is partly a virtue, because it forces the infrastructure to be readable.


And here module 6 ends. Look at what has changed.

At the start, AlpinaShop had an application deployed from a laptop with the latest tag, some manifests in Drive, a function outstanding since module 5, no way of knowing whether the shop was working other than waiting for a customer's email, and infrastructure that existed only in a terminal's history.

Now there is automated delivery: the code on GitHub with mandatory review, Cloud Build building, testing and publishing images tagged with the commit, promotion to production with approval, and the ML pipeline finally at maturity level 2. There are event-driven pieces that react on their own, with idempotency, retries and dead letter. There is complete observability: metrics, dashboards, alerts that warn within five minutes, checks from four continents, structured logs, distributed traces and a proven journey that goes from the alert to the line of code in seventeen minutes. And there is infrastructure as code: versioned, reviewed in pull requests and reproducible.

The system no longer depends on two people remembering to run things.

What remains are the decisions that kept being postponed, and now there is a basis for taking them. The catalogue is still on GKE, even though DA-001 decided long ago that its place is Cloud Run — and now that there is a pipeline, observability and infrastructure as code, that move is finally viable. There is a physical warehouse with its own systems that somebody will have to integrate. Networking stopped at the basics and there are outstanding conversations about Shared VPC and hybrid connectivity. Security was built piece by piece and has never been reviewed as a whole. The bill has grown with every module and nobody has looked at it closely. Alerts have been set up, but what "working well" means has not been defined: there are no SLOs and no error budgets. And the organization already has three projects, four repositories, dozens of service accounts and no policy governing the whole.

In module 7, advanced topics, they all get resolved: Anthos for hybrid and multicloud, Cloud Run to take the catalogue where it always belonged, advanced networking with Shared VPC, peering and hybrid connectivity, security reviewed from top to bottom, cost management so the bill stops being a monthly surprise, reliability with SLOs and error budgets that turn the alerts from 06-04 into measurable objectives, and governance at scale with organization policies and auditing.

AlpinaShop now builds, deploys and observes itself. Now it is time to make it robust, economical and governable.

Google Cloud Platform (GCP) Course

Module 1: Introduction to Google Cloud Platform

Module 2: Core GCP Services

Module 3: Networking and Security

Module 4: Data and Analytics

Module 5: Machine Learning and AI

Module 6: DevOps and Monitoring

Module 7: Advanced GCP Topics

Module 8: Final Project

© Copyright 2026. All rights reserved