Home / Databases

Firestore in Production: Security Rules, Indexes, Backups & Cost Model | 2026

Firestore in Production: Security Rules, Indexes, Backups & Cost Model | 2026

Firestore in production requires four layers: deny-by-default security rules with explicit allow blocks per collection, composite indexes declared in Terraform (google_firestore_index) to avoid runtime index-creation failures, point-in-time recovery (PITR) for data-loss protection, and a query pattern review to cap the cost-per-operation billing model. This guide covers all four layers with copy-paste Terraform and security rules.

By Mateusz Chmielewski · Aug 22, 2026 · 15 min read

What Is Cloud Firestore?

Cloud Firestore is Google Cloud's fully managed, serverless, NoSQL document database. Data is organized as collections of documents, where each document is a JSON-like object with arbitrary fields. Firestore offers two modes: Native mode (the default since 2019, recommended for all new projects) and Datastore mode (backward-compatible with Cloud Datastore). Firestore Native mode supports real-time listeners, mobile/web SDKs with built-in security rules, and sub-millisecond server-to-client latency. In 2026, Firestore supports point-in-time recovery, multi-region replication, and a Terraform provider for index and backup management.

Think of Firestore like a self-organizing filing cabinet in the cloud. Each drawer is a collection, each folder inside is a document, and the documents can have nested folders (sub-collections). The security guard (security rules) decides who can open which drawer without ever letting clients near the filing room directly — they hand requests through a slot in the wall (SDK) and the guard decides what comes back.

ConceptExplanationWhen to use
Collections & DocumentsCollections contain documents. Each document has a unique ID and a set of key-value fields. Documents can have sub-collections (nested collections).Model all Firestore data as collections of documents — avoid flat key-value patterns
Security RulesServer-side access control evaluated before any read/write is executed. Expressed in a Firebase Security Rules DSL. Without explicit allow, all access is denied.Every Firestore database — default is deny-all; explicit allow per collection is required
Single-Field vs Composite IndexesFirestore auto-creates single-field ascending/descending indexes for every field. Composite indexes (multi-field, ordered, OR queries) must be declared explicitly.Declare composite indexes for any query with WHERE + ORDER BY on different fields
PITR (Point-in-Time Recovery)Firestore can retain document versions for up to 7 days. A PITR restore recovers the database state to any second within that window.Enable in all production databases — PITR adds ~20% storage cost but covers accidental deletes and bad deployments
Export / BackupScheduled managed backups (google_firestore_backup_schedule) or manual exports to Cloud Storage for long-term retention beyond the 7-day PITR window.Compliance requirements, cross-region restore, data archival beyond 7 days
Billing ModelCharged per document operation (read, write, delete) and per GB of stored data. Real-time listeners count one read per received document update.Design queries to minimize document reads — fetch by document ID instead of collection scan wherever possible

Why Does Firestore Need Explicit Production Hardening?

The default Firestore setup from the Firebase console ships with rules that allow all reads and writes to authenticated users (`allow read, write: if request.auth != null`), no composite indexes (queries fail at runtime with an error pointing to the Firebase console to create them manually), no PITR enabled, and no billing alerts. In a production app with 100K daily active users, a missing deny rule allows any authenticated user to read all other users' data, a missing composite index causes 500s on filtered queries, and an unbounded collection-group listener can generate millions of reads in minutes.

This guide hardens Firestore with four layers: security rules with deny-by-default + explicit per-collection allow blocks validated against the Firebase Emulator in CI, composite indexes declared in google_firestore_index Terraform resources so they exist before deployment, PITR and scheduled managed backups via google_firestore_backup_schedule, and a query cost audit to replace expensive patterns with targeted document reads. This connects naturally with the rest of the GCP stack — use [Cloud SQL for PostgreSQL in Production](/tutorial/cloud-sql-postgres-production-private-ip-ha-backups-terraform) for relational data alongside Firestore for document storage, and [Cloud Run with Terraform](/tutorial/production-cloud-run-terraform-custom-domain-iam-vpc-connector-cicd) for the backend connecting via the Firebase Admin SDK. For budget guardrails across the full GCP project, see [Gemini API Cost Control on Vertex AI](/tutorial/gemini-api-cost-control-vertex-ai-quotas-budget-alerts).

