
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.
| Concept | Explanation | When to use |
|---|---|---|
| Collections & Documents | Collections 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 Rules | Server-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 Indexes | Firestore 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 / Backup | Scheduled 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 Model | Charged 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).
| Feature | thisService | altA | altB |
|---|---|---|---|
| Security model | Deny-by-default rules — explicit allow per collection with auth + role checks | Default 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 management | Terraform google_firestore_index — indexes exist before deployment | Manual via Firebase console — drift-prone, not version-controlled | firestore.indexes.json via Firebase CLI — version-controlled but no state tracking |
| Data recovery | PITR (7-day window, any-second restore) + scheduled managed backup | Manual Cloud Storage export — gap between exports = data loss window | No backup — accidental delete is permanent |
| Cost visibility | Query cost audit + billing alerts on Firestore read metric | No visibility until the bill arrives | Budget 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
| Feature | metric | cost1k | cost10k | cost100k | cost1m |
|---|---|---|---|---|---|
| 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 |