IaC GCP

Terraform Module: GCP Compute Instance — a hardened, var-driven VM with sane defaults

Quick take — A reusable Terraform module for google_compute_instance on hashicorp/google ~> 5.0: Shielded VM, attached data disks, service accounts with least-privilege scopes, and metadata-driven startup. 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 "compute_instance" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-compute-instance?ref=v1.0.0"

  project_id = "..."  # GCP project ID for the instance and disks.
  name       = "..."  # Instance name (RFC 1035 validated); also the data disk …
  zone       = "..."  # Zone, e.g. `asia-south1-a`.
}

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

What this module is

A google_compute_instance is a single Compute Engine virtual machine: a boot disk, a machine type, one or more network interfaces, and a bag of metadata, tags, and labels that GCP uses to wire up networking, OS Login, and startup behaviour. On its own the resource is deceptively large — it has nested blocks for boot_disk, attached_disk, network_interface.access_config, service_account, shielded_instance_config, and scheduling, and getting any one of them wrong (an external IP you didn’t want, default scopes that grant cloud-platform, a non-Shielded image) is the kind of mistake that shows up in a security review three months later.

Wrapping it in a module forces the safe choices to be the defaults. This module pins Shielded VM on, defaults to no public IP, attaches a dedicated runtime service account with explicitly scoped access instead of the project default, and lets you add data disks and a startup script through variables. You get a consistent VM shape across every team and environment, and a single place to roll out a hardening change (say, enabling Confidential Compute or switching to pd-balanced) instead of editing dozens of copy-pasted resource blocks.

When to use it

Module structure

terraform-module-gcp-compute-instance/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}
# main.tf
locals {
  # Merge a baseline label set with caller-supplied labels.
  base_labels = {
    managed_by = "terraform"
    module     = "gcp-compute-instance"
  }
  labels = merge(local.base_labels, var.labels)

  # Build the metadata map. Only inject the startup script key when one is given,
  # and enable OS Login unless the caller explicitly opts out.
  metadata = merge(
    var.metadata,
    { enable-oslogin = var.enable_oslogin ? "TRUE" : "FALSE" },
    var.startup_script != null ? { startup-script = var.startup_script } : {},
  )
}

resource "google_compute_instance" "this" {
  project      = var.project_id
  name         = var.name
  zone         = var.zone
  machine_type = var.machine_type

  # Allow Terraform to stop the VM to apply changes that require it
  # (e.g. machine_type or shielded config changes).
  allow_stopping_for_update = var.allow_stopping_for_update

  tags                      = var.network_tags
  labels                    = local.labels
  metadata                  = local.metadata
  deletion_protection       = var.deletion_protection
  enable_display            = var.enable_display
  can_ip_forward            = var.can_ip_forward
  resource_policies         = var.resource_policies

  boot_disk {
    auto_delete = var.boot_disk_auto_delete

    initialize_params {
      image  = var.boot_image
      size   = var.boot_disk_size_gb
      type   = var.boot_disk_type
      labels = local.labels
    }
  }

  # Additional persistent data disks created and attached to this VM.
  dynamic "attached_disk" {
    for_each = var.data_disks
    content {
      source      = google_compute_disk.data[attached_disk.key].id
      device_name = attached_disk.value.device_name
      mode        = attached_disk.value.mode
    }
  }

  network_interface {
    network            = var.network
    subnetwork         = var.subnetwork
    subnetwork_project = var.subnetwork_project
    network_ip         = var.network_ip

    # An external IP is only attached when explicitly requested.
    dynamic "access_config" {
      for_each = var.assign_external_ip ? [1] : []
      content {
        nat_ip       = var.external_ip_address
        network_tier = var.network_tier
      }
    }
  }

  # Least-privilege runtime identity. Defaults to the cloud-platform scope so
  # access is governed by the service account's IAM roles, not coarse legacy scopes.
  service_account {
    email  = var.service_account_email
    scopes = var.service_account_scopes
  }

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

  scheduling {
    preemptible                 = var.preemptible
    automatic_restart           = var.preemptible ? false : var.automatic_restart
    on_host_maintenance         = var.preemptible ? "TERMINATE" : var.on_host_maintenance
    provisioning_model          = var.spot ? "SPOT" : "STANDARD"
    instance_termination_action = var.spot ? var.instance_termination_action : null
  }

  lifecycle {
    # Avoid spurious diffs from agents/OS Login mutating instance metadata.
    ignore_changes = [metadata["ssh-keys"]]
  }
}

