There is a question Marta has been dodging since module 3, and one that an auditor, a corporate customer or a serious incident will ask sooner or later: if AlpinaShop's whole infrastructure had to be rebuilt in another region tomorrow, how long would it take?
The honest answer is that nobody knows. The alpinashop-vpc VPC, the sn-web-euw1 and sn-datos-euw1 subnets, Cloud NAT, the fw-* rules, the global load balancer with its health check and its URL map, the Cloud Armor policy, the Cloud DNS zone, the certificate, the MIG, the GKE cluster, the service accounts, the custom roles: all of that was created with gcloud commands that Marta ran over several weeks. Some worked first time. Others were adjusted afterwards from the console. A few were deleted and redone. The only record of that process is her terminal history, which is incomplete anyway because it includes commands that were undone and leaves out the changes made with the mouse.
And there is a second consequence, a more insidious one. alpinashop-dev was created from alpinashop-prod, but it has been drifting apart: a firewall rule that was relaxed to run a test, a different machine size, a Cloud SQL setting that was never replicated. Today testing in development guarantees nothing about production, because they are not the same system. That is called configuration drift and it is the reason why tested deployments fail in production.
This lesson attacks that problem. And it does so with a warning that has to be given early: the tool that gives the lesson its title is not the one you should use. It is worth understanding why, and above all what to do when you come across it.
Contents
- The concrete problem: infrastructure that only exists in a history file
- What infrastructure as code is
- Declarative versus imperative, and idempotency
- State: the idea that makes everything else possible
- Deployment Manager: GCP's native tool
- Templates in Jinja and in Python
- A real example: AlpinaShop's VPC and firewall
- The central warning: Deployment Manager is discontinued
- The succession: Infrastructure Manager, Config Connector and Terraform
- How to migrate from Deployment Manager to Terraform, step by step
- The concepts that carry over between tools
- The concrete problem: infrastructure that only exists in a history file
It is worth putting numbers on what the current situation costs, because the argument in favour of infrastructure as code is not an aesthetic one:
| Real situation | Consequence today | With IaC |
|---|---|---|
The environment has to be recreated in europe-west4 |
Weeks, with guaranteed mistakes | Change a variable and apply |
| Somebody asks which firewall rules exist | They are listed from the console, with no idea why they exist | You read the file, with its comments |
| You want to review a change before applying it | Impossible: you apply it and see what happens | Pull request with the plan attached |
alpinashop-dev does not resemble production |
Nobody knows how they differ | The same code, different variables |
| Marta goes on holiday | Nobody touches anything | Anyone can read and propose changes |
| A change breaks production | What was there before? Nobody knows | git revert and apply |
| Audit: who changed the firewall and when | Audit logs, with no context and no reason | Commit with author, date and justification |
The incident row deserves emphasis. When a manual change breaks production, the urgent question is "what was the previous configuration?". Without IaC, the only source is the memory of whoever changed it, under pressure and in a hurry. With IaC, the answer is in Git and rolling back is a familiar operation.
And the drift between environments is what costs most in the medium term, because it erodes the value of your tests. All the work from 06-01 — automated tests, deployment to alpinashop-dev, promotion to production — rests on the premise that development resembles production. If it does not, the pipeline gives a confidence that is not justified.
- What infrastructure as code is
Infrastructure as code (IaC) is the practice of defining infrastructure in versioned text files, and of creating and modifying it exclusively by applying those files with a tool, never by hand.
That last part is the hard bit and the bit that provides all the value. Having Terraform files and still tinkering from the console is the worst of both worlds: the complexity of the tool without the guarantee that the code reflects reality.
| Benefit | What it means in practice |
|---|---|
| Reproducibility | The same code produces the same infrastructure, every time |
| Review | A firewall change is discussed in a pull request before it exists |
| History | git log over the infrastructure, with author and reason |
| Rollback | Going back to yesterday's configuration is one command |
| Living documentation | The code is the documentation, and it does not go stale |
| Consistent environments | Development and production share a definition |
| Automation | The pipeline from 06-01 can apply infrastructure changes |
| Disaster recovery | Rebuilding is applying, not improvising |
The direct comparison with working from the console:
| Aspect | Console / gcloud by hand |
Infrastructure as code |
|---|---|---|
| Speed the first time | Faster | Slower: you have to write it |
| Speed the tenth time | Just as slow every time | Instant |
| Human error | Frequent and silent | Caught in review |
| Knowledge | In one person's head | In the repository |
| Audit | Logs with no context | Commits with a reason |
| Learning curve | None | Real |
And it is worth being honest about the cost: IaC is slower at first. Creating a firewall rule from the console takes thirty seconds; writing it, reviewing it and applying it takes ten minutes. The investment pays for itself the third or fourth time that rule has to be touched, and it multiplies when the environment has to be replicated or when somebody asks why it exists.
- Declarative versus imperative, and idempotency
The difference between the two approaches is what makes IaC work.
Imperative is describing the steps. It is what Marta does today:
gcloud compute networks create alpinashop-vpc --subnet-mode=custom
gcloud compute networks subnets create sn-web-euw1 \
--network=alpinashop-vpc --range=10.10.0.0/24 --region=europe-west1
gcloud compute firewall-rules create fw-permitir-salud \
--network=alpinashop-vpc --allow=tcp:8080 \
--source-ranges=35.191.0.0/16,130.211.0.0/22Declarative is describing the desired result, and letting the tool work out the steps:
The practical difference lies in what happens when you run it twice:
| Imperative | Declarative | |
|---|---|---|
| First run | Creates the resources | Creates the resources |
| Second run | Fails: "already exists" | Does nothing: it is already as it should be |
| If it was modified by hand | Does not notice | Detects the difference and corrects it |
| If something is removed from the file | Does not notice | Deletes it |
That property — applying N times producing the same result as applying once — is idempotency, and it is what lets you run the process with confidence. You can apply the configuration every morning: if nothing has changed, nothing happens; if somebody touched something by hand, it gets corrected.
The third row hides the most valuable benefit: the declarative approach detects and corrects drift. If somebody opens port 22 to the whole internet for a test and forgets to close it, the next application of the configuration detects it and reverts it. The infrastructure converges towards what the code says.
And the fourth row hides the danger: if you delete a resource from the file, the tool destroys it. It does not ignore it: it removes it, because the file declares the complete desired state. Carelessly deleting thirty lines of a .yaml can mean deleting a database.
- State: the idea that makes everything else possible
For a declarative tool to know that it must modify a resource rather than create it, it needs to know which resources it manages. That record is the state.
flowchart LR
A[Code:<br/>DESIRED state] --> C{Comparison}
B[State:<br/>what the tool<br/>MANAGES] --> C
D[Reality in GCP:<br/>what EXISTS] --> C
C --> E[Change plan:<br/>create, modify, destroy]
The state answers three questions the tool cannot answer any other way:
- Which resources do I manage? It tells apart what the tool created from what already existed. Without that distinction, applying a configuration could destroy resources created by another team.
- What identifier does each one have? The logical name in the file (
red_principal) is not the real identifier in GCP. - How were they last time? So the difference can be computed without querying the whole API on every run.
The big difference between this lesson's two tools lies precisely here:
| Tool | Where the state lives | Consequence |
|---|---|---|
| Deployment Manager | Managed by Google, inside the service itself | You do not have to manage it, but you cannot see or manipulate it |
| Terraform | A file you manage (.tfstate) |
Total control, and total responsibility |
Deployment Manager saves you the state problem. Terraform hands it to you, with all that follows: it has to be kept somewhere shared, with locking so that two people do not apply at the same time, with versioning in case it gets corrupted, and never in Git, because it contains sensitive values. All of that is solved in 06-07.
- Deployment Manager: GCP's native tool
Cloud Deployment Manager is Google Cloud's native infrastructure-as-code tool, available since 2015. Its model:
- A configuration in YAML declares resources.
- Each resource has a type derived directly from the GCP APIs (
compute.v1.network,sqladmin.v1beta4.instance). - A deployment is a set of resources managed as a unit, with its state stored by the service.
- Templates, in Jinja or Python, let you parameterise and reuse.
The minimal configuration:
# red.yaml
resources:
- name: alpinashop-vpc
type: compute.v1.network
properties:
autoCreateSubnetworks: false
description: "AlpinaShop main VPC"
- name: sn-web-euw1
type: compute.v1.subnetwork
properties:
# $(ref....) creates an IMPLICIT DEPENDENCY: the subnet waits for the network
network: $(ref.alpinashop-vpc.selfLink)
region: europe-west1
ipCidrRange: 10.10.0.0/24
privateIpGoogleAccess: trueThe $(ref.resource.property) syntax is the central mechanism: as well as obtaining a value that is not known until the resource exists, it declares a dependency. Deployment Manager builds a graph and creates the resources in the right order, parallelising what it can. It is exactly the same idea as in Terraform, with different syntax.
The lifecycle commands:
# Preview: works out what it would do, without touching anything
gcloud deployment-manager deployments create red-alpinashop \
--config=red.yaml --preview --project=alpinashop-dev
# See the computed plan
gcloud deployment-manager deployments describe red-alpinashop \
--project=alpinashop-dev
# Confirm and execute
gcloud deployment-manager deployments update red-alpinashop \
--project=alpinashop-dev
# Update after changing the file
gcloud deployment-manager deployments update red-alpinashop \
--config=red.yaml --project=alpinashop-dev
# See the managed resources
gcloud deployment-manager resources list \
--deployment=red-alpinashop --project=alpinashop-dev
# Destroy the WHOLE deployment
gcloud deployment-manager deployments delete red-alpinashop \
--project=alpinashop-devTwo operational warnings worth keeping in mind:
--previewis the equivalent of Terraform'splanand must never be skipped in a serious environment. It shows what will be created, modified or destroyed before anything is touched.deployments deletedeletes every resource in the deployment. It is not "forgetting the configuration": it is destroying the VPC, the subnets and everything they contain. Run against the wrong deployment in production, it is catastrophic.
There are also update policies, which control what to do when a change requires a resource to be recreated:
gcloud deployment-manager deployments update red-alpinashop \
--config=red.yaml \
--delete-policy=ABANDON \
--create-policy=CREATE_OR_ACQUIRE \
--project=alpinashop-devABANDON stops managing the resource instead of deleting it — useful for taking something out of the deployment without destroying it — and CREATE_OR_ACQUIRE adopts a resource that already exists with that name instead of failing. The latter is Deployment Manager's import mechanism, and it is far more limited than Terraform's.
- Templates in Jinja and in Python
A flat configuration does not scale: if you have to create five nearly identical firewall rules, copying and pasting is exactly the problem IaC came to solve. Templates parameterise.
A Jinja template, for the simple cases:
{# subred.jinja #}
resources:
- name: {{ properties["name"] }}
type: compute.v1.subnetwork
properties:
network: {{ properties["networkSelfLink"] }}
region: {{ properties["region"] }}
ipCidrRange: {{ properties["range"] }}
privateIpGoogleAccess: {{ properties.get("privateGoogleAccess", true) }}
outputs:
- name: selfLink
value: $(ref.{{ properties["name"] }}.selfLink)And how it is used:
# red.yaml
imports:
- path: subred.jinja
resources:
- name: alpinashop-vpc
type: compute.v1.network
properties:
autoCreateSubnetworks: false
- name: subred-web
type: subred.jinja
properties:
name: sn-web-euw1
networkSelfLink: $(ref.alpinashop-vpc.selfLink)
region: europe-west1
range: 10.10.0.0/24
- name: subred-datos
type: subred.jinja
properties:
name: sn-datos-euw1
networkSelfLink: $(ref.alpinashop-vpc.selfLink)
region: europe-west1
range: 10.20.0.0/24A Python template, when you need real logic. A Python template is a GenerateConfig(context) function that returns a dictionary of resources:
# firewall.py
"""Generates AlpinaShop's firewall rules from a list."""
BASE_RULES = [
{"name": "fw-permitir-salud", "ports": ["tcp:8080"],
"sources": ["35.191.0.0/16", "130.211.0.0/22"], # Google probes (03-02)
"tags": ["catalogo-web"]},
{"name": "fw-permitir-web-interno", "ports": ["tcp:8080"],
"sources": ["10.10.0.0/24"], "tags": ["catalogo-web"]},
{"name": "fw-permitir-sql", "ports": ["tcp:5432"],
"sources": ["10.10.0.0/24"], "tags": ["base-datos"]},
]
def GenerateConfig(context):
network = context.properties["networkSelfLink"]
environment = context.properties["environment"]
resources = []
for rule in BASE_RULES:
resources.append({
"name": f"{rule['name']}-{environment}",
"type": "compute.v1.firewall",
"properties": {
"network": network,
"sourceRanges": rule["sources"],
"targetTags": rule["tags"],
"allowed": [
{"IPProtocol": p.split(":")[0], "ports": [p.split(":")[1]]}
for p in rule["ports"]
],
"description": f"Generated by IaC - environment {environment}",
},
})
# Rule EXCLUSIVE to development: SSH from the office VPN.
# It does not exist in production, and that is exactly the advantage of using code.
if environment == "dev":
resources.append({
"name": "fw-permitir-ssh-oficina-dev",
"type": "compute.v1.firewall",
"properties": {
"network": network,
"sourceRanges": [context.properties["officeCidr"]],
"allowed": [{"IPProtocol": "tcp", "ports": ["22"]}],
},
})
return {"resources": resources}The conditional block at the end illustrates the main benefit: the difference between environments stops being accidental and becomes written down. Today nobody knows how alpinashop-dev differs from alpinashop-prod; with this, the difference is five lines of code with an explicit if, reviewed in a pull request.
Deployment Manager also supports schemas (.schema) that validate a template's parameters — types, required values, regular expressions — and produce clear errors before anything is touched. It is a good idea that Terraform later picks up with variable validation.
- A real example: AlpinaShop's VPC and firewall
Putting it all together, this is how AlpinaShop's network would look in Deployment Manager:
# alpinashop-red.yaml
imports:
- path: subred.jinja
- path: firewall.py
resources:
- name: alpinashop-vpc
type: compute.v1.network
properties:
autoCreateSubnetworks: false
routingConfig:
routingMode: REGIONAL
description: "AlpinaShop main VPC - managed by IaC"
- name: subred-web
type: subred.jinja
properties:
name: sn-web-euw1
networkSelfLink: $(ref.alpinashop-vpc.selfLink)
region: europe-west1
range: 10.10.0.0/24
- name: subred-datos
type: subred.jinja
properties:
name: sn-datos-euw1
networkSelfLink: $(ref.alpinashop-vpc.selfLink)
region: europe-west1
range: 10.20.0.0/24
- name: reglas-firewall
type: firewall.py
properties:
networkSelfLink: $(ref.alpinashop-vpc.selfLink)
environment: prod
officeCidr: 192.0.2.0/24
- name: alpinashop-router-euw1
type: compute.v1.router
properties:
network: $(ref.alpinashop-vpc.selfLink)
region: europe-west1
- name: alpinashop-nat-euw1
type: compute.v1.router
properties:
network: $(ref.alpinashop-vpc.selfLink)
region: europe-west1
nats:
- name: alpinashop-nat-euw1
natIpAllocateOption: AUTO_ONLY
sourceSubnetworkIpRangesToNat: ALL_SUBNETWORKS_ALL_IP_RANGES
outputs:
- name: redSelfLink
value: $(ref.alpinashop-vpc.selfLink)
- name: subredWeb
value: $(ref.subred-web.selfLink)gcloud deployment-manager deployments create alpinashop-red \
--config=alpinashop-red.yaml --preview --project=alpinashop-prodAnd here is the point of the lesson: this file, reviewed in a pull request and applied from the pipeline, replaces fifteen loose commands in Marta's history. The network becomes an artefact that is read, discussed and reproduced.
But before anybody sets about writing it, the following has to be said.
- The central warning: Deployment Manager is discontinued
Cloud Deployment Manager is discontinued. Google announced its withdrawal, it has received no new functionality for years and its end of support is fixed. It must not be used for any new project.
Everything in the previous section is correct and works today. And it would be a mistake to start writing it.
The four reasons Deployment Manager did not prosper, which are instructive in themselves:
| Reason | Detail |
|---|---|
| GCP only | Terraform manages GCP, AWS, Azure, GitHub, Cloudflare, Datadog… with one language |
| Non-existent ecosystem | Terraform has thousands of reusable public modules; Deployment Manager, almost none |
| Incomplete resource coverage | New services took a long time to get a type, or never got one |
| Low adoption | The community chose Terraform, and with it the examples, the books and the job openings |
The third is the one that hurt most in practice: when a new service appeared, you had to fall back on the generic types based on the REST API, with a fairly thankless experience.
So why is this lesson in the course? For three concrete reasons:
- You are going to come across it. There is a lot of infrastructure deployed with Deployment Manager in organizations that have been on GCP for years, including Marketplace templates. Being able to read a
.yamland rundeployments describeis a practical skill. - The concepts are transferable. Declarative, dependencies, templates, outputs, preview, state: they are the same in any tool. Learning them here pays off in 06-07.
- Because what matters is knowing how to migrate. If you come across Deployment Manager, your job will be to get out of it. That is section 10, and it is what is genuinely useful about this lesson.
And there is an underlying lesson that goes beyond the tool: choosing technology is not only choosing capabilities, it is choosing an ecosystem. Deployment Manager was technically reasonable. It lost because the community, the examples, the modules, the books and the trained professionals were on the other side. When evaluating a tool, ask as well how many people use it and what happens if the vendor stops investing in it.
- The succession: Infrastructure Manager, Config Connector and Terraform
Google did not leave a gap: it proposed successors, and each one answers a different profile.
Infrastructure Manager (often shortened to Infra Manager) is the managed, official successor. And the interesting part is what it runs underneath: Terraform. Google acknowledged that Terraform had won and, rather than compete, built a managed service that runs it for you.
gcloud infra-manager deployments apply proyectos/alpinashop-prod/locations/europe-west1/deployments/red \
--service-account=projects/alpinashop-prod/serviceAccounts/[email protected] \
--local-source=./terraform/red \
--input-values=entorno=prodWhat it adds over running Terraform yourself: it manages the state for you — goodbye to the bucket and the lock — it authenticates with a GCP service account without keys, it integrates with Cloud Build and with the audit logs, and it keeps a queryable deployment history. What it costs: GCP only, and less flexibility than running Terraform in your own pipeline.
Config Connector is a different proposition: a GKE add-on that lets you manage GCP resources as Kubernetes objects.
apiVersion: compute.cnrm.cloud.google.com/v1beta1
kind: ComputeNetwork
metadata:
name: alpinashop-vpc
namespace: tienda
spec:
autoCreateSubnetworks: false
routingMode: REGIONALYou apply that with kubectl apply and the controller creates the real VPC. The advantage is continuous reconciliation: the controller watches permanently and corrects drift on its own, without waiting for somebody to run anything. It makes sense for teams that already live in Kubernetes and use GitOps; for AlpinaShop, which has a cluster but not a GitOps culture, it would mean adding a large dependency to solve a problem Terraform solves more simply.
Terraform is the de facto standard, and that is why it gets its own lesson.
| Tool | Model | State | Multi-provider | For AlpinaShop? |
|---|---|---|---|---|
| Deployment Manager | YAML + templates | Managed | No | No: discontinued |
| Infrastructure Manager | Managed Terraform | Managed | No | A good option, mentioned in 06-07 |
| Config Connector | Kubernetes CRD | In the cluster | No | No: disproportionate complexity |
| Terraform | HCL | Yours | Yes | Yes: the choice |
- How to migrate from Deployment Manager to Terraform, step by step
This is the useful section of the lesson, and it serves two scenarios at once: migrating from Deployment Manager and — AlpinaShop's real case — bringing infrastructure created by hand into code. The procedure is practically the same, because in both cases the goal is for Terraform to take control of resources that already exist without destroying or recreating them.
flowchart TD
A[1. Inventory<br/>what really exists] --> B[2. Export the configuration<br/>bulk-export]
B --> C[3. Review and clean<br/>the generated HCL]
C --> D[4. Import into the state<br/>terraform import]
D --> E[5. Verify:<br/>EMPTY terraform plan]
E --> F{Plan empty?}
F -->|No| C
F -->|Yes| G[6. Abandon the Deployment Manager<br/>deployment]
G --> H[7. Refactor<br/>calmly]
Step 1: inventory
You cannot import what you do not know about. And the source of truth is not anybody's memory, nor a document: it is GCP.
# If there are Deployment Manager deployments, list their resources
gcloud deployment-manager deployments list --project=alpinashop-prod
gcloud deployment-manager resources list --deployment=alpinashop-red \
--project=alpinashop-prod --format='table(name, type, id)'
# And in any case, inventory reality resource by resource
gcloud compute networks list --project=alpinashop-prod
gcloud compute networks subnets list --project=alpinashop-prod
gcloud compute firewall-rules list --project=alpinashop-prod
gcloud compute forwarding-rules list --project=alpinashop-prod
gcloud sql instances list --project=alpinashop-prod
gcloud storage buckets list --project=alpinashop-prodA far more complete alternative is Cloud Asset Inventory, which enumerates everything that exists in a project in one go:
gcloud asset search-all-resources \
--scope=projects/alpinashop-prod \
--format='table(assetType, displayName, name)' > inventario-prod.txtThe output of this step is a written list of what is there. And it usually holds surprises: test resources nobody deleted, a static IP reserved and unused that is being paid for, duplicate firewall rules. That finding alone justifies the exercise, even before a line of Terraform is written.
Step 2: export the current configuration
The tool that saves most of the work:
# Export ALL the project's supported resources to .tf files
gcloud beta resource-config bulk-export \
--project=alpinashop-prod \
--resource-format=terraform \
--path=./terraform-exportado
# Or narrowed to a specific type, which is more manageable
gcloud beta resource-config bulk-export \
--project=alpinashop-prod \
--resource-format=terraform \
--resource-types=ComputeNetwork,ComputeSubnetwork,ComputeFirewall \
--path=./terraform-exportado/redIt generates .tf files with the real configuration of the existing resources. It is not production-ready code, and you have to accept that from the start: ugly automatically generated names, every value a literal with no variables, server-computed fields that should not be there, no modules and no structure. It is a starting point, and as a starting point it saves days.
There is also export to Deployment Manager format (--resource-format=krm for Config Connector), but for our destination we want terraform.
Step 3: review and clean
The manual work, and there is no shortcut. What has to be done with what was exported:
| Task | Why |
|---|---|
| Rename the resources | google_compute_network.tfer--alpinashop-vpc → red_principal |
| Remove the computed fields | self_link, id, creation_timestamp, fingerprint: GCP sets those |
| Replace literals with references | network = "https://..." → network = google_compute_network.red_principal.id |
| Extract variables | The project, the region and the CIDRs should be variables |
| Organise into files | red.tf, firewall.tf, sql.tf, iam.tf |
| Add comments | Why each rule exists: no tool exports that |
| Pin the provider version | Reproducibility |
The third row is the most important technically. An exported file has literal values that do not express relationships; replacing them with references is what makes Terraform understand the creation order and what makes the code reusable.
And the sixth is the most important on the human side. The export reproduces what is there, never why. The fw-permitir-salud rule with the ranges 35.191.0.0/16 and 130.211.0.0/22 is incomprehensible without a comment saying they are Google's health-check probes. This moment of the migration is the only occasion on which anybody is going to look at each resource one by one; it is when those comments have to be written, because afterwards it will not happen.
Step 4: import into the state
Terraform has the code, but its state is empty: it believes it manages nothing. If you applied now, it would try to create everything again and would fail with "already exists" errors — or worse, it would create duplicates.
Importing associates each real resource with its block of code. The classic way, resource by resource:
terraform import google_compute_network.red_principal \
projects/alpinashop-prod/global/networks/alpinashop-vpc
terraform import google_compute_subnetwork.web \
projects/alpinashop-prod/regions/europe-west1/subnetworks/sn-web-euw1
terraform import google_compute_firewall.permitir_salud \
projects/alpinashop-prod/global/firewalls/fw-permitir-saludAnd the modern way, available since Terraform 1.5 and clearly preferable, with declarative import blocks:
# importaciones.tf — a temporary file that gets deleted when you are done
import {
to = google_compute_network.red_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"
}The advantage of import blocks is enormous for a large migration: they are versionable, reviewable code, they show up in terraform plan before running, and they do not depend on somebody remembering the order of fifty commands. On top of that, terraform plan -generate-config-out=generado.tf can generate the HCL corresponding to the imported resources, which cuts down step 3's work even further.
The identifier format varies by resource type and is documented at the end of each resource's page in the provider documentation. It is the most tedious detail of the whole procedure.
Step 5: verify that the plan comes out empty
This is the step that validates the whole migration, and it is the success criterion:
An empty plan means the code describes reality exactly, that the state reflects it and that Terraform has taken control without changing anything. That is the goal.
If the plan does not come out empty, read it carefully because each kind of difference means something different:
| What the plan shows | Usual cause | What to do |
|---|---|---|
~ update of a trivial field |
A default value you did not write | Add it to the code with the real value |
~ update of a computed field |
You copied self_link or fingerprint |
Remove it from the code |
-/+ replace |
An immutable field does not match | STOP: applying it would destroy the resource |
+ create of something that exists |
It has not been imported | Import it |
- destroy of something that exists |
It is in the state but not in the code | Add it to the code |
The third row deserves a warning in capitals. A -/+ replace on google_sql_database_instance would destroy AlpinaShop's orders database. You never apply a migration plan containing a replacement without having understood exactly why it appears. It is almost always a mistranscribed immutable field and it is fixed in the code.
The operational recommendation: do the whole migration in alpinashop-dev first. If something goes wrong, you lose a test environment, not the shop.
Step 6: abandon the old deployment
When you have come from Deployment Manager, one critical detail remains. There are now two tools that believe they manage the same resources, and that is dangerous: a deployments delete would delete them out from under Terraform.
# ABANDON stops managing them WITHOUT DELETING THEM. Never use delete here.
gcloud deployment-manager deployments delete alpinashop-red \
--delete-policy=ABANDON \
--project=alpinashop-prod--delete-policy=ABANDON is the difference between a clean migration and a disaster. Without that parameter, the command destroys the infrastructure you have just imported.
Step 7: refactor calmly
Only now, with an empty plan and control in Terraform, does the improvement begin: extract modules, parameterise environments, add prevent_destroy to the critical things, integrate into the pipeline. And the golden rule: every refactoring change must also end with an empty plan. If moving code into a module makes the plan propose destroying and recreating something, you have changed the meaning, not the form.
A summary of the real effort, so nobody gets a surprise:
| Phase | Effort | Can it be automated |
|---|---|---|
| Inventory | Hours | Largely |
| Export | Minutes | Yes |
| Clean and comment | Days | Barely |
| Import | Hours | Yes, with import blocks |
| Verify the empty plan | Days of iteration | No |
| Refactor | Ongoing | No |
It is tedious work. And it is a one-off: once it is done, it is done for good.
- The concepts that carry over between tools
I will close with what makes learning Deployment Manager not a waste of time. The concepts are the same in every IaC tool, and only the syntax changes:
| Concept | Deployment Manager | Terraform | Config Connector |
|---|---|---|---|
| Declarative unit | Resource in YAML | resource block |
CRD object |
| Reference between resources | $(ref.x.selfLink) |
google_x.y.id |
Kubernetes Ref |
| Implicit dependency | Through the reference | Through the reference | Through the reference |
| Explicit dependency | metadata.dependsOn |
depends_on |
Annotations |
| Parameterisation | Template properties | variable |
Kustomize / Helm |
| Reuse | Jinja/Python templates | Modules | Charts |
| Output values | outputs |
output |
Object status |
| Preview | --preview |
plan |
--dry-run |
| State | Managed by Google | Your file | In the cluster (etcd) |
| Validation | .schema |
validation in variables |
CRD schema |
| Environments | Separate deployments | Workspaces or folders | Namespaces |
The four principles that hold in all of them:
- Describe the result, not the steps. The tool works out the path.
- Let dependencies be inferred from the references. Declaring the order by hand is a source of errors;
depends_onis the last resort. - Always preview before applying.
--previeworplan, with no exceptions in production. - The code is the source of truth. The moment somebody touches something by hand, the system stops being trustworthy and you are back where you started.
Common Mistakes and Tips
Starting a new project with Deployment Manager. It is discontinued. Everything in this lesson is for understanding and migrating what exists, not for writing anything new.
Running deployments delete without --delete-policy=ABANDON during a migration. It destroys the infrastructure instead of releasing it from management. It is the most expensive mistake possible in this procedure.
Applying a migration plan with a -/+ replace without understanding it. A replacement on Cloud SQL destroys the database. If the plan proposes recreating something, stop and find out which immutable field does not match.
Taking the code exported by bulk-export as good. It is a starting point, not a result. Without cleaning, you will have computed fields that produce eternal differences and literals that make the code impossible to reuse.
Not commenting on why each resource exists during the migration. It is the only time anybody will look at each resource individually. If the reason is not written down then, it is lost forever.
Carrying on touching things by hand after adopting IaC. It is the worst of both worlds. As soon as the code and reality diverge, trust in the tool disappears.
Migrating directly in production. Do the whole procedure in alpinashop-dev first. Migration mistakes are expensive and there they cost nothing.
Deleting resources from the file thinking they "will stop being managed". In a declarative tool, removing a resource from the code means destroying it. To stop managing it without deleting it there is terraform state rm or the ABANDON policy.
Importing without verifying the empty plan. Importing does not validate that the code is correct: it only associates an identifier. The empty plan is the only proof that the migration has been done properly.
A final tip: start with the least dangerous. Migrate the firewall rules and the buckets first, then the network, and leave Cloud SQL and everything containing data for last. By the time you get to the delicate parts you will have mastered the procedure and you will read a plan with ease.
Exercises
Exercise 1: read and translate a legacy configuration
You join a project and find a Deployment Manager deployment called plataforma-legacy with a file defining a VPC, two subnets, a firewall rule allowing tcp:22 from 0.0.0.0/0 and a Cloud SQL instance. Describe which commands you would run to understand what that deployment manages, what you would do with the firewall rule and in what order you would migrate the four resource types to Terraform, justifying the order.
Exercise 2: diagnose a plan that does not come out empty
During AlpinaShop's migration, after importing the network and the firewall, terraform plan shows: a ~ update on google_compute_network.red_principal changing description from "Main VPC" to ""; a ~ update on google_compute_subnetwork.web changing private_ip_google_access from true to false; and a -/+ replace on google_compute_subnetwork.datos because ip_cidr_range would go from 10.20.0.0/24 to 10.20.0.0/22. Explain the cause of each one, which is the most dangerous and how to fix them.
Exercise 3: plan AlpinaShop's complete migration
Marta asks you for a realistic plan to bring all of alpinashop-prod's infrastructure into Terraform: VPC with two subnets and Cloud NAT, about eight firewall rules, the complete global load balancer, the Cloud Armor policy, the Cloud DNS zone with the certificate, the MIG with its template, the GKE Autopilot cluster, the Cloud SQL instance, three buckets, four service accounts with their IAM bindings and a custom role. Propose a phased plan with grouping criteria, indicate which resources you would NOT import and why, and define the completion criterion for each phase.
Solutions
Solution 1
Commands for understanding the deployment:
# 1. Which resources it manages, with their types and identifiers
gcloud deployment-manager resources list --deployment=plataforma-legacy \
--format='table(name, type, id, update.state)'
# 2. The manifest: the REAL expanded configuration that was applied
gcloud deployment-manager manifests list --deployment=plataforma-legacy
gcloud deployment-manager manifests describe MANIFEST_ID \
--deployment=plataforma-legacy --format='value(expandedConfig)'
# 3. Who changed it last and when
gcloud deployment-manager deployments describe plataforma-legacyThe manifest is the key piece and the one people do not know about: it contains the expanded configuration, that is to say, with every template resolved and every default value filled in. The original .yaml may have parameterised templates that are hard to read; the manifest shows exactly what was created.
The tcp:22 rule from 0.0.0.0/0 is a security finding, and it has to be treated as one. It leaves SSH open to the whole internet, against everything established in 03-01 and 03-04.
And the right decision is to migrate it exactly as it is, and fix it afterwards, however much it hurts. The reasons:
- Changing it during the migration breaks the empty-plan criterion, which is the only way to verify that the migration is correct. If you mix migration and correction, when something fails you will not know which of the two caused it.
- Something may depend on it — an operational process, a supplier's access — and finding out requires investigation that must not block the migration.
- Once in Terraform, fixing it is a reviewable pull request, with history and with a way back. In other words, it gets fixed better afterwards.
What does have to be done immediately: document the finding, communicate it and put a date on it. And if the exposure is considered unacceptable in the short term, restrict the source to the office ranges as a stopgap, before starting the migration, so that the migration starts from an already acceptable state.
Migration order, from least to most dangerous:
| Order | Resource | Why there |
|---|---|---|
| 1 | Firewall rules | Independent, easy to import, a mistake destroys no data |
| 2 | VPC | The basis of everything; it has to be there before the subnets |
| 3 | Subnets | They depend on the VPC; watch out for ip_cidr_range, it is immutable |
| 4 | Cloud SQL | Always last: it contains data and a replace is irreversible |
The general criterion: start with what can be recreated with no consequences and finish with what contains data. By the time you reach Cloud SQL you will have imported several resources, you will read a plan with ease and you will recognise a -/+ replace instantly. And for that import in particular, add lifecycle { prevent_destroy = true } before running terraform apply for the first time: it is a two-line safety net that can save the database.
Solution 2
Difference 1 — description would go from "VPC principal" to "".
Cause: the real resource has a description, but the HCL code does not include the description attribute. Terraform reads the absence as "it must be empty" and tries to clear it.
Severity: low. Changing a description does not affect operation.
Fix: add the attribute to the code with the real value.
resource "google_compute_network" "red_principal" {
name = "alpinashop-vpc"
description = "AlpinaShop main VPC" # it was missing
auto_create_subnetworks = false
}Lesson: it is the most common symptom after importing. It shows up whenever a resource has optional attributes configured that the code does not mention.
Difference 2 — private_ip_google_access would go from true to false.
Cause: the same one — the attribute is missing from the code — but the consequences are completely different. That setting is what lets instances without a public IP reach Google's APIs. Applying it would leave the sn-web-euw1 VMs with no access to Cloud Storage, Secret Manager or Cloud Logging.
Severity: high. It is a genuine functional outage, and a hard one to diagnose on top of that: nothing goes down all at once, API calls simply start failing in an apparently random way.
Fix:
resource "google_compute_subnetwork" "web" {
name = "sn-web-euw1"
ip_cidr_range = "10.10.0.0/24"
region = "europe-west1"
network = google_compute_network.red_principal.id
private_ip_google_access = true # CRITICAL: it was missing
}Important lesson: a ~ update is not automatically harmless. Two syntactically identical differences — a missing attribute — have radically different impacts. You have to read which field is changing, not just the symbol in front of it.
Difference 3 — -/+ replace of the data subnet because of a CIDR change.
Cause: the code says /22 and reality is /24. It is a transcription error, very probably when copying from the inventory or writing from memory. And ip_cidr_range cannot be modified in place on an existing subnet with resources inside it, so Terraform can only destroy it and create it again.
Severity: critical. Destroying sn-datos-euw1 would mean disconnecting everything that lives in it — the Cloud SQL instance among other things — and the operation would fail halfway, leaving the infrastructure in an inconsistent state. And if for some reason it did manage to complete, the private IPs would change and everything that references them would stop working.
Fix: put the real value, 10.20.0.0/24, in the code.
Which is the most dangerous and why. The -/+ replace is the most dangerous, and not only because of the impact: it is the only one Terraform cannot undo. The two ~ updates are reversible — you fix the code and apply again; a destroyed resource does not come back. That is why the rule in section 10 is categorical: an unexpected replace in a migration must always stop you.
And a procedural recommendation that this exercise illustrates well: in a migration, run terraform plan after each import, not at the end of them all. With fifty resources imported at once, the plan is hundreds of lines long and the three important differences get lost in the noise. Importing three at a time and verifying is slower and vastly safer.
Solution 3
Grouping criterion by phases: from least to most dangerous, and respecting the dependencies.
Phase 0 — Preparation (half a day). alpinashop-terraform-estado bucket with versioning, backend configured, google provider with a pinned version, complete inventory with Cloud Asset Inventory and export with bulk-export. The alpinashop-infra repository from 06-02 with branch protection and CODEOWNERS requiring a security review.
Completion criterion: terraform init works and the remote state is empty but reachable.
Phase 1 — Independent resources with no data (1-2 days). Buckets — but not their contents — service accounts, the analistaCatalogo custom role and the IAM bindings.
Why first: they depend on nothing, they are easy to import and a mistake destroys nothing unrecoverable. It is where the team learns to read plans.
Criterion: empty plan and proof that the service accounts still work.
Phase 2 — Base network (2-3 days). VPC, two subnets, router, the alpinashop-nat-euw1 Cloud NAT and the eight firewall rules.
Why here: it is the basis of everything that comes after, and it still does not touch data. Maximum attention on ip_cidr_range and private_ip_google_access, for the reasons seen in exercise 2.
Criterion: empty plan and real functional verification: the shop still responds, the VMs still get out through NAT and still reach Google's APIs.
Phase 3 — Publication (2-3 days). The complete load balancer — the alpinashop-lb-ip IP, hc-catalogo, bs-catalogo-web, bb-catalogo-imagenes, alpinashop-url-map, proxy and forwarding rule — the pol-catalogo-web Cloud Armor policy, the alpinashop-publica Cloud DNS zone and the alpinashop-cert certificate.
Why together: they are tightly coupled and separating them would leave odd intermediate states. It is the phase with the most interdependent resources and where the import order matters most.
Criterion: empty plan, the shop responds over HTTPS with the right certificate and the CDN is still serving hits.
Phase 4 — Compute (2 days). Instance template, the alpinashop-web-mig MIG and the alpinashop-cluster GKE Autopilot cluster.
Specific caution: instance templates are immutable by design; any difference produces a replace, and there a replace is acceptable provided the MIG performs a rolling update and not a simultaneous recreation. It has to be verified in the plan before applying.
Criterion: empty plan and a check that the MIG has not recreated instances unexpectedly.
Phase 5 — Data (2 days, with maximum caution). The alpinashop-pedidos Cloud SQL instance and its tienda database.
Mandatory precautions: a verified backup before starting; lifecycle { prevent_destroy = true } written before the first apply; and applying during a maintenance window with somebody watching.
Criterion: empty plan and the application connecting normally.
| Resource | Import? | Reason |
|---|---|---|
| Bucket contents | No | Terraform manages the container, not the data |
| Cloud SQL rows and schema | No | Schema migrations belong to the application (06-01) |
| Secret Manager values | No | The secret yes, its value never: it would end up in the state |
| BigQuery datasets and tables | It depends | The dataset yes; tables created by pipelines, no |
Kubernetes objects in the tienda namespace |
No | They go in alpinashop-catalogo with their manifests |
| Individual MIG instances | No | The MIG manages those, not Terraform |
| Google-managed certificates | Yes, the resource | Google handles the renewal |
The rule that unifies the exclusions column: Terraform manages the shape, not the contents. The bucket yes, the objects no. The database yes, the rows no. The secret yes, its value never — because any value Terraform manages ends up written in the state file, and that would turn the state into a credential store, contradicting everything in 03-06.
Total estimate: between two and three weeks of non-continuous work, alongside normal operations. And the three final recommendations:
- Everything in
alpinashop-devfirst. The cost of a mistake there is zero, and phase 5 in particular should not be touched in production without having rehearsed it. - One phase per pull request, with the
terraform planpasted into the description. That is what turns the migration into reviewable work rather than one person's heroic operation. - Every phase ends with an empty plan and functional verification, not just with an empty plan. An empty plan says the code matches the state; whether the shop works is something only the shop can say.
Conclusion
AlpinaShop now has a plan for no longer depending on a terminal's history.
You know what the concrete problem was and what it cost: irreproducible infrastructure, unreviewed changes, alpinashop-dev drifting away from alpinashop-prod until testing stopped meaning anything, and no answer to the question of what configuration was in place before the change that broke something.
You know what infrastructure as code is and why its value lies not in writing files but in never touching anything by hand again. You understand the difference between declarative and imperative and its practical consequence: idempotency, which lets you apply the configuration as many times as you like, and drift detection, which corrects whatever somebody changed on their own. With the associated danger: in a declarative tool, deleting a resource from the file means destroying it.
You understand what state is and why it is indispensable — knowing what is managed, with which identifier and how it was — and the fundamental difference between the two tools: Deployment Manager manages it for you, Terraform hands it over with all the responsibility that implies.
You genuinely know Deployment Manager: YAML configurations with types derived from the APIs, $(ref....) creating implicit dependencies, templates in Jinja for the simple cases and in Python when logic is needed, validation schemas, the create --preview / update / delete cycle, and the ABANDON and CREATE_OR_ACQUIRE policies. You can write AlpinaShop's complete network with it.
And you know that you must not use it. It is discontinued, it lost for being GCP-only, for having no ecosystem, for incomplete resource coverage and because the community chose something else. With the underlying lesson that goes beyond the tool: choosing technology is choosing an ecosystem, and a technically correct tool with no community behind it is a losing bet.
You know the succession: Infrastructure Manager, which is Terraform managed by Google — the explicit acknowledgement of who won — Config Connector, with its continuous reconciliation, for those already living in Kubernetes; and Terraform as the de facto standard.
And you have what is genuinely useful about this lesson: the complete migration procedure, which serves both for getting out of Deployment Manager and for bringing hand-built infrastructure into code. Inventory with Cloud Asset Inventory, export with gcloud beta resource-config bulk-export, clean the generated HCL — removing computed fields, replacing literals with references and, above all, writing down why each resource exists, because it is the only time anybody will look at them one by one — import with terraform import or with the declarative import blocks, and verify that the plan comes out empty, which is the only valid success criterion. With the table for diagnosing a plan that does not come out empty and the categorical rule: an unexpected -/+ replace must always stop you. And the --delete-policy=ABANDON that separates a clean migration from a disaster.
Finally, you have the concepts that carry over between tools — resources, references, dependencies, parameterisation, reuse, outputs, preview, state — and the four principles that hold in any of them: describe the result, let the dependencies be inferred, always preview and treat the code as the only source of truth.
With this, AlpinaShop knows what it wants to do and how to get there. What it lacks is the tool to do it with.
Before that, however, the rest of observability is still outstanding. The metrics from 06-04 warn that something is happening, but they do not say what. In 06-06 come Cloud Logging and Cloud Trace: the logs that answer what exactly happened in each case, the traces that say where the time went, and the complete journey through an AlpinaShop incident from the alert to the line of code. After that, in 06-07, Terraform closes the module by putting into practice everything you have learned to plan here.
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
