Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Google Cloud Instance Template Terraform Module

Creates and manages a single, wholly immutable Google Compute Engine instance template (google_compute_instance_template) with no external IP and full Shielded VM protections by default. Targets hashicorp/google ~> 7.0, Terraform >= 1.12.0.

Terraform Google Provider Module Version Module Type Resources Posture


🧩 Overview

  • πŸ–₯️ Creates one google_compute_instance_template β€” a versioned, disposable blueprint for GCE instances, most commonly consumed by a terraform-google-managed-instance-group for rolling, autoscaled workloads.
  • 🧊 Wholly immutable: every argument on this resource is force-new at the GCE API level. There is no in-place update path at all β€” a configuration change always means "destroy this template, create a new one."
  • πŸ” House pattern: name_prefix (never a fixed name) plus lifecycle { create_before_destroy = true }, so callers can roll template versions without a window where no template exists.
  • πŸ”’ No access_config {} block (external IP) is ever rendered unless the caller explicitly sets assign_external_ip = true; Shielded VM (secure boot, vTPM, integrity monitoring) is enabled by default β€” matching terraform-google-compute-instance's posture.
  • πŸ”Œ Consumes a subnetwork self_link from terraform-google-vpc-network and, optionally, a service account email from terraform-google-service-account β€” both as plain cross-module identity strings, never invented inside this module.
  • 🧱 Standalone by design β€” disks and network interfaces are nested configuration blocks on the template itself, not a separate for_each-managed child collection.

πŸ’‘ Why it matters: because this resource cannot be updated in place, a caller who does not understand the immutability gotcha will be surprised the first time a routine change (a machine type bump, a new boot image) replaces the entire template rather than patching it. This module makes that behavior explicit up front and ships the name_prefix + lifecycle { create_before_destroy = true } pattern as the house answer to "how do I roll this without downtime."


❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


πŸ—ΊοΈ Where this fits

graph LR
 PS["terraform-google-project-services"]:::external
 VPC["terraform-google-vpc-network"]:::keystoneSibling
 SA["terraform-google-service-account"]:::keystoneSibling
 KMS["terraform-google-kms-keyring"]:::external
 IT["terraform-google-instance-template"]:::thisModule
 FW["terraform-google-firewall-policy"]:::sibling
 MIG["terraform-google-managed-instance-group"]:::keystoneSibling

 PS -- "enables compute.googleapis.com" --> IT
 VPC -- "subnetwork self_link consumed by network_interfaces[*].subnetwork" --> IT
 SA -- "email consumed by service_account_email (optional)" --> IT
 KMS -- "crypto key self_link consumed by disks[*].disk_encryption_key.kms_key_self_link (optional CMEK)" --> IT
 IT -- "tags consumed as target_tags" --> FW
 IT -- "self_link / self_link_unique consumed by version.instance_template" --> MIG

 classDef thisModule fill:#4285F4,color:#ffffff,stroke:#174EA6,stroke-width:1px;
 classDef keystoneSibling fill:#174EA6,color:#ffffff,stroke:#174EA6,stroke-width:1px;
 classDef sibling fill:#E8EAED,color:#202124,stroke:#9AA0A6,stroke-width:1px;
 classDef external fill:#E8EAED,color:#202124,stroke:#9AA0A6,stroke-width:1px,stroke-dasharray: 3 3;
Loading

terraform-google-project-services (external, dashed) must have already enabled compute.googleapis.com before this module applies. terraform-google-vpc-network and terraform-google-service-account are this module's two direct upstream identity suppliers (subnetwork and, optionally, service account email). terraform-google-kms-keyring is an optional upstream supplier for CMEK disk encryption. Downstream, terraform-google-firewall-policy targets instances created from this template via network tags (not a direct resource reference), and terraform-google-managed-instance-group is the primary known consumer of this module's self_link/self_link_unique outputs, feeding them into its own version.instance_template argument for rolling deployment.

Validated via the Mermaid Chart MCP before embedding.


🧬 What this builds

graph LR
 subgraph Inputs
 A["var.name / name_prefix / machine_type / region"]
 B["var.disks"]
 C["var.network_interfaces"]
 D["var.assign_external_ip / external_access_config"]
 E["var.service_account_email / scopes"]
 F["var.scheduling"]
 G["var.shielded_instance_config"]
 H["var.guest_accelerators"]
 I["var.labels / timeouts"]
 end

 R["google_compute_instance_template.this"]:::thisModule

 A --> R
 B --> R
 C --> R
 D --> R
 E --> R
 F --> R
 G --> R
 H --> R
 I --> R

 R --> O1["output: id, self_link, self_link_unique"]
 R --> O2["output: name"]
 R --> O3["output: numeric_id"]

 classDef thisModule fill:#4285F4,color:#ffffff,stroke:#174EA6,stroke-width:1px;