# Dedicated data disks, keyed by the map provided in var.data_disks.
resource "google_compute_disk" "data" {
  for_each = var.data_disks

  project = var.project_id
  name    = coalesce(each.value.name, "${var.name}-${each.key}")
  zone    = var.zone
  type    = each.value.type
  size    = each.value.size_gb
  labels  = local.labels
}
# variables.tf
variable "project_id" {
  type        = string
  description = "GCP project ID where the instance and disks are created."
}

variable "name" {
  type        = string
  description = "Name of the compute instance. Becomes the prefix for data disk names."

  validation {
    condition     = can(regex("^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$", var.name))
    error_message = "name must be 1-63 chars, lowercase, start with a letter, and contain only letters, digits, and hyphens (RFC 1035)."
  }
}

variable "zone" {
  type        = string
  description = "Zone for the instance, e.g. asia-south1-a."
}

variable "machine_type" {
  type        = string
  description = "Machine type, e.g. e2-standard-2 or n2-standard-4."
  default     = "e2-medium"
}

variable "boot_image" {
  type        = string
  description = "Source image or image family for the boot disk, e.g. projects/debian-cloud/global/images/family/debian-12."
  default     = "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
    error_message = "boot_disk_size_gb must be at least 10 GB."
  }
}

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_auto_delete" {
  type        = bool
  description = "Whether the boot disk is deleted when the instance is deleted."
  default     = true
}

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

variable "subnetwork" {
  type        = string
  description = "Self-link or name of the subnetwork to attach the primary NIC to."
  default     = null
}

variable "subnetwork_project" {
  type        = string
  description = "Project that owns the subnetwork (for Shared VPC host projects). Defaults to project_id."
  default     = null
}

variable "network_ip" {
  type        = string
  description = "Optional static internal IP. Leave null to let GCP assign one."
  default     = null
}

variable "assign_external_ip" {
  type        = bool
  description = "Attach an ephemeral or static external IP. Private-by-default when false."
  default     = false
}

variable "external_ip_address" {
  type        = string
  description = "Reserved external IP address to attach. Null uses an ephemeral IP (when assign_external_ip is true)."
  default     = null
}

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

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

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

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

variable "labels" {
  type        = map(string)
  description = "Labels merged onto the instance and its disks."
  default     = {}
}

variable "metadata" {
  type        = map(string)
  description = "Custom instance metadata key/value pairs."
  default     = {}
}

variable "startup_script" {
  type        = string
  description = "Startup script contents, injected as the startup-script metadata key. Null to omit."
  default     = null
}

variable "enable_oslogin" {
  type        = bool
  description = "Enable OS Login so SSH access is governed by IAM rather than instance ssh-keys."
  default     = true
}

variable "service_account_email" {
  type        = string
  description = "Email of the runtime service account. Strongly prefer a dedicated SA over the default compute SA."
  default     = null
}

variable "service_account_scopes" {
  type        = list(string)
  description = "OAuth scopes for the service account. Default cloud-platform delegates authorization to IAM roles."
  default     = ["https://www.googleapis.com/auth/cloud-platform"]
}

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

variable "enable_vtpm" {
  type        = bool
  description = "Shielded VM virtual Trusted Platform Module."
  default     = true
}

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

variable "enable_display" {
  type        = bool
  description = "Enable the virtual display device."
  default     = false
}

variable "deletion_protection" {
  type        = bool
  description = "Prevent the instance from being deleted via the API/Terraform."
  default     = false
}

variable "allow_stopping_for_update" {
  type        = bool
  description = "Permit Terraform to stop the VM when applying changes that require it."
  default     = true
}

variable "preemptible" {
  type        = bool
  description = "Use a legacy preemptible VM (max 24h, no restart). Prefer spot for new workloads."
  default     = false
}

