Deploy to Firebase Without the Hacks

Thijs Koerselman
6 min readMay 8, 2023

--

I love Firebase. I have been working with it on most of my projects for the past 8 years. We started building a product on the then beta version of Firestore and Functions, and while I have had some of my own coding decisions to regret along the way, I have yet to regret the platform we chose to build it on.

That being said, the Javascript ecosystem has evolved, and in a modern codebase you might be unpleasantly surprised to find out that it is not trivial to deploy to Firebase.

Monorepos

As code complexity grows, you will likely reach a point where it becomes beneficial to use a monorepo setup.

Monorepos traditionally have been a major source of frustration for me, because they are quite difficult to get right. There is so much configuration that you can mess up, and preferred approaches and build tools seemed to change fast in the Typescript ecosystem.

Turborepo

Only recently have I landed for the first time on a setup that I am truly happy with because it includes everything I ever wanted from a monorepo. It is based on Turborepo and other modern build tools. I wrote a separate article about it which I will link below.

Turborepo solves many things without much configuration, and, when combined with other modern tooling, the complexity of a monorepo no longer feels like something I need to worry about, which is a big relief! I encourage anyone to take a look at the Turbo example code.

There was however one problem left to solve; The Firebase tools deploy does not understand monorepos. People have been resorting to all kinds of workarounds to but none of them could be considered elegant or desirable.

The Problem with Firebase

When deploying to Firebase, it wants to upload a folder that behaves like a single-package repository, similar to what you would get from NPM, containing the source files together with a package manifest declaring its dependencies.

When the cloud pipeline receives the contents, it detects the package manager, installs dependencies and optionally triggers a build step.

In a monorepo, your code typically depends on one or more shared packages from the same repository that are not published anywhere.

Once the Firebase deployment pipeline tries to look up those dependencies, it can not find them, and the deployment fails.

Hacking Your Way Out

Using a Bundler

In order to solve this you could try to use a bundler like Webpack to combine your Firebase code with the code internally shared packages, and then remove the corresponding entries from the package.json manifest that is being sent to Firebase. This way it doesn’t know these packages ever existed.

Unfortunately, this strategy quickly becomes problematic…

If the internal packages themselves do not bundle all of their dependencies in their output, Firebase doesn’t know what the shared code depends on, as you are not including their package manifests.

You could then try to bundle the shared package together with all of its dependencies, but if your shared package depends on things your Firebase package also depends on, what do you get?

You now have one part of your code calling an internally bundled copy of a dependency, while another part is referencing that same dependency from a source installed by the package manager.

Also, some modules are not designed to be bundled, because they might consist of more than Javascript code. In my experience that includes some of the Google Cloud Platform libraries. You will soon find yourself trying to configure the bundler to externalize specific libraries in order get it to work, and it all becomes a huge tangled mess.

Even if you manage to make it all work, you are likely creating large bundles, which could in turn lead to slow deployments or increased cold-start times of your cloud functions.

Not exactly a reliable or scalable solution.

Packing and Linking Local Dependencies

An arguably more elegant approach involves packing the local dependencies into a tarball (similar to how a package would be published to NPM), and copying the results to the build output before linking them in an altered manifest file.

This could work quite well, as it basically resembles how your Firebase code would have worked if these packages were installed from an external domain.

Whether you are doing this manually, or write a shell script to handle things, it still feels very cumbersome and fragile, but I think it is a viable workaround if your local dependencies are simple.

However, this approach quickly becomes hairy once you start having internal packages depending on other internal packages, because then you have have multiple levels of things to pack and adapt.

A Real Solution

I have tried various different workarounds before I realized what a convenient solution would look like, and so I ended up making isolate-package.

It takes a similar approach to what is described earlier in packing and linking dependencies, but does so in a more sophisticated way. It is designed to handle different setups and hides all complexity from the user.

The target package is isolated together with all of its internal dependencies, while preserving file structures and generating a pruned lockfile for deterministic deployments.

The name is generic because it does not contain anything specific to Firebase. It should be valuable for other scenarios where you want to translate part of a monorepo to a different context, like a docker image.

For Firebase, the isolate binary it exposes can simply be added to the predeploy hook, and that is pretty much it!

Forking the Firebase Tools

There is one major problem with configuring isolate to run in the predeploy phase. If you start the local Firebase emulators with this configuration, they will run on the isolated output, which means you will lose live code updates while developing.

You could restart the isolation process after each change you want to test in the emulator, but this adds significant friction and undermines the purpose of using an emulator for convenient testing.

To solve this, I have [forked the Firebase Tools](https://github.com/0x80/firebase-tools-with-isolate) to integrate isolate as part of the deploy command.

Using this fork you get monorepo support pretty much out of the box. The only thing you have to do is opt-in by setting isolate: true in your firebase.json configuration.

The fork does not change any functionality of Firebase Tools outside of triggering this extra build step, so I would consider it safe to use. It will be kept in-sync regularly and is published with matching versions.

I plan to auto-detect monorepos in the future, so the fork will work with zero config, and hopefully could be adopted by the Firebase team as some point if they did not develop an alternative by then.

A Working Example

I have been working on a Typescript monorepo boilerplate, as a proof-of-concept to test this approach and other aspects. It demonstrates the use of both isolate-package and the Firebase tools fork.

It uses PNPM but there are parallel branches for NPM, classic Yarn (v1) and modern Yarn (v4).

I also wrote a detailed article about my journey, covering all kinds of things you can run into like ESM adoption and IDE go-to-definition. I recommend reading that if you are struggling with your monorepo setup.

That is it! I hope you found this article helpful.

--

--

Thijs Koerselman
Thijs Koerselman

Written by Thijs Koerselman

Software engineer with a particular interest in sound, music and audiovisual composition.