GitLab CI/CD pipeline for a React app on GCP Firebase Hosting

with Preview Channel / Review Apps for Merge Requests

Posted by Tobias L. Maier on November 07, 2020

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.

Create the basic CI pipeline

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

Setup GCP Firebase Hosting

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:

  • One project for the staging environment, which also includes the preview/review environment
  • One project for the production environment

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 SettingsCI / CDSection "Variables" and create a new Variable called FIREBASE_TOKEN and store the Access Token there.

Screenshot of the Section "Variables" within GitLab CI / CD Settings
Screenshot of the Section "Variables" within GitLab CI / CD Settings

Deploy to Staging and Production environments

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.

Screenshot of GitLab Environments showing the "Deploy to"-button
Screenshot of GitLab Environments showing the "Deploy to"-button

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 | 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 | 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

Deploy Review App with Firebase Preview Channel

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.

Screenshot of a Merge Request with the "View App" button
Screenshot of a Merge Request with the "View App" button

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 | 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

Summary

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.

Complete .gitlab-ci.yml for reference

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/

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 | 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 | 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