variable "spot" {
  type        = bool
  description = "Use Spot provisioning (SPOT model). Mutually informs scheduling fields below."
  default     = false
}

variable "instance_termination_action" {
  type        = string
  description = "Action when a Spot VM is preempted: STOP or DELETE."
  default     = "STOP"

  validation {
    condition     = contains(["STOP", "DELETE"], var.instance_termination_action)
    error_message = "instance_termination_action must be STOP or DELETE."
  }
}

variable "automatic_restart" {
  type        = bool
  description = "Automatically restart the instance if it is terminated by GCP (non-preemptible only)."
  default     = true
}

variable "on_host_maintenance" {
  type        = string
  description = "Maintenance behaviour: MIGRATE or TERMINATE."
  default     = "MIGRATE"

  validation {
    condition     = contains(["MIGRATE", "TERMINATE"], var.on_host_maintenance)
    error_message = "on_host_maintenance must be MIGRATE or TERMINATE."
  }
}

variable "resource_policies" {
  type        = list(string)
  description = "Self-links of resource policies (e.g. snapshot schedules) to attach to the instance."
  default     = []
}

variable "data_disks" {
  type = map(object({
    size_gb     = number
    type        = optional(string, "pd-balanced")
    mode        = optional(string, "READ_WRITE")
    device_name = optional(string)
    name        = optional(string)
  }))
  description = "Map of additional persistent data disks to create and attach, keyed by a short suffix (e.g. \"data1\")."
  default     = {}

  validation {
    condition = alltrue([
      for d in values(var.data_disks) : contains(["READ_WRITE", "READ_ONLY"], d.mode)
    ])
    error_message = "Each data disk mode must be READ_WRITE or READ_ONLY."
  }
}
# outputs.tf
output "id" {
  description = "Fully qualified instance ID."
  value       = google_compute_instance.this.id
}

output "name" {
  description = "Name of the instance."
  value       = google_compute_instance.this.name
}

output "self_link" {
  description = "URI (self-link) of the instance."
  value       = google_compute_instance.this.self_link
}

output "instance_id" {
  description = "Server-assigned numeric instance ID."
  value       = google_compute_instance.this.instance_id
}

output "zone" {
  description = "Zone the instance runs in."
  value       = google_compute_instance.this.zone
}

output "internal_ip" {
  description = "Primary internal IP address of the instance."
  value       = google_compute_instance.this.network_interface[0].network_ip
}

output "external_ip" {
  description = "External IP address, or null when no external IP is attached."
  value       = try(google_compute_instance.this.network_interface[0].access_config[0].nat_ip, null)
}

output "service_account_email" {
  description = "Email of the service account attached to the instance."
  value       = try(google_compute_instance.this.service_account[0].email, null)
}

output "data_disk_ids" {
  description = "Map of data disk suffix to created disk ID."
  value       = { for k, d in google_compute_disk.data : k => d.id }
}

How to use it

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

  project_id   = "kv-platform-prod"
  name         = "kv-app-runner-01"
  zone         = "asia-south1-a"
  machine_type = "n2-standard-2"

  # Private-by-default: no external IP, traffic egresses via Cloud NAT.
  network            = "projects/kv-network-host/global/networks/kv-vpc"
  subnetwork         = "projects/kv-network-host/regions/asia-south1/subnetworks/kv-apps-asia-south1"
  subnetwork_project = "kv-network-host"

  boot_image        = "projects/debian-cloud/global/images/family/debian-12"
  boot_disk_size_gb = 30
  boot_disk_type    = "pd-balanced"

  # Dedicated least-privilege identity instead of the default compute SA.
  service_account_email  = google_service_account.app_runner.email
  service_account_scopes = ["https://www.googleapis.com/auth/cloud-platform"]

  # Persistent data disk that survives instance recreation.
  data_disks = {
    data1 = {
      size_gb = 100
      type    = "pd-ssd"
    }
  }

  network_tags = ["kv-app", "allow-iap-ssh"]

  startup_script = <<-EOT
    #!/bin/bash
    set -euo pipefail
    mkdir -p /mnt/data
    DISK=/dev/disk/by-id/google-data1
    if ! blkid "$DISK"; then mkfs.ext4 -F "$DISK"; fi
    mount -o discard,defaults "$DISK" /mnt/data
  EOT

  labels = {
    env  = "prod"
    team = "platform"
    app  = "app-runner"
  }
}