Loading

Resource inventory (1 resource): google_compute_instance_template.this, with one-or-more disk nested blocks (unified boot+data disk β€” exactly one boot = true), one-or-more network_interface nested blocks (each optionally carrying an access_config and alias_ip_range), a service_account block (always rendered), scheduling, shielded_instance_config, zero-or-more guest_accelerator blocks, an optional advanced_machine_features block, an optional timeouts block (create/delete only), and a lifecycle { create_before_destroy = true } guard. No child resources, no for_each-managed collection β€” a single keystone resource with rich nested-block configuration.

Validated via the Mermaid Chart MCP before embedding.


βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/google provider ~> 7.0
Provider block None β€” the caller configures google (project, region/zone, auth)

Schema notes that bite (verified against hashicorp/google v7.39.0, cross-checked via the schema-JSON fallback at C:\tmp\gcp_schema\schema.json because the live provider documentation's chunked reference mixed this resource's content with its own data-source variant (name/filter/self_link_unique lookup arguments) and with google_compute_region_instance_template, so schema-JSON ground truth was used to disambiguate the real resource argument set field-for-field):

  • ⚠️⚠️ The entire resource is immutable β€” every argument is force-new. Unlike google_compute_instance (which allows some in-place changes via allow_stopping_for_update), there is no such escape hatch here. Any change destroys the existing template and creates a new one under a new identity.
  • disk is a single unified block, not separate boot_disk/attached_disk resources. The live schema has no nested initialize_params sub-block either β€” disk_size_gb, disk_type, source_image, etc. sit directly on each disk entry, with the boot disk distinguished only by its own boot = true flag. This genuinely diverges from google_compute_instance's shape β€” do not assume parity between the two resources.
  • network_interface.access_config.public_ptr_domain_name is computed-only on this resource β€” unlike google_compute_instance, where the same field is settable. This module does not expose it as a configurable field; setting it would be rejected by the provider.
  • timeouts supports only create and delete β€” there is no update timeout in the live schema, consistent with the resource's wholesale immutability.
  • name and name_prefix are mutually exclusive and both optional+computed; leaving both null lets the provider auto-generate a name. name_prefix longer than 37 characters falls back to a shortened, more collision-prone UUID suffix (54-character hard max).
  • This resource exposes both self_link and self_link_unique β€” the latter has no equivalent on google_compute_instance. Google's own docs recommend self_link_unique for a managed instance group's version.instance_template argument specifically to avoid a rare eventual-consistency staleness issue with the bare self_link after a create_before_destroy replacement.
  • GPU accelerators require scheduling.on_host_maintenance = "TERMINATE" β€” a live API constraint this module's validate/fmt gate cannot catch; an incompatible combination fails only at apply.
  • Shielded VM requires a Shielded-VM-capable boot image. An incompatible image fails only at apply, never at plan.
  • service_account {} is always rendered by this module, even when var.service_account_email is null, so that var.service_account_scopes's empty-list secure default actually takes effect.
  • disks[*].disk_type is deliberately not validated against a closed list. GCP adds new disk type families (Hyperdisk generations) over time; a hardcoded list would reject a legitimately new value until this module is updated.

πŸ”‘ Required IAM Roles

  • roles/compute.instanceAdmin.v1 on the target project β€” create, update, and delete instance templates.

☁️ GCP Prerequisites

  • compute.googleapis.com enabled on the target project (via terraform-google-project-services, applied before this module).
  • If assign_external_ip = true: confirm constraints/compute.vmExternalIpAccess does not deny external IPs at the org/folder level for instances created from this template β€” undetectable at plan time, only at apply.
  • If any disk_encryption_key.kms_key_self_link is set: the referenced Cloud KMS CryptoKey must already exist, and the Compute Engine service agent needs roles/cloudkms.cryptoKeyEncrypterDecrypter on it.

πŸ“ Module Structure

