IaC GCP

Terraform Module: GCP Firestore — production-ready Native-mode databases with PITR, backups, and locked deletes

Quick take — A reusable Terraform module for google_firestore_database on hashicorp/google ~> 5.0: Native vs Datastore mode, point-in-time recovery, scheduled backups, delete protection, and CMEK — all var-driven. 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 "firestore" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-firestore?ref=v1.0.0"

  project_id  = "..."  # GCP project ID that will own the Firestore database.
  location_id = "..."  # Region (e.g. `asia-south1`) or multi-region (`nam5`, `e…
}

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

What this module is

Cloud Firestore is Google Cloud’s serverless, horizontally scalable document database. It stores data as collections of JSON-like documents, replicates synchronously across multiple zones (or regions, for nam5/eur3-style multi-region locations), and exposes real-time listeners, offline sync, and strongly consistent queries with automatic single-field indexing. There are no servers to provision and no capacity to size — you pay per document read/write/delete and per GiB stored.

The catch is that a Firestore database carries several decisions that are effectively permanent or destructive once made: the database type (Native mode vs Datastore mode), its location_id, and the moment you delete it you lose every document. On top of that, production-grade operation needs point-in-time recovery (PITR), a backup schedule, delete protection, and frequently a customer-managed encryption key (CMEK). Wiring all of that by hand in every project leads to drift — one team enables PITR, another forgets delete_protection_state, a third hardcodes the location.

This module wraps google_firestore_database plus its two most common production companions — google_firestore_backup_schedule (daily and weekly retention) and google_firestore_field (for TTL and index policy on a hot collection) — behind a small, validated variable surface. You get one consistent, reviewable way to stand up a Firestore database with sane safety defaults across every environment.

When to use it

If you only need ephemeral key/value caching, or you need multi-row ACID transactions across a relational schema, reach for Memorystore or Cloud SQL/Spanner instead — Firestore’s transactions are document-oriented and its query model is index-driven, not ad-hoc SQL.

Module structure

terraform-module-gcp-firestore/
├── versions.tf      # provider + required_version pins
├── main.tf          # google_firestore_database + backup_schedule + field
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # database id/name + key attributes

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # PITR and backups are only meaningful for Native-mode databases.
  enable_pitr     = var.point_in_time_recovery_enabled && var.database_type == "FIRESTORE_NATIVE"
  enable_backups  = var.enable_backups && var.database_type == "FIRESTORE_NATIVE"
  enable_ttl_field = var.ttl_field_path != null && var.ttl_collection != null
}

resource "google_firestore_database" "this" {
  project     = var.project_id
  name        = var.database_id
  location_id = var.location_id
  type        = var.database_type

  # Concurrency + consistency knobs (Native mode supports both).
  concurrency_mode              = var.concurrency_mode
  app_engine_integration_mode   = var.app_engine_integration_mode

  # Point-in-time recovery: when enabled, retains 7 days of versioned data.
  point_in_time_recovery_enablement = (
    local.enable_pitr
    ? "POINT_IN_TIME_RECOVERY_ENABLED"
    : "POINT_IN_TIME_RECOVERY_DISABLED"
  )

  # Guardrail against accidental deletion of the database.
  delete_protection_state = (
    var.delete_protection
    ? "DELETE_PROTECTION_ENABLED"
    : "DELETE_PROTECTION_DISABLED"
  )

  # Deletion policy controls whether `terraform destroy` may remove the DB
  # even when delete protection is off (ABANDON keeps it in GCP).
  deletion_policy = var.deletion_policy

  # Optional customer-managed encryption (CMEK). Omit the block for Google-managed keys.
  dynamic "cmek_config" {
    for_each = var.kms_key_name != null ? [1] : []
    content {
      kms_key_name = var.kms_key_name
    }
  }

  labels = var.labels
}

# Daily backups with their own retention window.
resource "google_firestore_backup_schedule" "daily" {
  count = local.enable_backups ? 1 : 0

  project   = var.project_id
  database  = google_firestore_database.this.name
  retention = var.daily_backup_retention

  daily_recurrence {}
}

# Weekly backups (kept longer) on a configurable day of week.
resource "google_firestore_backup_schedule" "weekly" {
  count = local.enable_backups && var.enable_weekly_backups ? 1 : 0

  project   = var.project_id
  database  = google_firestore_database.this.name
  retention = var.weekly_backup_retention

  weekly_recurrence {
    day = var.weekly_backup_day
  }
}

# Optional TTL policy on a single field of one collection (e.g. expireAt).
# Setting ttl{} tells Firestore to auto-delete documents past that timestamp.
resource "google_firestore_field" "ttl" {
  count = local.enable_ttl_field ? 1 : 0

  project    = var.project_id
  database   = google_firestore_database.this.name
  collection = var.ttl_collection
  field      = var.ttl_field_path

  ttl_config {}

  # Leave automatic single-field indexing untouched for this field.
  index_config {}
}

variables.tf

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

variable "database_id" {
  type        = string
  description = "Firestore database ID. Use \"(default)\" for the project's default database, or a custom ID (4-63 chars) for named databases."
  default     = "(default)"

  validation {
    condition = (
      var.database_id == "(default)" ||
      can(regex("^[a-z][a-z0-9-]{2,61}[a-z0-9]$", var.database_id))
    )
    error_message = "database_id must be \"(default)\" or 4-63 chars: lowercase letters, digits, hyphens; start with a letter and not end with a hyphen."
  }
}

variable "location_id" {
  type        = string
  description = "Location for the database: a region (e.g. us-central1, asia-south1) or a multi-region (nam5, eur3). Immutable after creation."

  validation {
    condition     = length(var.location_id) > 0
    error_message = "location_id must be set (e.g. asia-south1 or nam5)."
  }
}

variable "database_type" {
  type        = string
  description = "Database mode: FIRESTORE_NATIVE (real-time, mobile SDKs) or DATASTORE_MODE (server-side, high throughput). Immutable after creation."
  default     = "FIRESTORE_NATIVE"

  validation {
    condition     = contains(["FIRESTORE_NATIVE", "DATASTORE_MODE"], var.database_type)
    error_message = "database_type must be FIRESTORE_NATIVE or DATASTORE_MODE."
  }
}

variable "concurrency_mode" {
  type        = string
  description = "Transaction concurrency control: OPTIMISTIC, PESSIMISTIC, or OPTIMISTIC_WITH_ENTITY_GROUPS (Datastore-mode only)."
  default     = "OPTIMISTIC"

  validation {
    condition = contains(
      ["OPTIMISTIC", "PESSIMISTIC", "OPTIMISTIC_WITH_ENTITY_GROUPS"],
      var.concurrency_mode
    )
    error_message = "concurrency_mode must be OPTIMISTIC, PESSIMISTIC, or OPTIMISTIC_WITH_ENTITY_GROUPS."
  }
}

variable "app_engine_integration_mode" {
  type        = string
  description = "Whether the database is tied to App Engine: ENABLED or DISABLED. Use DISABLED for standalone databases."
  default     = "DISABLED"

  validation {
    condition     = contains(["ENABLED", "DISABLED"], var.app_engine_integration_mode)
    error_message = "app_engine_integration_mode must be ENABLED or DISABLED."
  }
}

variable "point_in_time_recovery_enabled" {
  type        = bool
  description = "Enable point-in-time recovery (retains 7 days of versioned data). Native mode only; ignored for Datastore mode."
  default     = true
}

variable "delete_protection" {
  type        = bool
  description = "Enable database-level delete protection to block accidental deletion via API/console."
  default     = true
}

variable "deletion_policy" {
  type        = string
  description = "Terraform deletion behaviour: DELETE (allow destroy) or ABANDON (remove from state but keep the database in GCP)."
  default     = "ABANDON"

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

variable "kms_key_name" {
  type        = string
  description = "Optional CMEK key resource ID (projects/<p>/locations/<l>/keyRings/<r>/cryptoKeys/<k>). Must match the database location. Null = Google-managed encryption."
  default     = null
}

variable "enable_backups" {
  type        = bool
  description = "Create a daily Firestore backup schedule. Native mode only."
  default     = true
}

variable "daily_backup_retention" {
  type        = string
  description = "Retention for daily backups as a duration string (e.g. 604800s = 7 days). Max 14 days."
  default     = "604800s"

  validation {
    condition     = can(regex("^[0-9]+s$", var.daily_backup_retention))
    error_message = "daily_backup_retention must be a seconds duration string like \"604800s\"."
  }
}

variable "enable_weekly_backups" {
  type        = bool
  description = "Also create a weekly backup schedule (kept longer than daily)."
  default     = false
}

variable "weekly_backup_retention" {
  type        = string
  description = "Retention for weekly backups as a duration string (e.g. 8467200s = 14 weeks-equivalent). Max 14 weeks."
  default     = "8467200s"

  validation {
    condition     = can(regex("^[0-9]+s$", var.weekly_backup_retention))
    error_message = "weekly_backup_retention must be a seconds duration string like \"8467200s\"."
  }
}

variable "weekly_backup_day" {
  type        = string
  description = "Day of week for weekly backups."
  default     = "SUNDAY"

  validation {
    condition = contains(
      ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"],
      var.weekly_backup_day
    )
    error_message = "weekly_backup_day must be a valid uppercase day name (e.g. SUNDAY)."
  }
}

variable "ttl_collection" {
  type        = string
  description = "Collection (collection group) on which to enable a TTL field. Null disables the TTL field resource."
  default     = null
}

variable "ttl_field_path" {
  type        = string
  description = "Timestamp field used for TTL auto-deletion (e.g. expireAt). Requires ttl_collection. Null disables the TTL field resource."
  default     = null
}

variable "labels" {
  type        = map(string)
  description = "Labels applied to the Firestore database."
  default     = {}
}

outputs.tf

output "id" {
  description = "Fully qualified Firestore database ID (projects/<project>/databases/<database>)."
  value       = google_firestore_database.this.id
}

output "name" {
  description = "Short database name (e.g. \"(default)\" or the custom database ID)."
  value       = google_firestore_database.this.name
}

output "location_id" {
  description = "Location the database was created in."
  value       = google_firestore_database.this.location_id
}

output "database_type" {
  description = "Mode of the database (FIRESTORE_NATIVE or DATASTORE_MODE)."
  value       = google_firestore_database.this.type
}

output "uid" {
  description = "System-generated unique identifier (UUID) for the database."
  value       = google_firestore_database.this.uid
}

output "etag" {
  description = "Current etag of the database resource (useful for optimistic-concurrency reads)."
  value       = google_firestore_database.this.etag
}

output "daily_backup_schedule_id" {
  description = "ID of the daily backup schedule, or null when backups are disabled."
  value       = local.enable_backups ? google_firestore_backup_schedule.daily[0].id : null
}

How to use it

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

  project_id  = "kloudvin-prod"
  database_id = "orders"
  location_id = "asia-south1" # Mumbai region — keep data close to users

  database_type                  = "FIRESTORE_NATIVE"
  point_in_time_recovery_enabled = true
  delete_protection              = true
  deletion_policy                = "ABANDON"

  # Daily + weekly backups
  enable_backups          = true
  daily_backup_retention  = "604800s"  # 7 days
  enable_weekly_backups   = true
  weekly_backup_retention = "7257600s" # ~12 weeks
  weekly_backup_day       = "SUNDAY"

  # Auto-expire session documents 30 days after expireAt
  ttl_collection = "sessions"
  ttl_field_path = "expireAt"

  # Encrypt with a project CMEK key in the same region
  kms_key_name = "projects/kloudvin-prod/locations/asia-south1/keyRings/firestore/cryptoKeys/orders-db"

  labels = {
    app         = "orders-api"
    environment = "prod"
    managed_by  = "terraform"
  }
}

# Downstream: inject the database name into a Cloud Run service so the
# app connects to the right named Firestore database.
resource "google_cloud_run_v2_service" "orders_api" {
  name     = "orders-api"
  location = "asia-south1"
  project  = "kloudvin-prod"

  template {
    containers {
      image = "asia-south1-docker.pkg.dev/kloudvin-prod/apps/orders-api:1.4.0"

      env {
        name  = "FIRESTORE_DATABASE_ID"
        value = module.firestore.name
      }
      env {
        name  = "FIRESTORE_DATABASE"
        value = module.firestore.id
      }
    }
  }
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  location_id = "..."
}

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

cd live/prod/firestore && 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 will own the Firestore database.
database_id string "(default)" No (default) or a custom named-database ID (4-63 chars, lowercase/digits/hyphens).
location_id string Yes Region (e.g. asia-south1) or multi-region (nam5, eur3). Immutable.
database_type string "FIRESTORE_NATIVE" No FIRESTORE_NATIVE or DATASTORE_MODE. Immutable.
concurrency_mode string "OPTIMISTIC" No OPTIMISTIC, PESSIMISTIC, or OPTIMISTIC_WITH_ENTITY_GROUPS.
app_engine_integration_mode string "DISABLED" No ENABLED or DISABLED. Use DISABLED for standalone databases.
point_in_time_recovery_enabled bool true No Enable PITR (7-day versioned recovery). Native mode only.
delete_protection bool true No Block accidental deletion at the database level.
deletion_policy string "ABANDON" No Terraform destroy behaviour: DELETE or ABANDON.
kms_key_name string null No CMEK key resource ID matching the DB location; null = Google-managed keys.
enable_backups bool true No Create a daily backup schedule. Native mode only.
daily_backup_retention string "604800s" No Daily backup retention duration (max 14 days).
enable_weekly_backups bool false No Also create a weekly backup schedule.
weekly_backup_retention string "8467200s" No Weekly backup retention duration (max 14 weeks).
weekly_backup_day string "SUNDAY" No Day of week for the weekly backup.
ttl_collection string null No Collection group to enable a TTL field on.
ttl_field_path string null No Timestamp field for TTL auto-deletion (e.g. expireAt).
labels map(string) {} No Labels applied to the database.

Outputs

Name Description
id Fully qualified database ID (projects/<project>/databases/<database>).
name Short database name ((default) or the custom database ID).
location_id Location the database was created in.
database_type Database mode (FIRESTORE_NATIVE or DATASTORE_MODE).
uid System-generated unique identifier (UUID) for the database.
etag Current etag of the database resource.
daily_backup_schedule_id ID of the daily backup schedule, or null when backups are disabled.

Enterprise scenario

A logistics company runs a real-time parcel-tracking platform on Cloud Run in asia-south1, with each business domain (orders, tracking events, driver sessions) isolated in its own named Firestore Native-mode database inside a single production project. The platform team consumes this module once per database from a shared Terraform stack, enforcing delete_protection = true, PITR, daily-plus-weekly backups, and a CMEK key per database so that audit and key-rotation policies are uniform. The high-churn sessions database uses the module’s TTL field on expireAt to auto-purge expired driver sessions, keeping storage cost flat without a cleanup job — and because deletion_policy = "ABANDON", even a botched terraform destroy of the stack leaves the customer data intact in GCP.

Best practices

TerraformGCPFirestoreModuleIaC
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