IaC GCP

Terraform Module: GCP Instance Template — Immutable, Versioned Blueprints for Managed Instance Groups

Quick take — A production-ready Terraform module for google_compute_instance_template on hashicorp/google ~> 5.0: Shielded VM, name_prefix versioning, create_before_destroy, and outputs wired straight into a MIG. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "instance_template" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-instance-template?ref=v1.0.0"

  project_id            = "..."  # GCP project ID in which the template is created.
  name                  = "..."  # Base name used as a `name_prefix`; RFC1035, ≤ 54 chars.
  source_image          = "..."  # Boot disk image or image family self-link.
  network               = "..."  # VPC network self-link or name.
  subnetwork            = "..."  # Subnetwork self-link or name for the NIC.
  service_account_email = "..."  # Least-privilege service account email for instances.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

A GCP Instance Template (google_compute_instance_template) is an immutable blueprint that defines the machine type, boot disk image, network interfaces, service account, metadata, and security posture for VMs. You don’t run a template directly — you hand it to a Managed Instance Group (MIG), which stamps out identical instances from it for autoscaling, rolling updates, and self-healing.

The catch that bites every team: instance templates are immutable in-place. Once created, you cannot edit a template’s machine type, image, or metadata — GCP rejects the update. Terraform’s only path is to destroy and recreate, and a naive module will try to delete a template that a live MIG is still referencing, which the API blocks. That deadlock is exactly why wrapping this resource in a module pays off.

This module solves it the canonical way: it uses name_prefix instead of name so every change produces a uniquely-named template, combined with a create_before_destroy lifecycle so the new template exists before the old one is torn down. The result is a clean, versioned blueprint you can flip a MIG onto with zero downtime — plus baked-in Shielded VM defaults, validated machine-type input, and outputs (id, self_link, name) designed to feed a MIG resource directly.

When to use it

Module structure

terraform-module-gcp-instance-template/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # google_compute_instance_template resource
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, self_link, name, tags, service account

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # A template with no explicit scopes still gets the default GCE scope set
  # unless we pin "cloud-platform" and let IAM roles do the gating. We prefer
  # cloud-platform + least-privilege IAM over legacy scopes.
  service_account_scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}

resource "google_compute_instance_template" "this" {
  project = var.project_id

  # name_prefix (NOT name) is what makes immutable templates safe to recreate:
  # every change yields a unique name, so the MIG can migrate before destroy.
  name_prefix = "${var.name}-"
  description = var.description

  machine_type   = var.machine_type
  region         = var.region
  can_ip_forward = var.can_ip_forward

  tags   = var.network_tags
  labels = var.labels

  metadata = var.metadata

  # Boot disk built from a public/custom image or image family.
  disk {
    source_image = var.source_image
    auto_delete  = true
    boot         = true
    disk_size_gb = var.boot_disk_size_gb
    disk_type    = var.boot_disk_type

    dynamic "disk_encryption_key" {
      for_each = var.boot_disk_kms_key_self_link == null ? [] : [1]
      content {
        kms_key_self_link = var.boot_disk_kms_key_self_link
      }
    }
  }

  network_interface {
    network    = var.network
    subnetwork = var.subnetwork

    # Only attach an ephemeral external IP when explicitly requested.
    dynamic "access_config" {
      for_each = var.assign_external_ip ? [1] : []
      content {
        network_tier = var.network_tier
      }
    }
  }

  # Shielded VM: secure boot, vTPM, and integrity monitoring on by default.
  shielded_instance_config {
    enable_secure_boot          = var.enable_secure_boot
    enable_vtpm                 = var.enable_vtpm
    enable_integrity_monitoring = var.enable_integrity_monitoring
  }

  service_account {
    email  = var.service_account_email
    scopes = local.service_account_scopes
  }

  scheduling {
    preemptible       = var.preemptible
    automatic_restart = var.preemptible ? false : var.automatic_restart
    # Spot is the modern successor to preemptible; expose it explicitly.
    provisioning_model = var.preemptible ? "SPOT" : "STANDARD"
  }

  # Recreating a template while a MIG references it is rejected by the API
  # unless the replacement already exists. create_before_destroy fixes that.
  lifecycle {
    create_before_destroy = true
  }
}

variables.tf

variable "project_id" {
  type        = string
  description = "GCP project ID in which the instance template is created."
}

variable "name" {
  type        = string
  description = "Base name for the template; used as a name_prefix so each revision is unique."

  validation {
    # name_prefix appends a hyphen + generated suffix, so the base must stay short.
    condition     = can(regex("^[a-z]([-a-z0-9]{0,53}[a-z0-9])?$", var.name))
    error_message = "name must be RFC1035-compliant (lowercase letters, digits, hyphens) and <= 54 chars to leave room for the generated suffix."
  }
}