terraform-google-instance-template/
β”œβ”€β”€ providers.tf # required_providers (hashicorp/google ~> 7.0) + required_version β€” no provider {} block
β”œβ”€β”€ variables.tf # name/name_prefix, description, machine_type, region, disks, network_interfaces,
β”‚ # assign_external_ip, external_access_config, service_account_email/scopes,
β”‚ # scheduling, shielded_instance_config, guest_accelerators,
β”‚ # advanced_machine_features, labels, timeouts, and remaining scalar arguments
β”œβ”€β”€ main.tf # google_compute_instance_template.this β€” the sole resource, with
β”‚ # lifecycle { create_before_destroy = true }
β”œβ”€β”€ outputs.tf # id, self_link, self_link_unique, name, numeric_id
β”œβ”€β”€ README.md # this file
β”œβ”€β”€ SCOPE.md # lightweight cross-module contract
└── examples/ # runnable example matching the Quick Start below

βš™οΈ Quick Start

module "app_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "app-template-"
  machine_type = "e2-medium"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["app-subnet-use1"] }
  ]
}

The caller's root module configures the google provider (project, region/zone, and authentication via ADC, Workload Identity Federation, or a service account key supplied out-of-band) β€” this module accepts none of those as variables.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
network_interfaces[*].subnetwork string (self_link) terraform-google-vpc-network
service_account_email string (optional) terraform-google-service-account
disks[*].disk_encryption_key.kms_key_self_link string (optional, self_link) terraform-google-kms-keyring

Emits

Output Description Consumed by
id Terraform-internal resource identifier Any module needing the Terraform reference
self_link The template's URI (self_link) terraform-google-managed-instance-group's instance_template_self_link input β€” the output that module actually consumes
self_link_unique A uniquely-identifying URI for this specific template incarnation A MIG version.instance_template argument wanting the stronger eventual-consistency guarantee
name Realized template name (whichever of name/name_prefix/auto-generated took effect) Diagnostic/reference use
numeric_id GCE's server-assigned numeric template identifier Diagnostic/reference use

πŸ“š Example Library

1 Β· Minimal call
module "app_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "app-template-"
  machine_type = "e2-medium"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = "https://www.googleapis.com/compute/v1/projects/casey-prod-networking/regions/us-east1/subnetworks/app-subnet-use1" }
  ]
}

πŸ’‘ The minimal call is the safe, complete path: no external IP, Shielded VM fully enabled, empty service-account scopes β€” every secure default takes effect without the caller typing anything extra. Note name_prefix, not name β€” the house pattern from the very first example.

2 Β· Shielded VM defaults left in place
module "app_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "shielded-app-template-"
  machine_type = "e2-medium"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["app-subnet-use1"] }
  ]

  # shielded_instance_config intentionally omitted β€” secure boot, vTPM, and
  # integrity monitoring are all enabled by this module's own default.
}

πŸ”’ This example shows the default explicitly by omission β€” do not disable any shielded_instance_config field without a documented compatibility reason (e.g. a legacy image that predates Shielded VM support).

3 Β· Opting into assign_external_ip = true
module "bastion_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "bastion-template-"
  machine_type = "e2-small"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["mgmt-subnet-use1"] }
  ]

  assign_external_ip = true

  external_access_config = {
    network_tier = "PREMIUM"
  }
}

⚠️ Confirm constraints/compute.vmExternalIpAccess is not set to deny at the org/folder level β€” this module's validate/plan gate cannot detect that constraint; only apply surfaces it. Note external_access_config here has no public_ptr_domain_name field β€” that attribute is computed-only on this resource (see Schema notes that bite).

4 Β· Attaching a service account with least-privilege scopes
module "orders_api_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "orders-api-template-"
  machine_type = "e2-medium"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["app-subnet-use1"] }
  ]

  service_account_email = module.orders_api_sa.email
  # service_account_scopes intentionally left at its default `[]` β€” access is
  # granted via terraform-google-project-iam-bindings, not legacy instance scopes.
}

πŸ’‘ service_account_scopes defaults to [] specifically so IAM role bindings (additive, scoped to this identity) are the actual access-control mechanism β€” not broad legacy OAuth scopes.

5 Β· guest_accelerator usage (GPU-attached template)
module "ml_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "ml-training-template-"
  machine_type = "n1-standard-8"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 100
      disk_type    = "pd-ssd"
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["ml-subnet-use1"] }
  ]

  guest_accelerators = [
    { type = "nvidia-tesla-t4", count = 1 }
  ]

  scheduling = {
    on_host_maintenance = "TERMINATE"
  }
}

⚠️ GPU accelerators require on_host_maintenance = "TERMINATE" per the live GCP API β€” this module does not enforce the combination at plan time; an incompatible value fails only at apply.

