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

  1. The concrete problem: infrastructure that only exists in a history file
  2. What infrastructure as code is
  3. Declarative versus imperative, and idempotency
  4. State: the idea that makes everything else possible
  5. Deployment Manager: GCP's native tool
  6. Templates in Jinja and in Python
  7. A real example: AlpinaShop's VPC and firewall
  8. The central warning: Deployment Manager is discontinued
  9. The succession: Infrastructure Manager, Config Connector and Terraform
  10. How to migrate from Deployment Manager to Terraform, step by step
  11. The concepts that carry over between tools

  1. 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.

  1. 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.

  1. 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/22

Declarative is describing the desired result, and letting the tool work out the steps:

resources:
  - name: alpinashop-vpc
    type: compute.v1.network
    properties:
      autoCreateSubnetworks: false

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.

  1. 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.

  1. 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: true

The $(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-dev

Two operational warnings worth keeping in mind:

  • --preview is the equivalent of Terraform's plan and must never be skipped in a serious environment. It shows what will be created, modified or destroyed before anything is touched.
  • deployments delete deletes 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-dev

ABANDON 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.

  1. 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/24

A 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.

  1. 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-prod

And 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.

  1. 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:

  1. 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 .yaml and run deployments describe is a practical skill.
  2. 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.
  3. 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.

  1. 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=prod

What 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: REGIONAL

You 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

  1. 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-prod

A 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.txt

The 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/red

It 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-vpcred_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-salud

And 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:

terraform plan
No changes. Your infrastructure matches the configuration.

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.

  1. 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:

  1. Describe the result, not the steps. The tool works out the path.
  2. Let dependencies be inferred from the references. Declaring the order by hand is a source of errors; depends_on is the last resort.
  3. Always preview before applying. --preview or plan, with no exceptions in production.
  4. 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-legacy

The 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-dev first. 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 plan pasted 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

Module 2: Core GCP Services

Module 3: Networking and Security

Module 4: Data and Analytics

Module 5: Machine Learning and AI

Module 6: DevOps and Monitoring

Module 7: Advanced GCP Topics

Module 8: Final Project

© Copyright 2026. All rights reserved