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
- Why Terraform won
- The concepts: provider, resource, data source, variable, output and dependencies
- State: what it contains and why it never goes into Git
- The remote backend in Cloud Storage
- The workflow:
init,validate,plan,apply,destroy - How to read a plan
- AlpinaShop's real code: the network and the bucket
- Variables,
tfvarsand environments - Modules: writing the
red-alpinashopmodule - Public registry modules, with judgement
- Importing what already exists
- Terraform in CI/CD:
planon every pull request - What Terraform should NOT manage
prevent_destroyand the danger ofterraform destroy- Infrastructure Manager and alternatives
- AlpinaShop's final state
- 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.
- 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.
- 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.tfWith 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.
- 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-cicdAnd 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.
- The workflow:
init, validate, plan, apply, destroy
init, validate, plan, apply, destroyflowchart 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 outputThe -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.
- 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:
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.
- 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.
- Variables,
tfvars and environments
tfvars and environmentsThe 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"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
- Modules: writing the
red-alpinashop module
red-alpinashop moduleA 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:
- One clear responsibility.
red-alpinashopdoes the network. A module that creates the network, the database and the load balancer is a monolith by another name. - Parameterise what varies, fix what does not.
private_ip_google_access = trueis fixed on purpose: it is not a decision each environment should take, it is an AlpinaShop rule. - 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.
- 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.
- 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.tfTerraform 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 neededTips 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-devfirst. A mistake there costs nothing. prevent_destroybefore the firstapplyon 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.
- Terraform in CI/CD:
plan on every pull request
plan on every pull requestHere 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_ONLYThe 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.
- 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.
prevent_destroy and the danger of terraform destroy
prevent_destroy and the danger of terraform destroyterraform 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.
- 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.
- 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-prodAfterwards 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:
- 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.
- Take the subnet widening out of Terraform, do it with
expand-ip-rangeand update the code afterwards. - Justify the
tierchange with the CPU metric for the last two months, and schedule it in a maintenance window. - 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.tfplan2. 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 init → validate → plan → apply 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
- What is Google Cloud Platform?
- Setting Up Your GCP Account
- A Tour of the GCP Console
- Projects, Resource Hierarchy and Billing
- Regions, Zones and the Shared Responsibility Model
- Cloud Shell and the gcloud CLI
Module 2: Core GCP Services
- Compute Engine: Virtual Machines on Google Cloud
- Cloud Storage: Object Storage
- Cloud SQL: Managed Relational Databases
- App Engine: Platform as a Service
- Google Kubernetes Engine (GKE)
- NoSQL Databases: Firestore, Bigtable and Spanner
- How to Choose the Right Compute Service
Module 3: Networking and Security
- VPC Networks
- Cloud Load Balancing
- Cloud CDN
- Identity and Access Management (IAM)
- Cloud Armor
- Secrets and Encryption: Secret Manager and Cloud KMS
- Cloud DNS, TLS Certificates and Publishing Services Securely
Module 4: Data and Analytics
- BigQuery: The Analytical Data Warehouse
- Cloud Dataflow: Batch and Streaming Data Processing
- Cloud Dataproc: Managed Spark and Hadoop
- Cloud Pub/Sub: Asynchronous Messaging
- Cloud Data Fusion: Code-Free Data Integration
- Orchestrating Pipelines with Cloud Composer and Workflows
- Data Governance and Dashboards with Dataplex and Looker Studio
Module 5: Machine Learning and AI
- Vertex AI: The Machine Learning Platform on GCP
- AutoML: Custom Models Without Writing Code
- TensorFlow on GCP: Training and Serving Models
- Natural Language API
- Vision API
- Generative AI on Vertex AI: Gemini Models and Embeddings
- MLOps: From Model to Product with Vertex AI Pipelines
Module 6: DevOps and Monitoring
- Cloud Build: Continuous Integration on GCP
- Cloud Source Repositories and Source Code Management
- Cloud Functions: Serverless Functions
- Cloud Monitoring (formerly Stackdriver): Metrics, Dashboards and Alerts
- Cloud Deployment Manager and Native Infrastructure as Code
- Cloud Logging and Cloud Trace: Logs, Traces and Diagnostics
- Terraform on GCP: Infrastructure as Code in Practice
Module 7: Advanced GCP Topics
- Hybrid and Multicloud with Anthos
- Serverless Computing with Cloud Run
- Advanced Networking: Shared VPC, Peering and Hybrid Connectivity
- Security Best Practices
- Cost Management and Optimization
- Reliability: SLOs, High Availability and Disaster Recovery
- Governance at Scale: Organization, Policies and Auditing