FeaturethisServicealtAaltB
Security modelDeny-by-default rules — explicit allow per collection with auth + role checksDefault Firebase rules: allow read, write: if request.auth != null (any authed user can do anything)No rules file: all reads/writes blocked (dev-only locked-down mode)
Index managementTerraform google_firestore_index — indexes exist before deploymentManual via Firebase console — drift-prone, not version-controlledfirestore.indexes.json via Firebase CLI — version-controlled but no state tracking
Data recoveryPITR (7-day window, any-second restore) + scheduled managed backupManual Cloud Storage export — gap between exports = data loss windowNo backup — accidental delete is permanent
Cost visibilityQuery cost audit + billing alerts on Firestore read metricNo visibility until the bill arrivesBudget alerts only — no per-query optimization

Prerequisites

  • GCP project with Firestore Native mode enabled (firebase.googleapis.com and firestore.googleapis.com APIs active)
  • Terraform v1.6+ with google and google-beta providers ~> 5.x
  • Firebase CLI v13+ (npm install -g firebase-tools) for rules deployment and emulator
  • gcloud CLI v480.0+ authenticated
  • IAM roles: Firebase Admin, Cloud Datastore Owner (or Project Owner in sandbox)

Step-by-Step Guide

Step 1: Enable Firestore API and Initialize the Database with Terraform

Enable required APIs and create the Firestore database resource in Native mode with a specified location. Firestore location is permanent and affects latency, compliance region, and billing. europe-central2 keeps data within the EU; the google_firestore_database resource locks the mode to FIRESTORE_NATIVE.

resource "google_project_service" "firestore" {
  service            = "firestore.googleapis.com"
  disable_on_destroy = false
}

resource "google_firestore_database" "default" {
  project     = var.project_id
  name        = "(default)"
  location_id = "europe-central2"
  type        = "FIRESTORE_NATIVE"

  # Enable Point-in-Time Recovery
  point_in_time_recovery_enablement = "POINT_IN_TIME_RECOVERY_ENABLED"

  delete_protection_state = "DELETE_PROTECTION_ENABLED"

  depends_on = [google_project_service.firestore]
}

Step 2: Write Deny-by-Default Security Rules

Author a firestore.rules file with an explicit deny-all baseline and targeted allow blocks per collection. Firestore's security rules are the only server-side access gate for client SDK connections (mobile/web apps). A missing deny rule is a data breach waiting to happen — every authenticated user can read every document.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // ── Deny all by default ──────────────────────────────────────────────
    match /{document=**} {
      allow read, write: if false;
    }

    // ── User profiles: owner-only read/write ──────────────────────────
    match /users/{userId} {
      allow read, write: if request.auth != null
                       && request.auth.uid == userId;
    }

    // ── Public articles: anyone can read, only admins can write ────────
    match /articles/{articleId} {
      allow read: if true;
      allow write: if request.auth != null
                  && request.auth.token.admin == true;
    }

    // ── Orders: owner read, backend-only write (no client write) ───────
    match /orders/{orderId} {
      allow read: if request.auth != null
                 && request.auth.uid == resource.data.userId;
      allow write: if false;  // backend only via Admin SDK
    }

    // ── Field-level validation on create ─────────────────────────────
    match /products/{productId} {
      allow create: if request.auth != null
                   && request.auth.token.admin == true
                   && request.resource.data.price is number
                   && request.resource.data.price > 0
                   && request.resource.data.name is string
                   && request.resource.data.name.size() > 0;
      allow read: if true;
      allow update, delete: if request.auth != null
                           && request.auth.token.admin == true;
    }
  }
}

Step 3: Declare Composite Indexes with Terraform

Create google_firestore_index resources for every query that uses WHERE on one field and ORDER BY on a different field. Composite indexes must exist before the query runs. Without a composite index, Firestore returns an error with a link to create the index manually. In production, this means a 500 on the first filtered+sorted query in a new deployment — a race condition that only surfaces after deployment.

# Index for: articles WHERE category == X ORDER BY publishedAt DESC
resource "google_firestore_index" "articles_by_category_date" {
  project    = var.project_id
  database   = google_firestore_database.default.name
  collection = "articles"

  fields {
    field_path = "category"
    order      = "ASCENDING"
  }
  fields {
    field_path = "publishedAt"
    order      = "DESCENDING"
  }
}

# Index for: orders WHERE userId == X AND status == Y ORDER BY createdAt DESC
resource "google_firestore_index" "orders_by_user_status_date" {
  project    = var.project_id
  database   = google_firestore_database.default.name
  collection = "orders"

  fields {
    field_path = "userId"
    order      = "ASCENDING"
  }
  fields {
    field_path = "status"
    order      = "ASCENDING"
  }
  fields {
    field_path = "createdAt"
    order      = "DESCENDING"
  }
}

Step 4: Configure Scheduled Managed Backups

Create a google_firestore_backup_schedule resource for daily and weekly managed backups. This supplements PITR with longer-term retention. PITR covers the last 7 days at per-second granularity. Managed backups extend retention beyond 7 days for compliance requirements (e.g., GDPR data recovery windows, SOC 2 backup retention policies).

# Daily backup — retained for 7 days
resource "google_firestore_backup_schedule" "daily" {
  project  = var.project_id
  database = google_firestore_database.default.name

  retention = "604800s"  # 7 days in seconds

  daily_recurrence {}
}

# Weekly backup — retained for 14 weeks
resource "google_firestore_backup_schedule" "weekly" {
  project  = var.project_id
  database = google_firestore_database.default.name

  retention = "8467200s"  # 14 weeks in seconds

  weekly_recurrence {
    day = "SUNDAY"
  }
}

Step 5: Restore from PITR

Use gcloud to initiate a PITR restore to a new Firestore database at a specific point in time. PITR can recover from accidental deletions or bad deployments. PITR restore creates a new database (not in-place) — the original database keeps running with zero downtime. Once validated, you migrate traffic to the restored database at the application level.

# List available PITR timestamps (earliest and latest restore points)
gcloud firestore databases describe \
  --database='(default)' \
  --project=PROJECT_ID \
  --format='value(earliestVersionTime,latestVersionTime)'

# Restore to 2 hours ago → new database named 'restored-db'
gcloud firestore databases restore \
  --source-database='(default)' \
  --destination-database='restored-db' \
  --snapshot-time="$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --project=PROJECT_ID

# Verify the restored database
gcloud firestore databases describe \
  --database='restored-db' \
  --project=PROJECT_ID

Step 6: Query Cost Optimization — Reduce Document Reads

Audit your most frequent Firestore queries and replace collection scans with targeted document reads, use cursors for pagination, and limit real-time listeners to bounded scopes. Every document returned by a query costs one read. A collection-group query that returns 10,000 documents costs 10,000 reads — at $0.06/100K reads, a naive admin dashboard that runs this query 100 times/day generates $1.80/day or $54/month from a single query.

// ❌ Expensive: scan entire orders collection for a user's recent orders
// Returns ALL user orders, charges one read per document
const allOrders = await db.collection('orders')
  .where('userId', '==', userId)
  .get();

// ✅ Cheap: paginate with a cursor, limit result size
const recentOrders = await db.collection('orders')
  .where('userId', '==', userId)
  .orderBy('createdAt', 'desc')
  .limit(20)             // read max 20 documents per request
  .startAfter(lastDoc)   // cursor-based pagination — no offset
  .get();

// ❌ Expensive: real-time listener on entire collection (charges per doc per update)
db.collection('notifications').onSnapshot(snapshot => { ... });

// ✅ Cheap: listener scoped to one user's recent notifications
db.collection('notifications')
  .where('userId', '==', userId)
  .where('createdAt', '>', new Date(Date.now() - 86400000))  // last 24h
  .orderBy('createdAt', 'desc')
  .limit(10)
  .onSnapshot(snapshot => { ... });

Step 7: Deploy Security Rules and Indexes via CI/CD

Automate security rules deployment with Firebase CLI and Terraform index management in a GitHub Actions workflow so both are version-controlled and always in sync with the application. Manual rules deployments from a developer's laptop lead to drift — rules that exist in production but not in the repository. A missing index that was manually created in the Firebase console is invisible to Terraform and gets missed in disaster recovery.

# .github/workflows/firestore-deploy.yml
name: Firestore Deploy

on:
  push:
    branches: [main]
    paths:
      - 'firestore.rules'
      - 'terraform/indexes.tf'
      - 'terraform/firestore.tf'

