A separate firebase-cloud repo manages all GCP/Firebase infrastructure via Terraform. Each app (like this webhook repo) keeps only its application code and CI/CD workflows.
firebase-cloud/ # Infra-only repo
├── terraform/
│ ├── main.tf # GCP project, APIs, shared infra
│ ├── modules/
│ │ ├── project-setup/ # APIs, Firebase enable, Firestore
│ │ ├── wif-pool/ # ONE shared Workload Identity Pool
│ │ ├── app-identity/ # Per-app: SA + WIF provider + IAM
│ │ ├── artifact-registry/
│ │ ├── firestore-databases/ # Named database per app
│ │ └── shared-infra/ # DNS zone, VPC, etc.
│ ├── apps/
│ │ ├── hooklab.tf # module "hooklab" { source = "../modules/app-identity" }
│ │ ├── app-two.tf # module "app_two" { source = "../modules/app-identity" }
│ │ └── ...
│ ├── outputs.tf
│ └── variables.tf
└── .github/workflows/
└── terraform.yml # Plan on PR, apply on merge
webhook/ # App repo (this repo)
├── .github/workflows/
│ ├── ci.yml
│ └── cd.yml # Uses WIF_PROVIDER + GCP_SA_EMAIL from firebase-cloud
├── client/
├── server/
├── functions/
└── (NO terraform/)
- Create
firebase-cloudrepo - Move
webhook/terraform/into the new repo, restructure into modules terraform importexisting resources (WIF pool, SAs, APIs, etc.) into the new state- Verify with
terraform plan— should show no changes - Set up GCS backend for remote state with versioning + locking
- Delete
webhook/terraform/from this repo - Onboard future apps by adding a
.tffile inapps/
Per-app SA + per-app WIF provider (recommended over shared SA):
- Each app gets its own service account and WIF provider
- WIF provider
attribute_conditionis pinned to the app's specific GitHub repo - Least privilege — can revoke one app without touching others
- Audit trail per app
# modules/app-identity/main.tf
variable "app_name" {}
variable "github_org" {}
variable "github_repo" {}
variable "project_id" {}
variable "wif_pool_name" {}
variable "roles" { type = set(string) }
resource "google_service_account" "ci_cd" {
account_id = "${var.app_name}-ci-cd"
display_name = "${var.app_name} GitHub Actions CI/CD"
}
resource "google_project_iam_member" "roles" {
for_each = var.roles
project = var.project_id
role = each.value
member = "serviceAccount:${google_service_account.ci_cd.email}"
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = var.wif_pool_name
workload_identity_pool_provider_id = "${var.app_name}-github"
attribute_condition = "assertion.repository == '${var.github_org}/${var.github_repo}'"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
}
oidc { issuer_uri = "https://token.actions.githubusercontent.com" }
}
resource "google_service_account_iam_member" "wif_binding" {
service_account_id = google_service_account.ci_cd.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${var.wif_pool_name}/attribute.repository/${var.github_org}/${var.github_repo}"
}Onboarding a new app:
# apps/hooklab.tf
module "hooklab" {
source = "../modules/app-identity"
app_name = "hooklab"
github_org = "your-org"
github_repo = "webhook"
project_id = var.project_id
wif_pool_name = module.wif_pool.pool_name
roles = ["roles/firebase.admin", "roles/run.admin", "roles/artifactregistry.writer"]
}Each app repo needs two GitHub secrets: WIF_PROVIDER and GCP_SA_EMAIL.
| Method | How | Trade-off |
|---|---|---|
| Manual | Copy Terraform outputs into GitHub repo secrets | Simplest, but manual rotation |
| GitHub Terraform provider | Terraform sets secrets directly on each repo | Cleanest, requires GitHub PAT with repo admin scope |
| Secret Manager + fetch | Store in GCP Secret Manager, fetch in workflow | More moving parts |
Each app must declare a codebase in firebase.json. Without this, firebase deploy --only functions will delete functions from other apps.
{
"functions": [
{
"source": "functions",
"codebase": "hooklab",
"ignore": ["node_modules", ".git"]
}
]
}The codebase field tells the Firebase CLI to only manage functions belonging to that codebase — functions from other codebases are left untouched during deploy.
Additionally, prefix all function names with the app identifier:
// webhook repo
export const hooklab_onRequestCreated = onDocumentCreated(...);
export const hooklab_processWebhook = onRequest(...);
// app-two repo
export const apptwo_onOrderPlaced = onDocumentCreated(...);Firebase supports multiple named databases. Each app gets its own, providing separate rules, indexes, and data with zero coordination.
# firebase-cloud terraform
resource "google_firestore_database" "hooklab" {
project = var.project_id
name = "hooklab"
location_id = var.region
type = "FIRESTORE_NATIVE"
}
resource "google_firestore_database" "apptwo" {
project = var.project_id
name = "apptwo"
location_id = var.region
type = "FIRESTORE_NATIVE"
}App code connects to its named database:
const db = getFirestore(app, "hooklab");Benefits:
- Separate security rules per database
- Separate indexes per database
- No collection name collisions
- Independent backup/restore
Each app gets its own site_id. Terraform creates the hosting site resource, the app repo deploys to it. No collision risk.
Each app deploys its own named service (hooklab-api, apptwo-api). No collision.
Share one AR repo, use different image paths:
us-central1-docker.pkg.dev/PROJECT/shared-ar/hooklab-api:latest
us-central1-docker.pkg.dev/PROJECT/shared-ar/apptwo-api:latest
Prefix secret names per app and scope access via IAM per service account:
resource "google_secret_manager_secret_iam_member" "hooklab_secrets" {
secret_id = google_secret_manager_secret.hooklab_stripe_key.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${module.hooklab.sa_email}"
}| Resource | Risk | Isolation mechanism |
|---|---|---|
| Cloud Functions | High — flat namespace, deploy deletes others | codebase field in firebase.json |
| Firestore data | High — shared collections, single rules file | Named databases (one per app) |
| Firestore rules | High — last deploy wins | Named databases (own rules each) |
| Firestore indexes | Medium — merge conflicts | Named databases |
| Hosting | None | Separate site_id per app |
| Cloud Run | None | Separate service name per app |
| Storage rules | High — single rules file | Prefix paths + separate buckets |
| Secrets | Medium — naming overlap | Prefix + scoped IAM per SA |
| BigQuery | Low | Separate datasets per app |
- WIF Pool limit: Max 100 providers per pool. One shared pool works for <100 apps.
- WIF Pool deletion is soft-delete: 30-day grace period. Cannot recreate with same ID during that window.
- Terraform state bootstrap: GCS bucket for state must exist before
terraform init. Create manually withgsutil mb. - IAM eventual consistency: New SA + WIF bindings can take up to 60s to propagate. First deploys after infra changes may fail.
- Cross-repo secret rotation: Rotating SA or WIF provider breaks all app repos until their secrets are updated.
attribute_conditionis exact match: Case-sensitive, must match exactly what GitHub sends. Forks won't match.- Terraform apply ordering: Must
terraform applyinfirebase-cloudbefore an app repo's first CD run. codebaseis mandatory: Without it, deploying functions from one app silently deletes functions from all other apps. This is the single most dangerous collision vector.- Default Firestore database: If apps share
(default), anyfirebase deploythat includes firestore rules will overwrite the entire ruleset. Named databases eliminate this.