Deploy to Firebase without the hacks

Thijs Koerselman
5 min readMay 8, 2023

--

I love Firebase. I have been working with it on most of my projects for the past 7 years. We started building a product on the then beta version of Firestore and Functions, and it has been a pretty smooth track to success. While I’ve 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.

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. Preferred approaches and build tools seem to change fast in the Typescript ecosystem, and things got especially hairy when I tried to adopt ES Modules.

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’ll 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’d find on 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, and especially a private one, your Firebase code typically depends on one or more internal packages from the same repository, for which you have no desire to publish them 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 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 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’re doing this manually, or write a shell script to handle things, it still feels very cumbersome and fragile to me, 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 could look like, and I ended up writing 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!

It gets better

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, meaning you lose live code updates while developing.

You could re-start the isolation process after every change you want to test in the emulator, but this could be a real pain and creates a lot of unnecessary overhead.

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.

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.

There are 4 parallel branches to choose from, one for PNPM (main), NPM, classic Yarn (v1) and modern Yarn (v4).

I also wrote a detailed article about my journey, linked below, so I recommend reading that if want to have a better understanding of the challenges and motivation.

Enjoy! I hope you found this useful.

--

--

Thijs Koerselman

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