I built a multi-tenant PWA on Firebase this year: three environments, role-based access across organizations, offline support. Firebase makes the front-end wiring easy. What took real thought was splitting environments so a staging accident could never touch prod, and getting RBAC to work offline without an extra Firestore read on every request.
Three Firebase projects, not one
Firebase's free tier caps you at one database per project. Trying to run dev, staging, and prod on a single project is the fastest way to have your staging misconfigure and start writing to production collections.
I use three completely separate Firebase projects: my-app-dev, my-app-staging, my-app-production. Each has its own auth users, Firestore, storage bucket, and hosting deployment. Isolated security rules, isolated usage quotas, isolated service account permissions.
| Environment | Purpose | Deployment |
|---|---|---|
| Development | Local testing with emulator | Manual |
| Staging | Pre-production testing | GitHub Actions on staging push |
| Production | Live users | GitHub Actions on main push |
Environment config: explicit, not clever
The layout:
app/
├── .env # Development (uses emulator)
├── .env.staging # Staging deployment
├── .env.production # Production deployment
└── src/
└── config/
└── firebase.ts # Firebase initialization
Development .env:
REACT_APP_FIREBASE_PROJECT_ID=my-app-dev
REACT_APP_USE_EMULATOR=true
REACT_APP_FIREBASE_API_KEY=your-dev-api-key
REACT_APP_FIREBASE_AUTH_DOMAIN=my-app-dev.firebaseapp.com
Production .env.production:
REACT_APP_FIREBASE_PROJECT_ID=my-app-production
REACT_APP_USE_EMULATOR=false
REACT_APP_FIREBASE_API_KEY=your-prod-api-key
REACT_APP_FIREBASE_AUTH_DOMAIN=my-app-production.firebaseapp.com
My worst near-miss was letting the build script guess which environment file to load. Something upstream flipped the environment detection, staging built with production credentials pointed at the production Firestore, and I didn't catch it for hours.
Red flag: never let the build script guess the environment. Copy the file explicitly in the deploy step. If the command doesn't say the environment name in plain text, you have a foot-gun waiting to fire.
Right:
cp .env.production .env && pnpm build
Custom claims for multi-tenant RBAC
The classic pattern is storing roles in Firestore and reading them on every request. It works, but every protected page then loads two documents: the data you actually want, and the role check that guards it. It also breaks the moment the network drops.
Firebase Custom Claims live inside the JWT ID token. The client caches the token. Firestore security rules read the claims directly with no extra document lookup. And they work offline because the token is already on the device.
Claims structure for a user who admins one organization and views another:
interface CustomClaims {
isAdmin?: boolean; // Platform super-admin
organizations?: Record<OrgId, Role>; // Org-specific roles
}
{
"isAdmin": false,
"organizations": {
"org_abc123": "admin",
"org_xyz789": "viewer"
}
}
There is no Firebase Console UI for custom claims. You set them via the Admin SDK. I keep a small script in firebase-admin-scripts/:
// scripts/set-user-claims.js
const admin = require('firebase-admin');
admin.initializeApp({
credential: admin.credential.cert('./service-account.json')
});
async function setUserClaims(email, organizations) {
try {
const user = await admin.auth().getUserByEmail(email);
await admin.auth().setCustomUserClaims(user.uid, {
isAdmin: false,
organizations: organizations
});
console.log(`Claims updated for ${email}`);
} catch (error) {
console.error('Error:', error.message);
}
}
setUserClaims('user@example.com', {
'org_abc123': 'admin'
});
Lesson learned: custom claims cap at 1000 bytes per user. Enough for organization IDs and roles. For fine-grained per-resource permissions, keep those in Firestore and use claims only for the org membership set. Overload the claim and Firebase silently rejects future writes.
Security rules that use the claims
The rule below reads the org role straight from the token and applies different access per role, no extra Firestore read.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function hasOrgAccess(orgId, allowedRoles) {
return request.auth != null &&
request.auth.token.organizations[orgId] in allowedRoles;
}
match /organizations/{orgId} {
allow read: if hasOrgAccess(orgId, ['admin', 'editor', 'viewer']);
allow write: if hasOrgAccess(orgId, ['admin']);
}
match /organizations/{orgId}/documents/{docId} {
allow read: if hasOrgAccess(orgId, ['admin', 'editor', 'viewer']);
allow create, update: if hasOrgAccess(orgId, ['admin', 'editor']);
allow delete: if hasOrgAccess(orgId, ['admin']);
}
}
}
Test rules before deploying. Firebase Console → Firestore → Rules → Rules Playground simulates a user with specific claims against a specific path. I also keep unit tests using @firebase/rules-unit-testing:
describe('Organization access', () => {
it('allows an org member to read documents', async () => {
const db = getFirestore(testEnv, {
uid: 'user123',
token: { organizations: { org_abc: 'viewer' } }
});
const doc = db.collection('organizations').doc('org_abc');
await assertSucceeds(doc.get());
});
it('denies non-member access', async () => {
const db = getFirestore(testEnv, { uid: 'user456' });
const doc = db.collection('organizations').doc('org_abc');
await assertFails(doc.get());
});
});
Skip these tests and you'll ship a rule that permits more than you think. There is no runtime warning, just quiet exposure.
Deployment pipeline
Push to staging, GitHub Actions builds and pushes to the staging Firebase project:
# .github/workflows/deploy-staging.yml
name: Deploy to Staging
on:
push:
branches: [ staging ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: pnpm install
- name: Build for staging
run: |
cp .env.staging .env
pnpm build
- name: Deploy to Firebase Hosting
uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_STAGING }}'
projectId: my-app-staging
channelId: live
Store the Firebase service account JSON as a GitHub secret. Never commit it. The account has full write access to the Firestore database it belongs to.
Backup strategy for production
Three layers, each covering a different failure mode.
Daily backups with 7-day retention catch accidents you notice within the week: dropped collection, bad migration you spot on Monday.
gcloud firestore backups schedules create \
--database='(default)' \
--recurrence=daily \
--retention=7d
Weekly backups with 8-week retention catch slow-burn corruption you don't notice for a month: a bad migration that only affects new writes.
gcloud firestore backups schedules create \
--database='(default)' \
--recurrence=weekly \
--retention=8w \
--day-of-week=SUN
Monthly exports to Cloud Storage with 365-day retention are the everything-is-on-fire recovery: full portable dump, retrievable a year out.
gsutil mb -l us-central1 gs://my-app-backups
gcloud scheduler jobs create http firestore-monthly-export \
--location=us-central1 \
--schedule="0 2 1 * *" \
--uri="https://firestore.googleapis.com/v1/projects/my-app-production/databases/(default):exportDocuments" \
--http-method=POST \
--oauth-service-account-email=backup-sa@my-app-production.iam.gserviceaccount.com \
--headers="Content-Type=application/json" \
--message-body='{"outputUriPrefix":"gs://my-app-backups/monthly"}'
Native daily and weekly backups are free. You pay only when you restore. Cloud Storage exports run about $0.02-0.05/GB/month. For most apps under a few GB, all three layers combined stay under $5 a month.
Firebase Emulator: not optional
firebase emulators:start --only auth,firestore,storage
Skip this and you'll burn through your Firebase read quota on trivial dev work, or worse, write test data into production because you forgot to swap credentials. The emulator gives you zero API quota consumption, instant reset by restarting the process, offline development, and no chance of a stray test write hitting production.
What actually matters
The Firebase parts people underestimate are the ones that survive an incident, not the ones that make the app feel snappy.
Separate projects per environment eliminate the entire class of "staging leaked into prod" bugs. Custom claims give you RBAC that works offline and doesn't cost a Firestore read per page load, but remember the 1000-byte cap. Three-tier backups cover three failure modes for the price of a coffee per month. And the Firebase Emulator is the difference between working locally without thinking and getting surprised by a bill.
References I keep bookmarked:
Questions about Firebase infrastructure or DevOps? Connect with me on LinkedIn or visit my portfolio.
Top comments (0)