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
- You provision Compute Engine VMs repeatedly — bastion hosts, self-managed app servers, CI runners, license servers, or migration lift-and-shift workloads — and want one opinionated shape.
- You need data disks separate from the boot disk so they survive instance recreation and can be snapshotted independently.
- Security baselines require Shielded VM (secure boot, vTPM, integrity monitoring) and a least-privilege service account on every instance, with no exceptions slipping through.
- You want private-by-default networking, where an external IP is an explicit opt-in rather than the accidental result of leaving
access_configin. - Reach for a Managed Instance Group (MIG) module instead when you need autoscaling or rolling updates across many identical VMs; this module is for individually-addressed, longer-lived instances.
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 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/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
- Never run on the default compute service account. Pass a dedicated
service_account_emailper workload and keepcloud-platformscope so authorization is governed by narrowly-assigned IAM roles, not legacy OAuth scopes — the default SA is usually over-privileged Editor. - Stay private by default. Leave
assign_external_ip = falseand reach instances through IAP TCP forwarding or a load balancer; egress via Cloud NAT. An external IP should be a deliberate, reviewed decision, never a leftoveraccess_config. - Keep Shielded VM on and use a recent image family. Secure boot, vTPM, and integrity monitoring cost nothing and block whole classes of boot-level tampering; pair them with an image family (not a pinned image) so you pick up CVE patches on recreation.
- Separate data from the boot disk. Put stateful data on
data_diskswithboot_disk_auto_delete = truebut data disks persisting independently, attach a snapshot-schedule resource policy, and size withpd-balancedorpd-ssdto match IOPS needs rather than over-provisioning a giant boot disk. - Cut cost with right-sizing and Spot where it fits. Start at
e2/n2sizes informed by Recommender, setspot = truewithinstance_termination_action = "STOP"for fault-tolerant or batch workloads, and reserve standard provisioning for stateful nodes that must not be preempted. - Name and label consistently. Use the RFC 1035-validated
namefor a predictable<app>-<role>-<nn>convention, and rely on the module’s mergedlabels(env, team, app) so cost allocation, firewall targeting vianetwork_tags, and inventory queries all line up across the fleet.