variable "description" {
  type        = string
  description = "Human-readable description of the template's purpose."
  default     = "Managed by Terraform"
}

variable "machine_type" {
  type        = string
  description = "Compute machine type (e.g. e2-standard-2, n2-standard-4)."
  default     = "e2-medium"

  validation {
    condition     = can(regex("^[a-z0-9]+-[a-z0-9]+(-[0-9]+)?$", var.machine_type))
    error_message = "machine_type must look like a GCE machine type, e.g. e2-standard-2 or custom-4-8192."
  }
}

variable "region" {
  type        = string
  description = "Region the template targets (e.g. asia-south1). Required for regional MIGs."
  default     = null
}

variable "source_image" {
  type        = string
  description = "Boot disk image or image family (e.g. projects/debian-cloud/global/images/family/debian-12)."
}

variable "boot_disk_size_gb" {
  type        = number
  description = "Boot disk size in GB."
  default     = 20

  validation {
    condition     = var.boot_disk_size_gb >= 10 && var.boot_disk_size_gb <= 2048
    error_message = "boot_disk_size_gb must be between 10 and 2048."
  }
}

variable "boot_disk_type" {
  type        = string
  description = "Boot disk type: pd-standard, pd-balanced, pd-ssd, or hyperdisk-balanced."
  default     = "pd-balanced"

  validation {
    condition     = contains(["pd-standard", "pd-balanced", "pd-ssd", "hyperdisk-balanced"], var.boot_disk_type)
    error_message = "boot_disk_type must be one of pd-standard, pd-balanced, pd-ssd, hyperdisk-balanced."
  }
}

variable "boot_disk_kms_key_self_link" {
  type        = string
  description = "Optional CMEK self_link to encrypt the boot disk. Null uses Google-managed keys."
  default     = null
}

variable "network" {
  type        = string
  description = "Self-link or name of the VPC network."
}

variable "subnetwork" {
  type        = string
  description = "Self-link or name of the subnetwork the NIC attaches to."
}

variable "network_tags" {
  type        = list(string)
  description = "Network tags applied to instances for firewall targeting."
  default     = []
}

variable "labels" {
  type        = map(string)
  description = "Labels applied to the template and resulting instances."
  default     = {}
}

variable "metadata" {
  type        = map(string)
  description = "Instance metadata (e.g. startup-script, enable-oslogin = \"TRUE\")."
  default     = {}
}

variable "can_ip_forward" {
  type        = bool
  description = "Allow instances to send/receive packets with non-matching IPs (for NAT/router VMs)."
  default     = false
}

variable "assign_external_ip" {
  type        = bool
  description = "Attach an ephemeral external IP. Keep false for private fleets behind a NAT/LB."
  default     = false
}

variable "network_tier" {
  type        = string
  description = "Network tier for the external IP when assigned: PREMIUM or STANDARD."
  default     = "PREMIUM"

  validation {
    condition     = contains(["PREMIUM", "STANDARD"], var.network_tier)
    error_message = "network_tier must be PREMIUM or STANDARD."
  }
}

variable "service_account_email" {
  type        = string
  description = "Email of the least-privilege service account attached to instances."
}

variable "enable_secure_boot" {
  type        = bool
  description = "Enable Shielded VM secure boot."
  default     = true
}

variable "enable_vtpm" {
  type        = bool
  description = "Enable Shielded VM virtual TPM."
  default     = true
}

variable "enable_integrity_monitoring" {
  type        = bool
  description = "Enable Shielded VM integrity monitoring."
  default     = true
}

variable "preemptible" {
  type        = bool
  description = "Run instances as Spot/preemptible for cost savings on fault-tolerant workloads."
  default     = false
}

variable "automatic_restart" {
  type        = bool
  description = "Automatically restart instances on maintenance/crash (ignored when preemptible)."
  default     = true
}

outputs.tf

output "id" {
  description = "The fully-qualified ID of the instance template."
  value       = google_compute_instance_template.this.id
}

output "self_link" {
  description = "The self_link of the template — feed this to a MIG's instance_template field."
  value       = google_compute_instance_template.this.self_link
}

output "name" {
  description = "The generated unique name of the template (name_prefix + suffix)."
  value       = google_compute_instance_template.this.name
}

output "tags_fingerprint" {
  description = "Fingerprint of the network tags, useful for drift detection."
  value       = google_compute_instance_template.this.tags_fingerprint
}

