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
- You run stateless, horizontally-scaled workloads (web tiers, API fleets, batch workers) on a regional or zonal MIG and need a repeatable, golden-image-driven instance definition.
- You want immutable, blue/green-style VM rollouts where each config change mints a new template and the MIG performs a rolling or canary update onto it.
- You bake images with Packer (or use a hardened CIS/Shielded base image) and need Terraform to consume the latest image family deterministically.
- You need to enforce org-wide guardrails — Shielded VM, no external IP by default, least-privilege service accounts, OS Login — on every fleet without copy-pasting 60 lines of HCL per team.
- Reach for a different tool when: you need a single long-lived, individually-managed VM (use
google_compute_instanceinstead), or your config genuinely never changes (a plain template bynameis fine, butname_prefixcosts you nothing).
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 config — live/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 config — live/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
- Always use
name_prefix, never a staticname— paired withcreate_before_destroy, this is the only reliable way to recreate an immutable template that a live MIG references; a static name guarantees an “in use, cannot delete” failure on the next change. - Pin images to a family self-link, not a specific image ID — referencing
family/api-base-hardenedlets eachterraform applypick up the newest hardened build, while the unique template name makes the rollout auditable and reversible. - Keep
assign_external_ip = falseand run fleets behind Cloud NAT + an internal/external load balancer — direct external IPs on fleet members widen the attack surface and complicate egress controls; only NAT/bastion roles should setcan_ip_forward. - Attach a dedicated least-privilege service account with
cloud-platformscope, gated by IAM roles — avoid the default compute service account and legacy per-API scopes; let IAM bindings, not OAuth scopes, decide what the fleet can touch. - Use Spot (
preemptible = true) only for fault-tolerant, stateless tiers to cut compute cost by up to ~80%, and keepautomatic_restart = true(the default) for production tiers that must survive host maintenance. - Enforce CMEK (
boot_disk_kms_key_self_link) and Shielded VM defaults org-wide for regulated workloads, and standardizelabels(team, environment, cost-center) so billing export and policy tooling can attribute every instance the template spawns.