# Downstream: open the firewall to this VM via its IAP-tagged identity, and
# wire its internal IP into a private DNS record.
resource "google_dns_record_set" "app_runner" {
  project      = "kv-platform-prod"
  managed_zone = "kv-internal"
  name         = "app-runner-01.internal.kloudvin.dev."
  type         = "A"
  ttl          = 300
  rrdatas      = [module.compute_instance.internal_ip]
}

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/compute_instance/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  name = "..."
  zone = "..."
}

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

cd live/prod/compute_instance && 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 for the instance and disks.
name string yes Instance name (RFC 1035 validated); also the data disk name prefix.
zone string yes Zone, e.g. asia-south1-a.
machine_type string "e2-medium" no Machine type.
boot_image string debian-12 family no Boot disk source image or family.
boot_disk_size_gb number 20 no Boot disk size in GB (min 10).
boot_disk_type string "pd-balanced" no Boot disk type (validated set).
boot_disk_auto_delete bool true no Delete boot disk with the instance.
network string "default" no VPC network self-link or name.
subnetwork string null no Subnetwork self-link or name.
subnetwork_project string null no Subnetwork host project (Shared VPC).
network_ip string null no Static internal IP; null = auto-assigned.
assign_external_ip bool false no Attach an external IP (off by default).
external_ip_address string null no Reserved external IP; null = ephemeral.
network_tier string "PREMIUM" no External IP tier: PREMIUM or STANDARD.
can_ip_forward bool false no Allow non-matching src/dst IP forwarding.
network_tags list(string) [] no Network tags for firewall targeting.
labels map(string) {} no Labels merged onto instance and disks.
metadata map(string) {} no Custom instance metadata.
startup_script string null no Startup script (startup-script metadata).
enable_oslogin bool true no IAM-governed SSH via OS Login.
service_account_email string null no Runtime SA email (prefer dedicated SA).
service_account_scopes list(string) ["…/cloud-platform"] no OAuth scopes for the SA.
enable_secure_boot bool true no Shielded VM secure boot.
enable_vtpm bool true no Shielded VM vTPM.
enable_integrity_monitoring bool true no Shielded VM integrity monitoring.
enable_display bool false no Virtual display device.
deletion_protection bool false no Block deletion via API/Terraform.
allow_stopping_for_update bool true no Let Terraform stop the VM for updates.
preemptible bool false no Legacy preemptible VM.
spot bool false no Spot provisioning model.
instance_termination_action string "STOP" no Spot preemption action: STOP or DELETE.
automatic_restart bool true no Auto-restart (non-preemptible only).
on_host_maintenance string "MIGRATE" no Maintenance: MIGRATE or TERMINATE.
resource_policies list(string) [] no Resource policies (e.g. snapshot schedules).
data_disks map(object) {} no Extra persistent data disks to create/attach.

Outputs

Name Description
id Fully qualified instance ID.
name Instance name.
self_link Instance URI (self-link).
instance_id Server-assigned numeric instance ID.
zone Zone the instance runs in.
internal_ip Primary internal IP address.
external_ip External IP, or null when none is attached.
service_account_email Email of the attached service account.
data_disk_ids Map of data disk suffix to created disk ID.

Enterprise scenario

A fintech platform team runs a fleet of self-managed PostgreSQL replica VMs in asia-south1 that can’t move to Cloud SQL because of a custom replication extension. They consume this module once per replica with assign_external_ip = false, a dedicated pd-ssd data disk for the WAL volume, a snapshot-schedule resource policy attached for point-in-time recovery, and Shielded VM left at its hardened defaults to satisfy their PCI-DSS baseline. Each replica gets a per-instance service account scoped only to read its config secret from Secret Manager and write metrics, and the module’s internal_ip output feeds straight into the private DNS records the application connects through — so adding a replica is a five-line module block in a pull request, fully reviewable.

Best practices

TerraformGCPCompute InstanceModuleIaC
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