Firebase Environments;
[dev, test, prod]

Serious Firebase projects need multiple environments, usually at least three: dev, test and prod.

And you'll need to set these environments up at the beginning of a new project. This kind of setup is much harder to do once the codebase has grown and you're settled into your workflows.

tl;dr

See the tl;dr at the bottom of this article. I don't want to spoil it for you 😉

Two strategies

  • One-to-One: Configure a new Firebase/GCP project for each environment
  • One-to-Many: Configure a single Firebase/GCP project for many environments

One-to-one

One Firebase/GCP project for each environment is the gold standard for Firebase project configuration. You get perfect isolation between environments, and you can create as many environments as you like. You'll never get any weird interactions or confusion between them. Each Firebase project is an isolated, deployable "stack".

The downside is that you'll be managing security accounts, environment variables and GCP config for each individual account. You'll need a bulletproof README file for your project, because your engineers will spend time copying configurations across environments.

You can and should automate your environment setup as appropriate.

One-to-one feels expensive at the beginning of a new project because it is expensive. Enterprise applications should always be configured one-to-one. The upfront costs will pay off for years to come.

This strategy is self-explanatory. Stop reading now if you're already commited to full devops automation and isolation. Also stop reading if your project has a budget greater than 100,000 USD, because you absolutely have the budget to achieve full automation and isolation.

One-to-many

Smaller projects, prototypes and demos probably don't need the upfront costs of a one-to-one style configuration.

You still want to multiple environments... but you only want to manage a single Firebase/GCP project.

This can be accomplished with a little trickery; however, you won't achieve perfect environment isolation. There are a few Firebase services that will affect the entire project. You may find yourself having to engineer the environment isolation, which could get expensive if the project gets big.

Downsides

We need to cover the downsides first before launching into any sort of compromise solution.

  1. Cloud Functions are difficult to isolate
  2. Firebase Cloud Messaging is difficult to isolate
  3. Firebase Hosting cannot be isolated
  4. Firebase Authentication cannot be isolated
  5. Your engineers will have access to customer data in production

Upsides

  1. One service account to rule them all; i.e., your setup is much quicker
  2. One GCP project can be easier to track
  3. Security Rules will affect all environments identically
  4. Cloud Functions will affect all environments identically

Isolate your data

You'll need to isolate your data by environment, so your paths will need to be dynamic. This is good practice for all Firebase projects. There are lots of strategies that can work, but I like to create a schema.js file.

schema.js

The following file exports a function that takes an environment string and returns a schema object.

// schema.js
export default environment => ({
  userActivationCode: (rtdb, uid) => {
    return rtdb
      .ref(environment)
      .child('activation-code')
      .child(uid);
  },

  notifications: (rtdb, uid) => {
    return rtdb
      .ref(environment)
      .child('notifications')
      .child(uid);
  },

  pushNotifications: (rtdb, uid) => {
    return rtdb
      .ref(environment)
      .child('push-notifications')
      .child(uid);
  },

  settings: (db, uid) => {
    return db
      .collection(environment)
      .collection('permission-based')
      .doc('user-owned')
      .collection('settings')
      .doc(uid);
  },

  subscriptions: db => {
    return db
      .collection(environment)
      .collection('permission-based')
      .doc('user-readable')
      .collection('subscriptions');
  },

  userSubscriptions: (db, uid) => {
    return db
      .collection(environment)
      .collection('permission-based')
      .doc('user-readable')
      .collection('subscriptions')
      .doc(uid);
  },

  user: (db, uid) => {
    return db
      .collection(environment)
      .collection('users')
      .doc(uid);
  },

  users: db => {
    return db.collection(environment).collection('users');
  },

  userMessages: (db, uid) => {
    return db
      .collection(environment)
      .collection('permission-based')
      .doc('user-owned')
      .collection('messages')
      .doc(uid)
      .collection('messages');
  },

  userUploads: (storage, uid, hash) => {
    return storage
      .ref('user-uploads')
      .child(uid)
      .child(hash);
  },
});

Use schema.js

You can get references to RTDB and Firestore collections and objects like this:

// Get an RTDB ref
import Schema from './schema.js';
const rtdb = window.firebase.database();
const schema = Schema('test');

const userActivationCodeRef = schema.userActivationCode(rtdb, 'some-user-uid');
// Get an Firestore ref
import Schema from './schema.js';
const db = window.firebase.firestore();
const schema = Schema('test');

const settingsRef = schema.settings(db, 'some-user-uid');

Swap out the test string with dev, development, production or whatever you like. This strategy nests all of your data in a sub-collection under that string. So your data will live in test/activation-code/some-user-uid and test/permission-based/user-owned/settings/some-user-uid.

Write flexible security rules

Check out the following example of flexible security rules for Firestore. Notice how the individual match paths are all nested under match /{environment}.

service cloud.firestore {
  match /databases/{database}/documents {
    function isUser(uid) {
      return request.auth.uid == uid;
    }

    function isAdmin() {
      return request.auth.token.isAdmin == true;
    }

    function isModerator() {
      return request.auth.token.isModerator == true;
    }

    match /{environment} {
      match /admin/{document=**} {
        allow read, write: if isAdmin() || isModerator();
      }

      match /users/{uid} {
        allow read, write: if isUser(uid) || isAdmin() || isModerator();
      }

      match /permission-based/user-owned/{type}/{uid} {
        allow read, write: if isUser(uid) || isAdmin() || isModerator();
      }

      match /permission-based/user-owned/{type}/{uid}/messages/{messageId} {
        allow read, write: if isUser(uid) || isAdmin() || isModerator();
      }

      match /permission-based/user-writeable/{type}/{uid} {
        allow write: if isUser(uid) || isAdmin() || isModerator();
      }

      match /permission-based/user-readable/{type}/{uid} {
        allow read: if isUser(uid) || isAdmin() || isModerator();
      }
    }
  }
}