6 Β· name_prefix + create_before_destroy rollout pattern
module "app_template_v2" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  # Bumping machine_type from "e2-medium" (v1) to "e2-standard-2" (v2) forces
  # an entirely new template. Because name_prefix (not a fixed name) is used
  # and main.tf carries lifecycle { create_before_destroy = true }, Terraform
  # creates the NEW template (under a freshly generated name) BEFORE
  # destroying the old one β€” there is never a moment with zero templates.
  name_prefix  = "app-template-"
  machine_type = "e2-standard-2"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["app-subnet-use1"] }
  ]
}

# A consuming terraform-google-managed-instance-group picks up the new self_link on
# its own rolling-update schedule β€” this module only guarantees the new
# template exists alongside the old one during the transition, not that
# running instances are updated.

πŸ’‘ This is the house answer to "how do I change something on an immutable resource without downtime": name_prefix, never a fixed name, plus create_before_destroy. A fixed name combined with create_before_destroy would fail β€” Terraform would try to create the replacement under the same name before the original is destroyed, and GCE rejects that as a duplicate.

7 Β· Why changing this field replaces the template
module "app_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "app-template-"
  machine_type = "e2-medium"

  disks = [
    {
      boot = true
      # Changing source_image here (e.g. a new Debian point release) does
      # NOT patch an existing template's boot image in place β€” there is no
      # such operation for a template. It forces a brand-new template
      # resource, with a brand-new self_link/self_link_unique, exactly like
      # changing machine_type, network_interfaces, or ANY other argument in
      # this file would.
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["app-subnet-use1"] }
  ]
}

⚠️ Unlike google_compute_instance (where only a handful of fields are truly force-new and several allow an in-place update via allow_stopping_for_update), every argument on google_compute_instance_template is force-new. Plan every change here as "this creates a new template," never as "this updates the existing template."

8 Β· Legacy workload requiring an explicit OAuth scope
module "legacy_batch_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "legacy-batch-template-"
  machine_type = "e2-standard-4"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-11"
      disk_size_gb = 50
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["batch-subnet-use1"] }
  ]

  service_account_email  = module.legacy_batch_sa.email
  service_account_scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}

⚠️ Explicit opt-out from this module's secure default. Only use broad legacy scopes for a workload that genuinely cannot be migrated to IAM-role-based access in the near term.

9 Β· Preemptible (SPOT) scheduling configuration
module "batch_worker_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "batch-worker-template-"
  machine_type = "e2-standard-4"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 30
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["batch-subnet-use1"] }
  ]

  scheduling = {
    preemptible                 = true
    automatic_restart           = false
    provisioning_model          = "SPOT"
    instance_termination_action = "STOP"
  }
}

ℹ️ automatic_restart must be false for a preemptible/SPOT instance β€” the API expects it set to false for consistency even though GCP will not auto-restart a preempted instance regardless.

10 Β· Multiple network_interface entries
module "dual_homed_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "dual-homed-template-"
  machine_type = "n2-standard-4"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["app-subnet-use1"] },
    { subnetwork = module.vpc_network.subnetwork_self_links["data-subnet-use1"] }
  ]
}

ℹ️ Only network_interfaces[0] (nic0) is ever eligible for assign_external_ip from this module β€” network_interfaces[1] (nic1) and beyond are always private-only.

11 Β· Additional data disk alongside the boot disk
module "database_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "db-template-"
  machine_type = "n2-standard-8"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 50
      disk_type    = "pd-ssd"
    },
    {
      boot         = false
      disk_name    = "data"
      disk_size_gb = 200
      disk_type    = "pd-balanced"
      auto_delete  = false
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["data-subnet-use1"] }
  ]
}

ℹ️ Unlike terraform-google-compute-instance (separate boot_disk + attached_disks), this module's disks list carries both the boot AND data disks together β€” exactly one entry must set boot = true. auto_delete = false on the data disk keeps it from being deleted if the template itself is later destroyed.

12 Β· CMEK-encrypted disk
module "regulated_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "regulated-workload-template-"
  machine_type = "n2-standard-4"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 50
      disk_encryption_key = {
        kms_key_self_link = module.kms_keyring.crypto_key_ids["instance-template-key"]
      }
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["regulated-subnet-use1"] }
  ]
}

πŸ”’ disk_encryption_key.kms_key_self_link is accepted as an optional variable and never defaulted to a specific key, per this suite's CMEK convention extended to this module's disk encryption.

13 Β· Advanced machine features
module "nested_virt_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "ci-runner-template-"
  machine_type = "n2-standard-8"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 50
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["ci-subnet-use1"] }
  ]

  advanced_machine_features = {
    enable_nested_virtualization = true
    threads_per_core             = 1 # disable SMT
  }
}

ℹ️ Leaving advanced_machine_features = null (the default) renders no advanced_machine_features {} block at all β€” this example shows the opt-in path.

14 Β· Custom labels and create/delete timeouts
module "app_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "app-template-labeled-"
  machine_type = "e2-medium"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["app-subnet-use1"] }
  ]

  labels = {
    environment = "prod"
    owning_team = "platform"
  }

  timeouts = {
    create = "10m"
    delete = "10m"
  }
}

ℹ️ Only create/delete are meaningful here β€” this resource's live schema has no update timeout at all, since there is nothing to update in place. Label keys/values must be lowercase letters, numbers, underscores, or hyphens (63 char max) β€” enforced at plan time.

15 Β· πŸ—οΈ End-to-end composition
module "project_services" {
  source = "git::https://github.com/microsoftexpert/terraform-google-project-services.git?ref=v1.0.0"

  # Enables compute.googleapis.com (and any other APIs the composition needs).
}

module "vpc_network" {
  source = "git::https://github.com/microsoftexpert/terraform-google-vpc-network.git?ref=v1.0.0"

  network_name = "casey-prod-networking"
  routing_mode = "REGIONAL"

  subnetworks = {
    "app-subnet-use1" = {
      ip_cidr_range = "10.0.1.0/24"
      region        = "us-east1"
    }
  }

  depends_on = [module.project_services]
}

module "orders_api_sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id   = "orders-api"
  display_name = "Orders API runtime identity"

  depends_on = [module.project_services]
}

module "orders_api_iam" {
  source = "git::https://github.com/microsoftexpert/terraform-google-project-iam-bindings.git?ref=v1.0.0"

  bindings = {
    "orders-api-secret-accessor" = {
      role   = "roles/secretmanager.secretAccessor"
      member = module.orders_api_sa.member
    }
  }
}

module "orders_api_template" {
  source = "git::https://github.com/microsoftexpert/terraform-google-instance-template.git?ref=v1.0.0"

  name_prefix  = "orders-api-template-"
  machine_type = "e2-medium"

  disks = [
    {
      boot         = true
      source_image = "debian-cloud/debian-12"
      disk_size_gb = 20
    }
  ]

  network_interfaces = [
    { subnetwork = module.vpc_network.subnetwork_self_links["app-subnet-use1"] }
  ]

  service_account_email = module.orders_api_sa.email
  tags                  = ["orders-api"]

  labels = {
    environment = "prod"
    owning_team = "platform"
  }

  depends_on = [module.orders_api_iam]
}

module "orders_api_mig" {
  source = "git::https://github.com/microsoftexpert/terraform-google-managed-instance-group.git?ref=v1.0.0"

  name                        = "orders-api-mig"
  region                      = "us-east1"
  instance_template_self_link = module.orders_api_template.self_link

  target_size = 3
}

πŸ’‘ This wires terraform-google-project-services β†’ terraform-google-vpc-network + terraform-google-service-account β†’ terraform-google-project-iam-bindings β†’ terraform-google-instance-template β†’ terraform-google-managed-instance-group in dependency order: APIs enabled first, then networking and identity created in parallel, then IAM roles granted, then the template itself (its service_account_email and network_interfaces[*].subnetwork both come from sibling module outputs, never a hardcoded string), and finally the managed instance group that actually runs instances from this template's self_link.

⚠️ IAM role grants can take up to ~60 seconds to propagate; the explicit depends_on on orders_api_iam orders the graph correctly, but a transient permission-denied error on first apply of an instance created from this template is still possible β€” see Troubleshooting.


πŸ“₯ Inputs

