+1 (762) 572-1994

Staging That Matches Production: Firebase Projects, Emulators in CI, and Safe Deploys

Most Firebase teams get to production with one project and a second project named -dev that nobody trusts. Rules are deployed from a laptop. Indexes drift. The emulator is used for local clicking around, not for tests. Then somebody runs firebase deploy from a branch at 6pm and the rules file that ships is six weeks old.

None of that is a Firebase defect. Firebase gives you project isolation, a deterministic emulator suite, and declarative config for rules, indexes, and functions. Teams just rarely wire them into CI, because the console path works until it doesn't.

Here is the environment setup we put in place on hardening engagements, and the specific failure modes each piece prevents.

One project per environment, no exceptions

A Firebase project is the isolation boundary. Not a collection prefix, not a env field on documents. Auth users, security rules, indexes, Functions config, App Check tokens, quotas and the bill are all per project.

Three projects:

acme-dev        developers, emulator fallback, disposable data
acme-staging    mirrors prod config, seeded data, CI deploys here on merge
acme-prod       real users, tagged releases only

The shared-project shortcut — one project, dev_ prefixed collections — fails in four places that matter:

  1. Security rules cannot be tested in isolation. The rules file governs both datasets, so a rules bug in dev is a rules bug in prod.
  2. Auth is shared. A test account created against dev has a real UID in prod.
  3. Billing is a single number. You cannot tell a load test from a traffic spike.
  4. Index builds block each other. A backfilling composite index on a dev collection is a prod operation.

Wire the projects into .firebaserc so nobody has to remember an ID:

{
  "projects": {
    "default": "acme-dev",
    "staging": "acme-staging",
    "prod": "acme-prod"
  }
}

Every deploy command then carries -P staging or -P prod, and a mistyped alias fails instead of landing somewhere.

Everything that defines behavior lives in the repo

The test for whether your environments are real: delete the staging project, create a new one, and run one command. If you cannot reconstruct it, the console is your source of truth and the console does not have code review.

In the repo:

  • firestore.rules, storage.rules
  • firestore.indexes.json
  • firebase.json — emulator ports, hosting rewrites, function sources
  • functions/ with pinned dependencies
  • Seed scripts for staging data
  • Remote Config template JSON, exported with firebase remoteconfig:get

Index files drift more than anything else, because the console offers a one-click "create index" link in error messages and everyone clicks it. Pull them back down before they diverge:

firebase firestore:indexes -P prod > firestore.indexes.json
git diff --exit-code firestore.indexes.json

Run that in CI on a nightly schedule. A non-zero exit means someone created an index by hand in prod, which also means staging is now a weaker test than you think it is.

The emulator suite is a test dependency, not a dev convenience

The Firebase Emulator Suite runs Firestore, Auth, Functions, Storage, RTDB, and Pub/Sub locally. It is deterministic, it starts in a few seconds, and it is the only sane place to test security rules — against a real project you would be creating and deleting live users to test a deny.

The shape that works in CI:

// firebase.json
{
  "emulators": {
    "auth":      { "port": 9099 },
    "firestore": { "port": 8080 },
    "functions": { "port": 5001 },
    "storage":   { "port": 9199 },
    "ui":        { "enabled": false },
    "singleProjectMode": true
  }
}
firebase emulators:exec --only auth,firestore,functions,storage \
  --project demo-acme "npm run test:integration"

Two details carry most of the value.

Use a demo- project ID. Any project ID starting with demo- makes the emulators refuse to reach cloud resources at all. Without it, a missing emulator host variable in a test means your suite quietly writes to a real project. We have seen a CI run delete a staging collection this way.

emulators:exec exits with your test command's code and tears the emulators down afterward. emulators:start in a background step leaks processes and masks failures.

A rules test looks like this, and the denial cases are the point:

import { initializeTestEnvironment, assertFails, assertSucceeds }
  from '@firebase/rules-unit-testing';

let env;
beforeAll(async () => {
  env = await initializeTestEnvironment({
    projectId: 'demo-acme',
    firestore: { rules: readFileSync('firestore.rules', 'utf8') },
  });
});

afterAll(() => env.cleanup());
beforeEach(() => env.clearFirestore());

test('a member reads their own org, not a neighbour org', async () => {
  await env.withSecurityRulesDisabled(async (ctx) => {
    const db = ctx.firestore();
    await db.doc('orgs/acme/members/u1').set({ role: 'member' });
    await db.doc('orgs/globex/members/u2').set({ role: 'member' });
  });

  const u1 = env.authenticatedContext('u1').firestore();
  await assertSucceeds(u1.doc('orgs/acme/invoices/i1').get());
  await assertFails(u1.doc('orgs/globex/invoices/i9').get());
});

Most rules suites we inherit test that grants work. Almost none test that denials deny, which is the half that gets you breached. We covered the full pattern in Testing Firestore Security Rules Before They Burn You; the point here is that it belongs in the pipeline, on every pull request, not in a file somebody runs occasionally.

Three jobs, in order

A workable pipeline is small. Resist the fifteen-stage version.

On every pull request — no cloud access at all:

- run: npm ci
- run: npm run lint
- run: npx firebase emulators:exec --project demo-acme \
         --only auth,firestore,storage,functions \
         "npm run test:rules && npm run test:integration"

No credentials in this job. It cannot touch a real project, so a compromised dependency in a fork PR cannot either.

On merge to main — deploy staging, then smoke test it:

- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
    service_account: ci-deployer@acme-staging.iam.gserviceaccount.com
- run: npx firebase deploy -P staging \
         --only firestore:rules,firestore:indexes,storage,functions,hosting
- run: npm run test:smoke -- --target=https://acme-staging.web.app

Use Workload Identity Federation, not a long-lived FIREBASE_TOKEN. The legacy CI token flow is deprecated and the token is a full-powered credential sitting in a secrets store with no expiry. A federated identity is scoped to the repo and expires in an hour.

On a tag — deploy prod, with rules and indexes split from code:

- run: npx firebase deploy -P prod --only firestore:indexes
- run: npx firebase deploy -P prod --only firestore:rules,storage
- run: npx firebase deploy -P prod --only functions,hosting

Index-building is asynchronous and can take minutes to hours on a large collection. A query that needs a not-yet-built index fails with FAILED_PRECONDITION. Deploy indexes first, let them build, then ship the code that depends on them. In the other order your release is broken for the duration of the build.

Rules deploy is atomic and near-instant, but it is still a one-way door: the deploy replaces the live ruleset with no version negotiation, and old clients get denied immediately. When a rules change tightens access, ship the client change that satisfies it first, in a prior release.

The permissions most teams get wrong

The CI service account usually ends up with Owner because the first deploy failed with a permission error and that was the fastest fix. Owner on the prod project means your CI pipeline can delete the project.

A deployer that works:

roles/firebasehosting.admin
roles/cloudfunctions.developer
roles/firebaserules.admin
roles/datastore.indexAdmin
roles/iam.serviceAccountUser   (on the functions runtime SA only)

No roles/datastore.user. CI has no business reading or writing production documents. If a migration script needs data access, that is a separate, human-triggered job with a separate identity and an audit log entry.

Staging data: seeded, not copied

The instinct is to clone prod into staging so testing is realistic. That moves personal data into an environment with weaker access control, and most teams do not have a legal basis to do it. Under GDPR it is a processing purpose you almost certainly did not disclose.

Seed instead. A script that writes a few hundred documents covering the shapes that matter — an org with one member, an org with 5,000 members, a user mid-onboarding, a subscription in past_due, a document with the unicode name that broke your search last quarter. It runs in seconds, it is in version control, it is deterministic, and new edge cases get appended as bugs are found.

If you genuinely need production-scale data for a performance test, export to BigQuery and generate a synthetic set from the distributions. Shape is what matters for performance, not the actual values.

What this costs and what it buys

Three projects cost nothing extra. Firebase bills by usage, not by project, and staging under test traffic is typically a few dollars a month. The emulator suite is free and runs on the CI machines you already pay for.

The setup work is roughly two to four days on an existing codebase: a day to move rules and indexes into the repo and reconcile the drift, a day or two to get the first real rules test suite running green, half a day for the pipeline and the service accounts.

What it prevents is specific. Rules that ship unreviewed. An index that exists in prod and nowhere else, so the staging test passed on a query that will fail on real volume. A function deployed from a dirty working tree. A CI token with Owner in a repo secret. Each of those is a normal Tuesday until it is an incident.

This is the baseline we set up under Build & Hardening, and environment and deploy hygiene is one of the sections in a Firebase audit. If your rules currently deploy from somebody's laptop, start there — it is the cheapest fix on the list.

Trademark note: Firebase, Firestore, and Google Cloud are trademarks of Google LLC. FlameRangers is an independent consultancy and is not affiliated with or endorsed by Google.