Here's an example of flexible security rules for the Realtime Database. It's simple to nest everything under "$environment"

{
  "rules": {
    "$environment" {
      "notifications": {
        "$uid": {
          ".read": "auth.uid == $uid || auth.token.isAdmin == true",
          ".write": "auth.uid == $uid || auth.token.isAdmin == true"
        }
      },
      "activation-code": {
        "$uid": {
          ".read": "auth.uid == $uid || auth.token.isAdmin == true",
          ".write": "auth.uid == $uid || auth.token.isAdmin == true"
        }
      },
      "push-notifications": {
        "$uid": {
          ".read": "auth.token.isAdmin == true",
          ".write": "auth.token.isAdmin == true"
        }
      }
    }
  }
}

Mitigate Cloud Functions/Firebase Cloud Messaging interactions

Cloud Functions and Firebase Cloud Messaging (FCM) are trickier to isolate, because they have a single deploy per project.

It's not impossible to partially isolate them, but if your code breaks, you're breaking prod, and that's just bad news. So don't break your Cloud Functions or FCM implementations.

The trick here is to deploy different functions to different triggers. It's all about the prefixes.

It could looks something like this:

// functions/index.js
const admin = require('firebase-admin');
const environment = require('./environments/environment.js');
const devEnvironment = require('./environments/environment.dev.js');
const functions = require('firebase-functions');

const config = functions.config();
const isDevelopment = config.environment && config.environment.is_development == 'true';
const context = { admin, environment: isDevelopment ? devEnvironment : environment };

const AuthorizationOnCreate = require('./src/authorization-on-create');
const GithubIngestContent = require('./src/github-ingest-content');
const GithubWebhookPushOnRequest = require('./src/github-webhook-push-on-request');
const MessagesOnWrite = require('./src/messages-on-write');
const PushNotificationsOnCreate = require('./src/push-notifications-on-create');

admin.initializeApp();

// Auth trigger
exports.authorizationOnCreate = functions.auth.user().onCreate(AuthorizationOnCreate(context));

// Pubsub trigger
exports.githubIngestContent = functions.pubsub
  .topic(environment.pubSub.GITHUB_INGEST_CONTENT)
  .onPublish(GithubIngestContent(context));

// RTDB
exports.pushNotificationsOnCreate = functions.database
  .ref(`${environment.schema.pushNotifications}/{uid}/{pushNotificationId}`)
  .onCreate(PushNotificationsOnCreate(context));

// Firestore trigger
exports.messagesOnWrite = functions.firestore
  .document(`${environment.schema.messages}/{uid}/messages/{messageId}`)
  .onWrite(MessagesOnWrite(context));

// HTTP: Express app
const express = require('express');
const cors = require('cors');

const app = express();

app.use(cors({ origin: true }));

app.get('/', (req, res) => res.sendStatus(200));

app.post('/github-webhook-push', GithubWebhookPushOnRequest(context));

exports.api = functions.https.onRequest(app);

This example relies on a context object that gets passed into each function. This is a pattern known as currying, which just means wrapping a function in another function. The parent function takes some arguments that the child function can then use.

Here's a quick example from the code above:

// functions/src/authorization-on-create
module.exports = context => async user => {
  const { admin, environment } = context;
  const auth = admin.auth();

  // TODO: implement auth trigger logic
};

functions/src/authorization-on-create exports a function that takes a context argument and returns the Cloud Function that we care about.

We can pass entirely different contexts in, even fake contexts for testing purposes. This dependency injection style is incredibly powerful, and we've used it without any sort of DI framework. It's just vanilla, functional JavaScript.

Accept that Firebase Hosting is a shared resource

Each Firebase project has a single Firebase Hosting deploy.

You can get fancy and nest your deployed assets under routes, like public/prod/index.html and public/dev/index.html. I can't recommend that strategy... but it could definitely work. It's just going to lead to a more complex deploy process.

It might make more sense to accept that your front-end assets are going to be served either locally via your local dev process or in production. There's no test deploy for your front-end assets in this configuration.

But do you really care? If it's a quick and dirty project, or if your budget is tight, you're unlikely to have a QA process that requires a test environment.

Accept that Firebase Authentication is a shared resource

This isn't usually a problem. Just recognize it up front.

If you do anything fancy with a user account, say... implement custom claims... you'll need separate logins for each environment.

Accept less data security

There's no way to segregate data access within a Firebase project.

If you decide to host your production data in the same Firebase project as your development and/or test data, your engineers will be able to read, write and delete production data.

Again, you may not care. Not all data is particularly sensitive. Sometimes the project is merely a demo with fake data, or the data is all in-house anyway.

Use your best judgement.

Create backup processes as necessary.

tl;dr

One Firebase/GCP project for each environment is the gold standard. Do this if you have time and budget for automated devops and/or you need complete isolation.

Multiple environments can be hosted within a single Firebase/GCP project. It's definitely faster to set up, and it will force you to use some very powerful dependency injection techniques. The downside is that Cloud Functions, Firebase Cloud Messaging, Firebase Hosting and Firebase Authentication are all shared resources that can be difficult or impossible to isolate.


Still need help?

Find me online at firebaseconsulting.com or chrisesplin.com

Email me at chris@chrisesplin.com or find me on Twitter @ChrisEsplin. My DMs are always open!