jobs:
  deploy-rules:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}
          service_account: ${{ vars.DEPLOY_SA }}

      - name: Deploy Firestore Security Rules
        run: |
          npm install -g firebase-tools
          firebase deploy --only firestore:rules \
            --project ${{ vars.GCP_PROJECT_ID }} \
            --non-interactive

  terraform-indexes:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}
          service_account: ${{ vars.DEPLOY_SA }}
      - uses: hashicorp/setup-terraform@v3
      - run: terraform -chdir=terraform init
      - run: terraform -chdir=terraform apply -auto-approve

Verification & Health Check

Best Practices

  • Always Start with a Deny-All Default Rule
  • Declare All Composite Indexes in Terraform, Not the Firebase Console
  • Use .limit() and Cursor Pagination on All Collection Queries

Common Mistakes

  • {"errorCode":"PERMISSION_DENIED — security rule blocks valid request","symptoms":"Client SDK throws FirebaseError: Missing or insufficient permissions on a request that should be allowed. Happens after deploying new security rules.","rootCause":"The new rules have not propagated yet (takes up to 60 seconds), or the allow block condition uses a field that does not exist on the document at evaluation time (resource.data.userId undefined on a new document).","fixCommand":"firebase emulators:start --only firestore — reproduce the request in the emulator and check rule evaluation in the Firestore emulator UI at http://localhost:4000.","code":"// Defensive rule — check field existence before comparing\nmatch /orders/{orderId} {\n allow read: if request.auth != null\n && 'userId' in resource.data // guard against missing field\n && request.auth.uid == resource.data.userId;\n}\n","language":"javascript","filename":"firestore.rules","prevention":"Run firebase deploy --only firestore:rules --dry-run in CI to validate syntax. Write @firebase/rules-unit-testing tests for every collection's read and write paths."}
  • {"errorCode":"FAILED_PRECONDITION — index not ready","symptoms":"Firestore query returns error: 'The query requires an index. You can create it here: [link]'. Happens immediately after deployment to a new environment.","rootCause":"The application deployed a new query that requires a composite index, but the index was not created before deployment (either missing from Terraform or not yet built).","fixCommand":"terraform apply — or: firebase deploy --only firestore:indexes","code":"resource \"google_firestore_index\" \"fix_missing_index\" {\n project = var.project_id\n database = \"(default)\"\n collection = \"events\"\n fields {\n field_path = \"category\"\n order = \"ASCENDING\"\n }\n fields {\n field_path = \"createdAt\"\n order = \"DESCENDING\"\n }\n}\n","language":"hcl","filename":"indexes.tf","prevention":"Add a pre-deployment check in CI that runs all Firestore queries against the Firebase Emulator with the production index configuration. Index building takes 2–10 minutes — always apply indexes before deploying application code."}
  • {"errorCode":"RESOURCE_EXHAUSTED — quota exceeded","symptoms":"Firestore operations return RESOURCE_EXHAUSTED or Quota exceeded errors. Cloud Monitoring shows reads/writes spiking to 10x normal.","rootCause":"A real-time listener on a large collection or a polling loop in a background process is generating unbounded reads. Typical culprits: a listener on a collection without .limit(), an admin dashboard that fetches all documents on every refresh, or a runaway retry loop.","fixCommand":"gcloud logging read 'resource.type=firestore_instance' --project=PROJECT_ID --limit=50","code":"// Replace unbounded listener with bounded + limited version\ndb.collection('events')\n .where('status', '==', 'active')\n .orderBy('createdAt', 'desc')\n .limit(50) // cap document reads per snapshot\n .onSnapshot(snapshot => {\n // process at most 50 documents per update\n });\n","language":"javascript","filename":"bounded-listener.js","prevention":"Set a Cloud Monitoring alert on firestore.googleapis.com/document/read_count with a threshold of 2x the expected daily read count. Also set a GCP billing budget alert at 80% of the expected monthly Firestore cost."}

Cost Analysis

Featuremetriccost1kcost10kcost100kcost1m
Document reads ($0.06 / 100K)$0.00$0.01$0.06$0.60
Document writes ($0.18 / 100K)$0.00$0.02$0.18$1.80
Document deletes ($0.02 / 100K)$0.00$0.00$0.02$0.20
Storage ($0.108 / GB / month)$0.11$0.11$0.54$2.16
PITR storage surcharge (20% of storage)$0.02$0.02$0.11$0.43
Managed backup storage ($0.023 / GB / month)$0.02$0.02$0.12$0.46
Network egress (same region: free; cross-region: $0.01/GB)$0.00$0.00$0.01$0.10

References

Browse all tutorials