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
- You need shared POSIX file storage (
ReadWriteMany) that several VMs or GKE pods mount simultaneously — something a single Persistent Disk cannot provide. - You are migrating a legacy app that hard-codes an NFS mount path and you don’t want to run and maintain your own NFS server on a VM.
- You want predictable, provisioned performance tied to capacity rather than the burst-credit model of network file shares elsewhere.
- You need regional resilience (
ENTERPRISE/REGIONALtiers survive a zone outage) or share-level snapshots for fast in-place recovery.
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 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/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
- Get the capacity-to-tier math right before
apply. Each tier has its own minimum and step (e.g.BASIC_SSDstarts at 2560 GiB;ZONAL/REGIONALuse 256 GiB steps in the 1–9.75 TiB band and 2560 GiB steps from 10 TiB up). A mismatchedcapacity_gbis a 400 error, and shrinking capacity is not allowed — size with headroom but avoid over-provisioning, since Filestore bills on provisioned, not used, GiB. - Lock the export down with
nfs_export_options. Restrictip_rangesto the exact client subnets and preferROOT_SQUASHwith an anonymous uid/gid for multi-tenant or untrusted clients; the defaultNO_ROOT_SQUASHlets any mounting root act as root on the share. - Match
connect_modeto your network topology and reserve the IP range explicitly. UsePRIVATE_SERVICE_ACCESSwith Service Networking in Shared VPC environments, and pin a dedicated/29(direct peering) or named PSA range so the private IP — and therefore the mount target — stays stable and never collides with another peered service. - Choose the tier for the resilience you actually need.
BASIC_*andZONALlive in a single zone; onlyREGIONALandENTERPRISEsurvive a zone failure. Don’t pay the regional premium for scratch data, but don’t put a zonal tier under a workload with a regional RTO either. - Enable CMEK and deletion protection for production shares. Set
kms_key_nameon the high-performance tiers to keep encryption keys in your control, and keepdeletion_protection_enabled = trueso a strayterraform destroyor state drift cannot wipe the file system. - Use a consistent name + label scheme (
<app>-<purpose>likerender-scratch, plusteam/environmentlabels) so instances are easy to attribute in billing exports and to target in IAM and monitoring policies.