output "service_account_email" {
  description = "Service account email bound to instances created from this template."
  value       = google_compute_instance_template.this.service_account[0].email
}

How to use it

Consume the module, then wire its self_link straight into a regional MIG that performs rolling updates:

module "instance_template" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-instance-template?ref=v1.0.0"

  project_id            = "kloudvin-prod"
  name                  = "api-web"
  region                = "asia-south1"
  machine_type          = "n2-standard-2"
  source_image          = "projects/kloudvin-images/global/images/family/api-base-hardened"
  boot_disk_size_gb     = 30
  boot_disk_type        = "pd-balanced"
  network               = "projects/kloudvin-prod/global/networks/app-vpc"
  subnetwork            = "projects/kloudvin-prod/regions/asia-south1/subnetworks/web-asia-south1"
  service_account_email = "api-fleet@kloudvin-prod.iam.gserviceaccount.com"
  network_tags          = ["web", "allow-health-check"]

  metadata = {
    enable-oslogin = "TRUE"
    startup-script = file("${path.module}/scripts/bootstrap.sh")
  }

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

# Downstream: a regional MIG consumes the template's self_link.
# Because the module uses name_prefix + create_before_destroy, any image or
# machine_type change mints a NEW template and this MIG rolls onto it cleanly.
resource "google_compute_region_instance_group_manager" "api" {
  name               = "api-web-mig"
  project            = "kloudvin-prod"
  region             = "asia-south1"
  base_instance_name = "api-web"

  version {
    instance_template = module.instance_template.self_link
  }

  target_size = 3

  update_policy {
    type                  = "PROACTIVE"
    minimal_action        = "REPLACE"
    max_surge_fixed       = 3
    max_unavailable_fixed = 0
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/instance_template/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-instance-template?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  name = "..."
  source_image = "..."
  network = "..."
  subnetwork = "..."
  service_account_email = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/instance_template && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
project_id string yes GCP project ID in which the template is created.
name string yes Base name used as a name_prefix; RFC1035, ≤ 54 chars.
description string "Managed by Terraform" no Human-readable description of the template.
machine_type string "e2-medium" no GCE machine type (e.g. n2-standard-4, custom-4-8192).
region string null no Region the template targets; set for regional MIGs.
source_image string yes Boot disk image or image family self-link.
boot_disk_size_gb number 20 no Boot disk size in GB (10–2048).
boot_disk_type string "pd-balanced" no One of pd-standard, pd-balanced, pd-ssd, hyperdisk-balanced.
boot_disk_kms_key_self_link string null no CMEK self_link for boot disk encryption; null = Google-managed.
network string yes VPC network self-link or name.
subnetwork string yes Subnetwork self-link or name for the NIC.
network_tags list(string) [] no Network tags for firewall targeting.
labels map(string) {} no Labels applied to the template and instances.
metadata map(string) {} no Instance metadata (startup-script, enable-oslogin, etc.).
can_ip_forward bool false no Allow IP forwarding for NAT/router instances.
assign_external_ip bool false no Attach an ephemeral external IP.
network_tier string "PREMIUM" no External IP tier: PREMIUM or STANDARD.
service_account_email string yes Least-privilege service account email for instances.
enable_secure_boot bool true no Shielded VM secure boot.
enable_vtpm bool true no Shielded VM virtual TPM.
enable_integrity_monitoring bool true no Shielded VM integrity monitoring.
preemptible bool false no Run instances as Spot/preemptible.
automatic_restart bool true no Auto-restart on maintenance/crash (ignored when preemptible).

Outputs

Name Description
id Fully-qualified ID of the instance template.
self_link Self_link of the template; pass to a MIG’s instance_template.
name Generated unique name (name_prefix + suffix).
tags_fingerprint Fingerprint of the network tags for drift detection.
service_account_email Service account email bound to instances from this template.

Enterprise scenario

A fintech platform team runs its customer-facing API fleet on a regional MIG across three zones in asia-south1 for an RBI data-residency requirement. Every Friday, Packer bakes a new hardened image with the latest OS patches and publishes it to the api-base-hardened family; the team bumps source_image and the module’s name_prefix plus create_before_destroy lifecycle mints a fresh template while the old one keeps serving. The MIG then performs a PROACTIVE rolling update with max_unavailable_fixed = 0, replacing instances in surges of three with zero customer-visible downtime, and the Shielded VM defaults (secure boot + integrity monitoring) satisfy the PCI-DSS hardening control without any per-team configuration.

Best practices

TerraformGCPInstance TemplateModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading