IaC GCP

Terraform Module: GCP Filestore — managed NFS shares with predictable performance

Quick take — Provision GCP Filestore instances as code with a reusable Terraform module: pick the right tier, size capacity to the tier’s GiB rules, pin a private VPC range, and wire snapshots/backups for production NFS workloads. 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 "filestore" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-filestore?ref=v1.0.0"

  project_id  = "..."  # GCP project ID that owns the instance.
  name        = "..."  # Instance name (2-63 chars, starts with a letter, lowerc…
  capacity_gb = 0      # Share capacity in GiB; must meet the tier's min/step ru…
  network     = "..."  # VPC network name or self-link the instance attaches to.
}

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

What this module is

Google Cloud Filestore is a fully managed NFSv3 file storage service. You ask for a tier and a capacity, and Google hands you a highly available file server with a stable IP address that any Compute Engine VM, GKE node, or Cloud Run (with a connector) can mount as a POSIX file system — no NFS daemon to patch, no disks to grow by hand. It is the natural fit for workloads that genuinely need shared, low-latency file semantics: lift-and-shift apps expecting a mount point, CI/CD scratch space, media render farms, GKE ReadWriteMany persistent volumes, and HPC scratch.

The catch is that Filestore is full of tier-specific footguns. Capacity is not free-form — each tier (BASIC_HDD, BASIC_SSD, ZONAL, REGIONAL, ENTERPRISE) has its own minimum, maximum, and step size for GiB, and the ZONAL/REGIONAL tiers further split into a low-capacity band and a high-capacity band with different step rules. The instance must also be pinned to a private IP range inside your VPC, the connect-mode (direct peering vs. Private Service Access) has to match how the rest of your network reaches Google services, and BASIC_* tiers are zonal while ENTERPRISE/REGIONAL are regional. Wrapping all of this in a module lets you encode the valid combinations once, expose a small set of safe inputs, and stop every team from re-learning the GiB arithmetic the hard way.

When to use it

Reach for a different tool when you only need object storage (use Cloud Storage / GCS), when a single VM needs a fast local disk (use a Persistent Disk or Local SSD), or when you want a managed database — Filestore is a file system, not a key-value or relational store.

Module structure

terraform-module-gcp-filestore/
├── 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 {
  # BASIC_* tiers are zonal and require a `zone`; ENTERPRISE/REGIONAL are regional.
  # ZONAL is zonal but priced/sized like the high-performance family.
  is_zonal_tier = contains(["BASIC_HDD", "BASIC_SSD", "ZONAL"], var.tier)

  labels = merge(
    {
      managed-by = "terraform"
      module     = "gcp-filestore"
    },
    var.labels,
  )
}

resource "google_filestore_instance" "this" {
  project  = var.project_id
  name     = var.name
  tier     = var.tier
  location = local.is_zonal_tier ? var.zone : var.region

  description = var.description
  labels      = local.labels

  # The single NFS share exported by the instance.
  file_shares {
    name        = var.share_name
    capacity_gb = var.capacity_gb

    # Optional NFS export rules to lock the share down to specific clients.
    dynamic "nfs_export_options" {
      for_each = var.nfs_export_options
      content {
        ip_ranges   = nfs_export_options.value.ip_ranges
        access_mode = nfs_export_options.value.access_mode
        squash_mode = nfs_export_options.value.squash_mode
        anon_uid    = lookup(nfs_export_options.value, "anon_uid", null)
        anon_gid    = lookup(nfs_export_options.value, "anon_gid", null)
      }
    }
  }

  # Where the instance gets its private IP. `reserved_ip_range` may be either a
  # CIDR (direct peering) or the name of an allocated PSA range.
  networks {
    network           = var.network
    modes             = ["MODE_IPV4"]
    connect_mode      = var.connect_mode
    reserved_ip_range = var.reserved_ip_range
  }

  # Customer-managed encryption (ENTERPRISE / REGIONAL / ZONAL tiers).
  kms_key_name = var.kms_key_name

  # Protect against accidental capacity shrink or deletes from drift.
  deletion_protection_enabled = var.deletion_protection_enabled
  deletion_protection_reason  = var.deletion_protection_enabled ? var.deletion_protection_reason : null

  timeouts {
    create = "30m"
    update = "30m"
    delete = "20m"
  }
}

# Optional point-in-time snapshot of the share (supported on the
# high-performance tiers: ZONAL, REGIONAL, ENTERPRISE).
resource "google_filestore_snapshot" "this" {
  for_each = var.snapshots

  project  = var.project_id
  name     = each.value.name
  instance = google_filestore_instance.this.name
  location = google_filestore_instance.this.location

  description = lookup(each.value, "description", null)
  labels      = local.labels
}

variables.tf

variable "project_id" {
  type        = string
  description = "GCP project ID that will own the Filestore instance."
}

variable "name" {
  type        = string
  description = "Instance name. Lowercase letters, digits and hyphens; must start with a letter."

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

variable "tier" {
  type        = string
  description = "Service tier: BASIC_HDD, BASIC_SSD, ZONAL, REGIONAL, or ENTERPRISE."
  default     = "BASIC_SSD"

  validation {
    condition     = contains(["BASIC_HDD", "BASIC_SSD", "ZONAL", "REGIONAL", "ENTERPRISE"], var.tier)
    error_message = "tier must be one of BASIC_HDD, BASIC_SSD, ZONAL, REGIONAL, ENTERPRISE."
  }
}

variable "region" {
  type        = string
  description = "Region for regional tiers (REGIONAL, ENTERPRISE), e.g. asia-south1. Ignored for zonal tiers."
  default     = null
}

variable "zone" {
  type        = string
  description = "Zone for zonal tiers (BASIC_HDD, BASIC_SSD, ZONAL), e.g. asia-south1-a. Ignored for regional tiers."
  default     = null
}

variable "share_name" {
  type        = string
  description = "Name of the NFS file share / export (the mount point name)."
  default     = "share1"

  validation {
    condition     = can(regex("^[a-z][a-z0-9_]{0,15}$", var.share_name))
    error_message = "share_name must be 1-16 chars, start with a letter, and use only lowercase letters, digits, and underscores."
  }
}

variable "capacity_gb" {
  type        = number
  description = <<-EOT
    Share capacity in GiB. Tier minimums: BASIC_HDD 1024, BASIC_SSD 2560,
    ZONAL/REGIONAL 1024 (low band, 256 GiB steps) or 10240+ (high band, 2560 GiB steps),
    ENTERPRISE 1024. Pick a value that matches the tier's allowed step.
  EOT

  validation {
    condition     = var.capacity_gb >= 1024
    error_message = "capacity_gb must be at least 1024 GiB (and meet the per-tier minimum/step rules)."
  }
}

variable "network" {
  type        = string
  description = "Name (or self-link) of the VPC network the instance attaches to."
}

variable "connect_mode" {
  type        = string
  description = "DIRECT_PEERING (default VPC peering) or PRIVATE_SERVICE_ACCESS (shared VPC / Service Networking)."
  default     = "DIRECT_PEERING"

  validation {
    condition     = contains(["DIRECT_PEERING", "PRIVATE_SERVICE_ACCESS"], var.connect_mode)
    error_message = "connect_mode must be DIRECT_PEERING or PRIVATE_SERVICE_ACCESS."
  }
}

variable "reserved_ip_range" {
  type        = string
  description = <<-EOT
    Reserved IP range for the instance. For DIRECT_PEERING pass a /29 CIDR
    (e.g. 10.0.0.0/29); for PRIVATE_SERVICE_ACCESS pass the name of an
    allocated address range. Leave null to let Google auto-allocate.
  EOT
  default     = null
}

variable "kms_key_name" {
  type        = string
  description = "Self-link of a Cloud KMS CryptoKey for CMEK. Supported on ZONAL, REGIONAL, ENTERPRISE tiers. Null = Google-managed keys."
  default     = null
}

variable "nfs_export_options" {
  type = list(object({
    ip_ranges   = list(string)
    access_mode = optional(string, "READ_WRITE")
    squash_mode = optional(string, "NO_ROOT_SQUASH")
    anon_uid    = optional(number)
    anon_gid    = optional(number)
  }))
  description = "Up to 10 NFS export rules restricting which client CIDRs may mount the share and how. Empty list = default open export to the VPC."
  default     = []

  validation {
    condition     = length(var.nfs_export_options) <= 10
    error_message = "Filestore supports a maximum of 10 nfs_export_options entries."
  }
}

variable "snapshots" {
  type = map(object({
    name        = string
    description = optional(string)
  }))
  description = "Map of point-in-time snapshots to create (high-performance tiers only). Key is an arbitrary stable identifier."
  default     = {}
}

variable "deletion_protection_enabled" {
  type        = bool
  description = "Block deletion of the instance until protection is explicitly disabled."
  default     = true
}

variable "deletion_protection_reason" {
  type        = string
  description = "Human-readable reason recorded when deletion protection is enabled."
  default     = "Managed by Terraform; remove protection intentionally before destroy."
}

variable "description" {
  type        = string
  description = "Free-text description of the instance."
  default     = "Filestore instance managed by Terraform."
}

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

outputs.tf

output "id" {
  description = "Fully qualified Filestore instance ID."
  value       = google_filestore_instance.this.id
}

output "name" {
  description = "Filestore instance name."
  value       = google_filestore_instance.this.name
}

output "location" {
  description = "Zone or region the instance was created in."
  value       = google_filestore_instance.this.location
}

output "tier" {
  description = "Service tier of the instance."
  value       = google_filestore_instance.this.tier
}

output "ip_address" {
  description = "Private IPv4 address clients use as the NFS server host in the mount command."
  value       = google_filestore_instance.this.networks[0].ip_addresses[0]
}

output "share_name" {
  description = "Name of the exported NFS share (the path appended after the IP when mounting)."
  value       = var.share_name
}

output "mount_target" {
  description = "Ready-to-use NFS mount source in the form <ip>:/<share>."
  value       = "${google_filestore_instance.this.networks[0].ip_addresses[0]}:/${var.share_name}"
}

output "capacity_gb" {
  description = "Provisioned share capacity in GiB."
  value       = google_filestore_instance.this.file_shares[0].capacity_gb
}

output "snapshot_ids" {
  description = "Map of snapshot keys to their fully qualified snapshot IDs."
  value       = { for k, s in google_filestore_snapshot.this : k => s.id }
}

How to use it

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

  project_id = "kloudvin-prod"
  name       = "render-scratch"
  tier       = "ZONAL"
  zone       = "asia-south1-a"

  share_name        = "renders"
  capacity_gb       = 10240
  network           = "kloudvin-vpc"
  connect_mode      = "DIRECT_PEERING"
  reserved_ip_range = "10.20.30.0/29"

  # Only the render-farm subnet may mount the share, and root is squashed.
  nfs_export_options = [
    {
      ip_ranges   = ["10.40.0.0/20"]
      access_mode = "READ_WRITE"
      squash_mode = "ROOT_SQUASH"
      anon_uid    = 65534
      anon_gid    = 65534
    },
  ]

  snapshots = {
    pre-migration = {
      name        = "render-scratch-pre-migration"
      description = "Baseline before the 2026 pipeline cutover."
    }
  }

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

# Downstream: pass the ready-made mount source into a VM's startup script so
# the instance mounts the Filestore share at boot.
resource "google_compute_instance" "render_node" {
  project      = "kloudvin-prod"
  name         = "render-node-01"
  zone         = "asia-south1-a"
  machine_type = "n2-standard-8"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-12"
    }
  }

  network_interface {
    network = "kloudvin-vpc"
  }

  metadata_startup_script = <<-EOT
    #!/bin/bash
    set -euo pipefail
    apt-get update && apt-get install -y nfs-common
    mkdir -p /mnt/renders
    mount -o nfsvers=3 ${module.filestore.mount_target} /mnt/renders
  EOT
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  name = "..."
  capacity_gb = 0
  network = "..."
}

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

cd live/prod/filestore && 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 that owns the instance.
name string Yes Instance name (2-63 chars, starts with a letter, lowercase/digits/hyphens).
tier string "BASIC_SSD" No Service tier: BASIC_HDD, BASIC_SSD, ZONAL, REGIONAL, or ENTERPRISE.
region string null Conditional Region for regional tiers (REGIONAL, ENTERPRISE).
zone string null Conditional Zone for zonal tiers (BASIC_HDD, BASIC_SSD, ZONAL).
share_name string "share1" No NFS share/export name (1-16 chars, lowercase/digits/underscores).
capacity_gb number Yes Share capacity in GiB; must meet the tier’s min/step rules (>= 1024).
network string Yes VPC network name or self-link the instance attaches to.
connect_mode string "DIRECT_PEERING" No DIRECT_PEERING or PRIVATE_SERVICE_ACCESS.
reserved_ip_range string null No /29 CIDR (direct peering) or allocated PSA range name; null auto-allocates.
kms_key_name string null No CMEK CryptoKey self-link (ZONAL/REGIONAL/ENTERPRISE); null = Google-managed.
nfs_export_options list(object) [] No Up to 10 export rules (ip_ranges, access_mode, squash_mode, anon_uid/gid).
snapshots map(object) {} No Point-in-time snapshots to create (high-performance tiers only).
deletion_protection_enabled bool true No Block deletion until explicitly disabled.
deletion_protection_reason string "Managed by Terraform; ..." No Reason recorded when deletion protection is enabled.
description string "Filestore instance managed by Terraform." No Free-text instance description.
labels map(string) {} No Extra labels merged onto the instance and snapshots.

Outputs

Name Description
id Fully qualified Filestore instance ID.
name Filestore instance name.
location Zone or region the instance was created in.
tier Service tier of the instance.
ip_address Private IPv4 address used as the NFS server host when mounting.
share_name Name of the exported NFS share.
mount_target Ready-to-use NFS mount source in the form <ip>:/<share>.
capacity_gb Provisioned share capacity in GiB.
snapshot_ids Map of snapshot keys to their fully qualified snapshot IDs.

Enterprise scenario

A media-and-entertainment platform runs a Linux render farm of ~120 preemptible n2 VMs in asia-south1 that all need to read source assets and write rendered frames to one shared, low-latency file system. The team provisions a single ZONAL Filestore instance at 10 TiB through this module, restricts the export to the render subnet with ROOT_SQUASH, and feeds module.filestore.mount_target straight into each VM’s startup script so nodes mount /mnt/renders at boot. Before every quarterly pipeline upgrade they add a snapshot entry to the snapshots map, giving them a one-command rollback point if the new toolchain corrupts in-flight output.

Best practices

TerraformGCPFilestoreModuleIaC
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