Variable Type Required Default Notes
name string No null Force-new; mutually exclusive with name_prefix; not recommended with create_before_destroy
name_prefix string No null House-recommended naming pattern; max 54 chars
description string No null
instance_description string No null Describes instances created from this template, not the template itself
machine_type string Yes β€” Force-new (whole-resource immutability)
region string No null Restricts the template to a region only if a regional resource (e.g. custom subnetwork) is referenced β€” this is a global resource
disks list(object({...})) Yes β€” At least one entry; exactly one boot = true; force-new
network_interfaces list(object({...})) Yes β€” At least one entry; entry 0 is the primary interface
assign_external_ip bool No false Secure default β€” gates access_config on entry 0 only
external_access_config object({...}) No {} Ignored unless assign_external_ip = true; no public_ptr_domain_name field (computed-only on this resource)
service_account_email string No null Source from terraform-google-service-account
service_account_scopes list(string) No [] Secure default β€” use IAM roles, not legacy scopes
tags list(string) No [] Network tags, consumed by firewall target_tags
metadata map(string) No {}
metadata_startup_script string No null
can_ip_forward bool No false
min_cpu_platform string No null
resource_policies list(string) No [] Max 1 supported by the GCP API; distinct from per-disk resource_policies
key_revocation_action_type string No null STOP | NONE
scheduling object({...}) No {}
shielded_instance_config object({...}) No {} (all enabled) Secure default
guest_accelerators list(object({...})) No [] Requires scheduling.on_host_maintenance = "TERMINATE"
advanced_machine_features object({...}) No null No block rendered when null
labels map(string) No {} GCP label-format validated
timeouts object({...}) No null create/delete only β€” no update timeout exists on this resource
Full variable schemas
variable "disks" {
  type = list(object({
    boot              = optional(bool, false)
    auto_delete       = optional(bool, true)
    device_name       = optional(string)
    disk_name         = optional(string)
    disk_size_gb      = optional(number)
    disk_type         = optional(string)
    source_image      = optional(string)
    source            = optional(string)
    source_snapshot   = optional(string)
    interface         = optional(string, "SCSI")
    mode              = optional(string, "READ_WRITE")
    type              = optional(string, "PERSISTENT")
    labels            = optional(map(string), {})
    resource_policies = optional(list(string), [])
    disk_encryption_key = optional(object({
      kms_key_self_link       = optional(string)
      kms_key_service_account = optional(string)
    }))
  }))
}

variable "network_interfaces" {
  type = list(object({
    subnetwork         = string
    network            = optional(string)
    subnetwork_project = optional(string)
    network_ip         = optional(string)
    nic_type           = optional(string)
    stack_type         = optional(string)
    queue_count        = optional(number)
    alias_ip_ranges = optional(list(object({
      ip_cidr_range         = string
      subnetwork_range_name = optional(string)
    })), [])
  }))
}

variable "external_access_config" {
  type = object({
    nat_ip       = optional(string)
    network_tier = optional(string, "PREMIUM")
  })
  default = {}
}

variable "scheduling" {
  type = object({
    preemptible                 = optional(bool, false)
    automatic_restart           = optional(bool, true)
    on_host_maintenance         = optional(string)
    provisioning_model          = optional(string)
    instance_termination_action = optional(string)
  })
  default = {}
}

variable "shielded_instance_config" {
  type = object({
    enable_secure_boot          = optional(bool, true)
    enable_vtpm                 = optional(bool, true)
    enable_integrity_monitoring = optional(bool, true)
  })
  default = {}
}

variable "guest_accelerators" {
  type = list(object({
    type  = string
    count = number
  }))
  default = []
}

variable "advanced_machine_features" {
  type = object({
    enable_nested_virtualization = optional(bool, false)
    threads_per_core             = optional(number)
    visible_core_count           = optional(number)
    turbo_mode                   = optional(string)
    performance_monitoring_unit  = optional(string)
    enable_uefi_networking       = optional(bool, false)
  })
  default = null
}

variable "labels" {
  type    = map(string)
  default = {}
  # keys/values: ^[a-z][a-z0-9_-]{0,62}$ / ^[a-z0-9_-]{0,63}$ enforced via validation {}
}

variable "timeouts" {
  type = object({
    create = optional(string)
    delete = optional(string)
  })
  default = null
}

See variables.tf for every remaining scalar variable (name, name_prefix, description, instance_description, machine_type, region, service_account_email, service_account_scopes, tags, metadata, metadata_startup_script, can_ip_forward, min_cpu_platform, resource_policies, key_revocation_action_type) and every validation {} block's exact condition.


🧾 Outputs

Output Description
id Terraform-internal resource identifier
self_link The template's URI (self_link) β€” the output terraform-google-managed-instance-group actually consumes
self_link_unique A uniquely-identifying URI for this specific template incarnation (no equivalent on google_compute_instance)
name Realized template name, whichever naming input actually took effect
numeric_id GCE's server-assigned numeric template identifier (distinct from id)

