Hello world!
This is the second post of Building Firebase Apps with Architect series that i have started to help other fellow engineers and founders. Building products has gotten much easier due to the vibe coding platforms and its a very commonly accepted notion that building is just 20% of effort, rest 80% is marketing and promotion, which tbh, i completely agree with, but writing code to launch a product without considering the long term vision for your codebase may work in the short term, but it hinders the growth as you move past the validation phase, as you need to build and test features at a good speed.
In this post, i want to talk about how to setup different environments when building an saas app. The idea is to have a common codebase and have it configured in a way so that when deployed across different environments, it helps you test and build things in complete isolation from other environments. This way, its incredibly useful and safe as we don’t have to experiment in the production app.
Building Firebase Apps with Architect Series
Local development with Firebase
When building an app with firebase as its backend, we usually use firebase functions for its backend apis and firestore for the No SQL database, both of these things are extremely straightforward to setup and use.
Firebase comes with an emulator to emulate most the the backend services that are needed to build an saas app, and there are a few ways you can setup your codebase to use firebase, here are few of the ways to use firebase as a backend service
Use Client SDK
In your client app(web, android, iOS), you can just use firestore client SDK to create, update and delete data, you can read more about this approach here, NOTE: with this approach it is mandatory to setup the correct firebase rules so others can’t abuse your data and app.
Use Firebase Functions
Second approach is to use firebase functions as backend server and write functions that act as a api for your client apps to use, in this approach, client apps can use the firebase client SDK to call the function or write their own custom api handler to make the network calls to firebase functions api endpoints. Here is how to do it in code
import { getFunctions, httpsCallable } from "firebase/functions";
const functions = getFunctions();
const addMessage = httpsCallable(functions, 'addMessage');
addMessage({ text: messageText })
.then((result) => {
// Read result of the Cloud Function.
/** @type {any} */
const data = result.data;
const sanitizedMessage = data.text;
});
- Using a custom api handler to make api calls
// Defining API Client to make api calls
export const apiClient = async (payload: StandardPayload) => {
await getAuth().authStateReady();
const user = getAuth().currentUser;
const authToken = user ? await user.getIdToken(/* forceRefresh= */ false) : null;
const res = await fetch('https://fn-6754.us-central1.run.app', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}),
},
body: JSON.stringify({ data: payload })
});
const body = await res.json();
if (res.status !== 200) throw Error(body.error.message || 'Something went wrong!')
return { data: body?.result };
};
// Making the api call to fetch data
import { apiClient } from "@/app/utils/apiClient"
const response = await apiClient(data);
IMPORTANT: You should attach a custom domain to your firebase functions, this will be helpful in short term and long term both, I have talked about it in my another post here.
Steps of pointing a custom domain to your functions is also different, with v2 functions, you can do it from the google cloud console here, For v1 functions, have a look at these two resources from Google, Blog, Video
Once you have mapped the custom domain with your firebase function, there are different ways to use the domain with the above two approaches with making the api call.
With the client SDK approach, you need to pass your domain to getFunctions call before you can make the call, here is how you do it in code
import { httpsCallable } from "firebase/functions";
export const firebaseApp = initializeApp(FIREBASE_CONFIG);
export const functions = getFunctions(firebaseApp, 'https://www.example.com');
const api = httpsCallable(functions, 'function_name');
api({ data: { hello: 'world' } })
.then((result) => {
console.log('api result', result);
}).catch(error => { console.log('api error', error); });
With the custom api handler approach, you can just pass your domain into the fetch like this
export const apiClient = async (payload: StandardPayload) => {
// ... some code, check the complete definition above
const res = await fetch('https://www.example.com', OPTIONS);
// ... some more code
};
Managing Environment Variables
When you have the same code base for all the environments, you need some identifiers in code to identify the environments and tweak code accordingly. To solve this, we use environment variables, they are called environment variables because they have different values for all the environments. Lets have a look at how to setup environment variables in frontend and backend codebases
Managing Environment Variables in Frontend Codebase
We will use the NextJS react frontend framework to see how to setup environment variables, but these approaches will work across other frontend frameworks too.
Environment variables are usually inject into the codebase during build phase by passing them in the build command, and here is how you do it.
"scripts": {
"dev": "NEXT_PUBLIC_ENV=development next dev --turbopack",
"build": "NEXT_PUBLIC_ENV=production next build --turbopack",
"build:stage": "NEXT_PUBLIC_ENV=staging next build --turbopack"
}
IMPORTANT
When we are passing the variables into the build command, we need to make sure that these variables are injected into client and server code both of the frontend codebase, and Vercel only allows the environment variables defined with NEXT_PUBLIC_ prefix to be injected into both, the client side code and the server side code, if you don’t use the prefix, the variable will only be available when your frontend code is running on the server side, and will resolve to undefined when accessed on client(browser).
Once you have defined and injected the environment variables, you need to access them in code to pick different configurations for different environments. Here are some of the examples of picking the right configurations for their respective environments.
Initialising Google Analytics only for Production Environment
<html lang="en">
<body>
</body>
{
process.env.NEXT_PUBLIC_ENV === 'production' &&
<GoogleAnalytics gaId="G-BJKTECJ4DQ" />
}
</html>
Initialising Sentry only for Production Environment
if (process.env.NEXT_PUBLIC_ENV === "production") {
Sentry.init({
dsn: "",
tracesSampleRate: 1,
enableLogs: true,
debug: false,
});
}
Using API base URLs based on Environments
const BaseURLs: BaseURLsType = {
development: {
api: 'http://127.0.0.1:5001/<project_jd>/<region>/api'
},
staging: {
api: 'https://api-stage.yourdomain.com'
},
default: {
api: 'https://api.yourdomain.com'
}
}
export const getBaseUrls = () => {
return BaseURLs?.[process.env.NEXT_PUBLIC_ENV] || BaseURLs.default;
}
Initialising Emulator when developing locally
// point functions to local emulator
if(process.env.NEXT_PUBLIC_ENV === 'development') {
connectFunctionsEmulator(functions, '127.0.0.1', 5001);
if (!firebaseAuth.emulatorConfig) {
connectAuthEmulator(firebaseAuth, 'http://127.0.0.1:9099')
}
}
Enable Redux dev tools locally
export const makeStore = (preloadedState?: Partial<RootState>) => {
return configureStore({
reducer: rootReducer,
// ... more code
devTools: process.env.NEXT_PUBLIC_ENV === 'development',
});
}
Securing Cookies on Production
cookiesInstance.set(SESSION_COOKIE_NAME, uid, {
httpOnly: true,
secure: process.env.NEXT_PUBLIC_ENV === 'production',
maxAge: 60 * 60 * 24, // One day
path: '/',
});
Managing Environment Variables in Backend Codebase
Managing environment variables in firebase functions codebase is little bit tricky. Passing the environment variables in the build command works only partially, let me explain.
The environment variables that we define for different environments can be used in two different contexts, first is inside the firebase functions, and other is outside the firebase functions, this could be any where in the codebase and depending on where you want to use the variables, you need a different approach to define the variables.
Approach 1: Passing variables in build command
Here is what it looks like to pass the variables in build command —
"serve": "APP_ENV=development npm run build && APP_ENV=development firebase emulators:start --import ./.db --export-on-exit ./.db",
"deploy": "APP_ENV=production firebase deploy --only functions --project <prod_project_id>",
"deploy:stage": "APP_ENV=staging firebase deploy --only functions --project <stage_project_id>",
What happens here is that firebase will inject the environment variables during runtime and their values will only be available inside the firebase function execution, check the code below along with the comments
const functionsConfig = {
maxInstances: 1,
cors: process.env.BASE_URL, // will be undefined as its outside the function execution
timeoutSeconds: 540,
};
exports.api = onCall(functionsConfig, (request: functions.https.CallableRequest) => {
console.log('APP_ENV', process.env.APP_ENV); // here this variable will be available and will work fine.
}
);
Since we cannot use the environment variables outside the functions, we need a different approach of initialising the variables, and clearly we have a lot or use cases where we need the environment variables outside of firebase functions scope.
Approach 2: Using Firebase aliases, dotenv package and some custom logic
The process.env object is always available during runtime, its just that the variables that we pass during the build command only gets injected in the process.env when a functions starts running. But this environment object does contain other fields for example process.env.FUNCTIONS_EMULATOR and process.env.GCLOUD_PROJECT are accessible and are initiated to the right values and we will be using these variables to correctly initialise the environment variables.
When we want to set multiple environment variables, passing them in the build command can get ugly and unmanageable pretty quickly, therefore its better to rely on some third party package like dotenv to make the variables manageable.
dotenv packages allows us to define variables in environment files and lets us initialise all the variables within that file with a single line. Here is how it works
Step 1 - Define .env.development, .env.staging, .env.production files with the correct values
BASE_URL=http://localhost:3000
APP_ENV=development
GCP_PROJECT_ID=<your_gcp_project_id> // needed to fetch secrets from Google Secrets Manager, you may not need it
You can find your GCP_PROJECT_ID here — https://console.cloud.google.com/welcome
Step 2 - Initialise project aliases in .firebaserc file
This is what .firebaserc looks like
{
"projects": {
"default": "<prod-or-stage-project-id>",
"dev": "stage-project-id",
"prod": "prod-project-id",
"stage": "stage-project-id"
},
"targets": {},
"etags": {}
}
Step 3 - Set the correct alias and project id in the build command
"serve": "firebase use dev && npm run build && firebase emulators:start --import ./.db --export-on-exit ./.db",
"deploy": "firebase use prod && firebase deploy --only functions --project <prod-project-id>",
"deploy:stage": "firebase use stage && firebase deploy --only functions --project <stage-project-id>",
Once this is done, and when we run the commands, firebase initialises the process.env.GCLOUD_PROJECT with the correct firebase project id and process.env.FUNCTIONS_EMULATOR boolean to tell if the emulator is running, we will use these two variables to find the environment, here is how we do it in code, and note that the code below must be executed at the very top of the root file of your project, so others can access the environment variables loaded using the dotenv package.
const projectId = process.env.GCLOUD_PROJECT;
const isEmulatorRunning = process.env.FUNCTIONS_EMULATOR === "true";
let env;
if (projectId === "prod-project-id") {
env = "prod";
} else if (projectId === "stage-project-id" && !isEmulatorRunning) {
env = "stage";
} else {
env = "dev";
}
require("dotenv").config({path: `.env.${env}`});
Once, the above code is executed, you can read the environment variables anywhere in your codebase. The code below will now work
// In this code, both the variables -- BASE_URL and APP_ENV will be available with the correct values
const functionsConfig = {
maxInstances: 1,
cors: process.env.BASE_URL,
timeoutSeconds: 540,
};
exports.api = onCall(functionsConfig, (request) => {
console.log('APP_ENV', process.env.APP_ENV);
});
Let me know if there is another way to achieve this, this is what i have tested and it works just fine for me.
Managing Staging & Production Environments with Firebase
To have a clear, separate and truly independent environments in firebase, we need to create two separate projects from firebase dashboard, one for staging and other for production environment and then we need to setup both the projects identically so that our same codebase can run identically on both the environments.
When we create two separate projects, we will get two separate firebase configs and we need to initialise firebase with the correct config based on the environment. Here is how you can initialise the firebase with the correct config in the frontend code.
const firebaseProdConfig = {
projectId: "<prod_project_id>",
// ... other fields
};
const firebaseStageConfig = {
projectId: "<stage_project_id>",
// ... other fields
};
const firebaseConfig = process.env.NEXT_PUBLIC_ENV === 'production' ? firebaseProdConfig : firebaseStageConfig;
// Initialize Firebase
export const firebaseApp = !getApps().length ? initializeApp(firebaseConfig) : getApps()[0];
And here is how you can do it your backend codebase
import {initializeApp} from "firebase-admin/app";
import {getFirestore} from "firebase-admin/firestore";
import {applicationDefault} from "firebase-admin/app";
const creds = applicationDefault();
export const FIREBASE_PROD_CONFIG = {
credential: creds,
projectId: "firebase-prod-id",
};
export const FIREBASE_STAGE_CONFIG = {
credential: creds,
projectId: "firebase-stage-id",
};
export const FIREBASE_CONFIG = process.env.APP_ENV === "production" ? FIREBASE_PROD_CONFIG : FIREBASE_STAGE_CONFIG;
export const firebase = initializeApp(FIREBASE_CONFIG);
export const firebaseDB = getFirestore(firebase);
Securing Environments
When you have deployed the staging and production environments, they need to be secured with the right security rules on cloudflare and firebase both. Your staging environments has no business being publicly accessible and should only be accessible using a VPN.
So, you should setup and custom VPN server on a VPN and setup a cloudflare rule so that only you can access your staging servers and only when you are connected to your VPN.
Here is an video on setting up a custom VPN server, and this is how you setup a cloudflare rule to block public access to your staging frontend and backend URLs.

Top comments (0)