GitLab CI/CD pipeline for a React app on GCP Firebase Hosting
with Preview Channel / Review Apps for Merge Requests
This post explains how to setup and integrate GitLab CI and Firebase Hosting in order to provide an efficient CI/CD pipeline and also enable to preview and review Merge Requests when they are in the making.
I wanted to deploy a React app. My first choice would have been GitLab Pages for the sake of simplicity, but as it faces certain limitations relevant for Single Page Applications (SPAs)1, I went for GCP Firebase Hosting.
Firebase Hosting has some nice features, which make it a nice choice to host web applications in general.
For starters, it includes a CDN, manages your SSL certificates and has a generous free tier.
But there is more: As hinted above, for a SPA it is important to rewrite all urls to /index.html
in order to have the SPA Router take over and render the right page.
What I particularly like is the recently announced Preview Channel feature of Firebase Hosting, which complements in my opinion perfectly GitLabs’ Review Apps feature. Both features aim to deploy a change (Merge Request) to a preview or review environment before accepting a Merge Request. This simplifies hands-on testing and improves the feedback loop within the development process.
This blog post will explain how to set up GitLab CI to create a CI/CD pipeline for the React App, which supports Preview Channels/Review Apps, continuously deploys to staging and production environments.
My CI pipeline is pretty straight forward.
It essentially consists of build
, test
and deploy
stages.
And additionally to this, an install
stage.
The install
stage takes care of installing packages and optimizes the caching the installed node_modules
within the following jobs.
stages:
- install
- build
- test
- deploy
install:
stage: install
image: node:14.9-alpine3.12
script:
- npm install
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
The build
job builds the application.
It uses the previously cached NPM packages (pull only, no override, see cache
keys)
The artifact is what we will deploy later on.
It consists of the build
directory and also carries over two Firebase Hosting related files (see artifacts
key).
Carrying them over will save us in the deploy
jobs from actually cloning this git repository, which in-turn will increase the speed of the pipeline.
We further improve speed by allowing the jobs of the build and test stages to run in parallel by enabling the Directed Acyclic Graph (DAG)2
(see needs
key)
build:
stage: build
image: node:14.9-alpine3.12
script:
- npm run build
needs: ["install"]
cache:
key:
files:
- package-lock.json
policy: pull
paths:
- node_modules/
artifacts:
expire_in: 1 week
paths:
- .firebaserc
- build
- firebase.json
The test
stage consists of a lint job as well as a classic test job.
lint:
stage: test
image: node:14.9-alpine3.12
needs: ["install"]
cache:
key:
files:
- package-lock.json
policy: pull
paths:
- node_modules/
script:
- npm run lint
test:
stage: test
image: node:14.9-alpine3.12
needs: ["install"]
cache:
key:
files:
- package-lock.json
policy: pull
paths:
- node_modules/
script:
- npm run test
A detailed guide on how to create a Firebase Hosting project for an SPA is out of scope for this article. I believe the official get started guide will bring you quite far.
I just want to mention some general assumptions on the cloud design, which are also relevant for the upcoming sections:
We have two GCP projects:
We do this to provide proper isolation of the production environment from all other environments. This avoids that the production environment would ever be hit, if we would accidentally reach any quota limit within the staging environment and it simplifies access control to the cloud resources, when necessary.
In order to be able to deploy to any environment, we need to provide access credentials to GitLab CI. There are two ways available: Create a GCP Service Account or generate a Firebase Access Token. I went with the former for the sake of simplicity. However, I consider Service Accounts for each GCP project to be a safer solution.
Create the Firebase Access Token using $ firebase login:ci
.
Go to your GitLab Project Settings
→ CI / CD
→ Section "Variables"
and create a new Variable called FIREBASE_TOKEN
and store the Access Token there.
The deploy
jobs deploy the application to the staging and production environment.
We follow the GitHub flow-based branching strategy. We assume that there is a master branch, wich will be automatically deployed to the staging environment and then in a manual action to production. Changes will be proposed with Merge Requests. which we discuss in the next section in detail.
GitLab provides support for environment and deployments.3 This feature allows to trigger the deployment from staging to production, but also supports to roll-back to a previous release, if ever necessary.
To track deployments, I set the environment
key, where the URL of this environment is dynamically derived from Firebase (see script
, environment
and artifacts
keys).
$ echo "ENVIRONMENT_URL=$(firebase hosting:channel:open live --non-interactive | tail -1 | awk '{ print $3 }')" >> deploy.env
does this.
As you can see, this is not trivial.
I don’t know if this would have been possible at all before GCP introduced the Preview Channel feature, which we discuss below in more detail.
And with it, it also requires some awk
magic to get the required string.
The job uses the firebase CLI to deploy the SPA.
It uses the job artifacts of the build
job and thus, does not need to clone the git repository.
deploy:staging:
image: andreysenov/firebase-tools:latest
stage: deploy
before_script:
- firebase use $ENVIRONMENT
script:
- firebase deploy
--token $FIREBASE_TOKEN
--message "Pipeline $CI_PIPELINE_ID, build $CI_BUILD_ID"
--non-interactive
--only hosting
- echo "ENVIRONMENT_URL=$(firebase hosting:channel:open live --non-interactive | tail -1 | awk '{ print $3 }')" >> deploy.env
environment:
name: staging
url: $ENVIRONMENT_URL
artifacts:
reports:
dotenv: deploy.env
dependencies:
- build
variables:
GIT_STRATEGY: none
ENVIRONMENT: staging
only:
- master
deploy:production:
extends: deploy:staging
environment:
name: production
variables:
ENVIRONMENT: production
when: manual
I always recommend to open Merge Requests as draft as early as possible. This will be particularly useful for the preview and review feature, which I want to enable here.
The intended workflow is as follows:
A developer takes an Issue/Story, creates the branch and immediately the merge request.
The merge request is marked as draft
, which allows to make upcoming changes visible as early as possible, but which disables the capability to merge it just yet.
A co-worker (e.g. a PO, a tester, an architect) can see the Merge Request, discuss and review the code - and thanks to the Review App feature, he or she can also see the running application.
Each new deployment to a preview channel will generate a new URL dynamically, similar to the deploy job discussed above.
What is different is the $ firebase hosting:channel
command, which deploys the application to the Preview Channel and provides the URL.
GitLab would also provide the capability to destroy a Review App deployed, e.g. when the Merge Request has been merged or closed. This is not necessary for Firebase Hosting, as the Preview Channel will be closed after a few days (max. 30 days)4.
deploy:review:
image: andreysenov/firebase-tools:latest
stage: deploy
before_script:
- firebase use $ENVIRONMENT
script:
- firebase hosting:channel:deploy $CI_ENVIRONMENT_SLUG
- echo "ENVIRONMENT_URL=$(firebase hosting:channel:open $CI_ENVIRONMENT_SLUG --non-interactive | tail -1 | awk '{ print $3 }')" >> deploy.env
environment:
name: review/$CI_COMMIT_REF_NAME
url: $ENVIRONMENT_URL
artifacts:
reports:
dotenv: deploy.env
dependencies:
- build
variables:
GIT_STRATEGY: none
ENVIRONMENT: staging
only:
- branches
except:
- master
I created a GitLab CI/CD pipeline for Firebase Hosting, which is also enables the Review App capability of GitLab.
Firebase Hosting proved to be a simple solution in the context.
What was non-trivial was to actually get the URLs of the environments.
I don’t know if this would have been possible at all before GCP introduced the Preview Channel feature.
And with it, it also requires some awk
magic to get the required string.
.gitlab-ci.yml
for referencestages:
- install
- build
- test
- deploy
install:
stage: install
image: node:14.9-alpine3.12
script:
- npm install
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
build:
stage: build
image: node:14.9-alpine3.12
script:
- npm run build
needs: ["install"]
cache:
key:
files:
- package-lock.json
policy: pull
paths:
- node_modules/
artifacts:
expire_in: 1 week
paths:
- .firebaserc
- build
- firebase.json
lint:
stage: test
image: node:14.9-alpine3.12
needs: ["install"]
cache:
key:
files:
- package-lock.json
policy: pull
paths:
- node_modules/
script:
- npm run lint
test:
stage: test
image: node:14.9-alpine3.12
needs: ["install"]
cache:
key:
files:
- package-lock.json
policy: pull
paths:
- node_modules/
script:
- npm run test
deploy:review:
image: andreysenov/firebase-tools:latest
stage: deploy
before_script:
- firebase use $ENVIRONMENT
script:
- firebase hosting:channel:deploy $CI_ENVIRONMENT_SLUG
- echo "ENVIRONMENT_URL=$(firebase hosting:channel:open $CI_ENVIRONMENT_SLUG --non-interactive | tail -1 | awk '{ print $3 }')" >> deploy.env
environment:
name: review/$CI_COMMIT_REF_NAME
url: $ENVIRONMENT_URL
artifacts:
reports:
dotenv: deploy.env
dependencies:
- build
variables:
GIT_STRATEGY: none
ENVIRONMENT: staging
only:
- branches
except:
- master
deploy:staging:
image: andreysenov/firebase-tools:latest
stage: deploy
before_script:
- firebase use $ENVIRONMENT
script:
- firebase deploy
--token $FIREBASE_TOKEN
--message "Pipeline $CI_PIPELINE_ID, build $CI_BUILD_ID"
--non-interactive
--only hosting
- echo "ENVIRONMENT_URL=$(firebase hosting:channel:open live --non-interactive | tail -1 | awk '{ print $3 }')" >> deploy.env
environment:
name: staging
url: $ENVIRONMENT_URL
artifacts:
reports:
dotenv: deploy.env
dependencies:
- build
variables:
GIT_STRATEGY: none
ENVIRONMENT: staging
only:
- master
deploy:production:
extends: deploy:staging
environment:
name: production
variables:
ENVIRONMENT: production
when: manual
| tail -1
to the “deploy.env
” command to workaround additional data (namely a deprecation warning), which firebase CLI prints to stdout