None of these outputs are secret-bearing; no sensitive = true is applied to any of them.


🧠 Architecture Notes

  • Every argument in this module is force-new β€” there is no in-place update path. This is the single fact this entire module is built around documenting. Unlike terraform-google-compute-instance (which allows several in-place changes via allow_stopping_for_update), there is no equivalent escape hatch for a template β€” any configuration change destroys the existing template and creates a new one under a new identity.
  • lifecycle { create_before_destroy = true } on the keystone resource was added during initial authoring (not part of any standard boilerplate) specifically to make that replace-on-any-change behavior survivable: the new template is created before the old one is destroyed, so a consuming managed instance group's own version reference is never left pointing at nothing. Confirmed this does not conflict with this suite's "record ordering via implicit references" convention, which concerns ordering between different resources, not lifecycle behavior on a single resource.
  • name_prefix, never a fixed name, is required for create_before_destroy to actually work. A fixed name would make Terraform attempt to create the replacement template under the SAME name before destroying the original β€” the GCE API rejects that as a duplicate. variables.tf documents this pairing explicitly and recommends name_prefix in its description.
  • disks unifies boot and data disks into one ordered list β€” a genuine structural divergence from google_compute_instance's separate boot_disk/attached_disk resources. Exactly one entry must set boot = true, enforced by this module's own validation {} block (the live schema only enforces min_items = 1 on the block itself, not the boot-disk cardinality).
  • external_access_config here has no public_ptr_domain_name field. That attribute is computed-only on google_compute_instance_template (confirmed against schema.json for both resources) β€” a genuine schema divergence from terraform-google-compute-instance's equivalent variable, which does expose it.
  • self_link_unique exists specifically because of the replace-on-any-change behavior. Google recommends it over the bare self_link for a managed instance group's version block to avoid a rare eventual-consistency staleness issue right after a create_before_destroy replacement.
  • Several nested blocks/fields the live schema exposes are deliberately out of scope for v1.0.0 β€” confidential_instance_config, network_performance_config, reservation_affinity, disk.source_image_encryption_key/disk.source_snapshot_encryption_key, resource_manager_tags (both instance- and disk-level), Hyperdisk-specific disk fields (provisioned_iops/provisioned_throughput/storage_pool), disk.architecture, disk.guest_os_features, network_interface's IPv6/dynamic-NIC extras, and scheduling's sole-tenant/advanced-SPOT fields. This mirrors this suite's "scope a first slice and expand deliberately" convention applied at the nested-block level rather than the module-catalog level β€” see variables.tf's file header for the full list. None of these are silently accepted and dropped; there is simply no variable exposing them yet.
  • service_account {} is always rendered, never omitted β€” the only way var.service_account_scopes's empty-list secure default actually takes effect, since omitting the block entirely lets GCP attach its own broader historical default scope set instead.
  • network_interfaces and disks are lists, not for_each-keyed maps. Ordering is semantically meaningful for both (GCE assigns device/interface index by list position; this module's external-IP gate keys off network_interfaces[0], and exactly one disks entry must be the boot disk), which is why these are the two repeating structures in the module that intentionally depart from the "keyed map" convention used for genuinely order-independent child collections elsewhere in this library.

🧱 Design Principles

Concern Secure default Opt-out (explicit)
Instance external IP No access_config {} block emitted unless assign_external_ip = true; applies to network_interfaces[0] only Caller sets assign_external_ip = true
Shielded VM (secure boot / vTPM / integrity monitoring) All three enabled by default via shielded_instance_config Caller disables per-flag, with a compatibility reason documented in the calling code
Service account OAuth scopes (this module's extension) service_account_scopes = [] β€” access granted via IAM role bindings on the attached identity, not legacy instance scopes Caller supplies an explicit scopes list (e.g. cloud-platform) for legacy workloads
Disk encryption (CMEK) disk_encryption_key.kms_key_self_link accepted as an optional variable, never defaulted to a specific key Caller supplies a terraform-google-kms-keyring crypto key self_link
Template rollout safety (this module's extension) lifecycle { create_before_destroy = true } on the keystone resource, paired with the name_prefix house recommendation Caller may still supply a fixed name, at the documented risk of a duplicate-name collision on the next change
IAM grants for the attached identity Not managed by this module β€” grant roles additively via terraform-google-project-iam-bindings, never an authoritative _iam_policy N/A β€” authoritative IAM policy is intentionally excluded from this library

πŸš€ Runbook

cd terraform-google-instance-template
terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module source to ?ref=v1.0.0 β€” never a branch. This library is plan-only from an authoring session; a human applies from CI with valid Workload Identity Federation or ADC credentials.


πŸ§ͺ Testing

terraform validate confirms internal type and reference consistency β€” every validation {} block (exactly-one-boot-disk, disk mode/interface/type enums, network-interface nic_type/stack_type, scheduling enums, name/name_prefix mutual exclusivity, label format, and more) fires at plan time, before any GCP API call. terraform fmt -check confirms canonical formatting. Neither can catch GCP API-level rejections: quota limits, the constraints/compute.vmExternalIpAccess org policy, a boot image incompatible with Shielded VM, a GPU/on_host_maintenance mismatch, or a duplicate-name collision from a fixed name combined with create_before_destroy. Only a real terraform plan/apply against a live project, with valid credentials, exercises those paths β€” that step belongs to the consuming CI pipeline, not this authoring session. Because this resource is wholly immutable, plan against a live project is also the only way to confirm a given configuration change produces the replacement a caller expects, rather than an unexpected diff on a field assumed to be updatable in place.


πŸ’¬ Example Output

$ terraform output

id = "projects/casey-prod-workloads/global/instanceTemplates/orders-api-template-a1b2"
name = "orders-api-template-a1b2"
numeric_id = "5821093746582910234"
self_link = "https://www.googleapis.com/compute/v1/projects/casey-prod-workloads/global/instanceTemplates/orders-api-template-a1b2"
self_link_unique = "https://www.googleapis.com/compute/v1/projects/casey-prod-workloads/global/instanceTemplates/orders-api-template-a1b2?uniqueId=5821093746582910234"

πŸ” Troubleshooting

Symptom Cause Fix
apply replaces the template after only changing the boot image Every argument on this resource is force-new β€” there is no in-place OS patch path, unlike google_compute_instance Expect and plan for template replacement; a consuming MIG needs its own rolling-update strategy to roll the change out to running instances
Changed a field and got a brand-new template instead of an update This is expected β€” the resource has no update API at all Confirm the change is intentional; use name_prefix + the default create_before_destroy lifecycle so the replacement does not create a downtime window
apply fails with a duplicate-name error when rolling a template A fixed var.name was used instead of var.name_prefix, colliding with create_before_destroy's create-then-destroy ordering Switch to name_prefix β€” never a fixed name β€” for any template that will be rolled
plan fails with "at most one of name, name_prefix" Both var.name and var.name_prefix were set Set at most one β€” supply name_prefix for the house rollout pattern
plan fails with "disks must contain exactly one entry with boot = true" Zero or more than one disks entry set boot = true Set boot = true on exactly one entry β€” the boot disk
GPU-attached template fails to create at apply (not caught by validate) scheduling.on_host_maintenance was left at MIGRATE (or unset) with guest_accelerators populated Set scheduling.on_host_maintenance = "TERMINATE"
Instance creation from this template fails with a Shielded VM error Boot image is not on GCP's Shielded-VM-capable image list Choose a Shielded-VM-compatible image, or explicitly disable the specific unsupported shielded_instance_config field with a documented reason
assign_external_ip = true but instances from this template never get a public IP at apply Org policy constraints/compute.vmExternalIpAccess denies external IPs at the org/folder level Confirm the org policy with your GCP organization administrator before relying on this flag
plan fails with a labels validation error A label key/value contains uppercase letters, starts with a number, or exceeds 63 characters Adjust to lowercase letters, numbers, underscores, or hyphens only, 63 chars max
An IAM-role-dependent workload on an instance from this template fails immediately after apply IAM role grants (e.g. via terraform-google-project-iam-bindings) can take up to ~60 seconds to propagate Retry, or add a short delay between the IAM-binding apply and workload startup

πŸ”— Related Docs

  • google_compute_instance_template provider resource reference
  • terraform-google-vpc-network (supplies network_interfaces[*].subnetwork)
  • terraform-google-service-account (supplies service_account_email)
  • terraform-google-kms-keyring (supplies optional CMEK kms_key_self_link)
  • terraform-google-firewall-policy (consumes this module's tags via target_tags)
  • terraform-google-managed-instance-group (consumes this module's self_link/self_link_unique)
  • terraform-google-project-iam-bindings (grants IAM roles to the attached service account)
  • This module's SCOPE.md

About

Terraform module: terraform-google-instance-template

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages