Build your own preview deployment service

Aug 25 2020

/ 9 min read /

0 Likes

0 Replies

0 Reposts

Preview deployments are an essential step in the CI/CD pipelines of many frontend teams. The ability to preview every frontend change in a hosted and self-contained environment can increase the development velocity of a team quite significantly. Moreover, it brings more confidence that any newly added change will not bring any undesirable effect that would not get caught by automated tests before getting merged to production.

I wanted to bring this kind of service to my team at work, however, using one of the already available platforms that provided preview deployments out of the box such as Netlify, Vercel or Serverless was not an option. All our services and deployments were either managed on Google Cloud or Firebase. Thus, if we wanted a preview deployment service, we would have to build it on Google's Cloud platform.

Luckily, Google provides a great serverless service called Cloud Run. Cloud Run enables teams to deploy containers to production in a matter of seconds! Thus, I chose it as the service where the preview deployments would live, then built an automated pipeline around it that would deploy any change made to an app on every pull request and returned a URL to access that new version of that same app. In this article, we will go through each step to implement such an automated pipeline and build your own preview deployment service on Google Cloud Run as I did 🚀.

I'm only going to focus on my implementation of preview deployments on Google Cloud Run in this post. However, I'm pretty convinced a similar approach is doable on AWS lambda or other serverless platforms.

The perfect preview deployments developer experience

You might have seen this "preview deployments" feature in many other tools or SaaS out there, but I wanted to put together the list of elements that compose a great preview deployments developer experience before deep diving in my implementation. I used this list as my "north star" when building the automated pipeline and looking for how to host my previews, and the following are the key items I took into account:

  • automated: whether it's on every push or every pull request event, the developer should not need to execute any command manually to make the preview operational.
  • easily accessible: once deployed, your preview deployment should have a unique link that allows anyone to access that specific version of your frontend app.
  • fast: the whole process of getting your app deployed should last no more than a couple of minutes
  • self-actualized: each new change on the same branch or pull request should be deployed (preferably) on top of the other one
  • running in a consistent environment: each preview should run in the same replicated environment

Some people prefer to have a unique preview deployment URL per commit, others per pull request, I prefer per pull request which is the one way I'll detail in the rest of this post.

Considering these points, I knew I'd have to use Docker containers from the get-go to deploy my previews:

  • their portability ensure that the environment of the preview deployments is constant.
  • having one image per PR is easy: I could build the image and tag it with the number of the pull request. Each new change would be built and tagged with that same number, thus ensuring the image always contains the most up to date version of the UI for that PR.

Thus, the first steps of our preview deployments pipeline would consist of:

  1. Building our UI
  2. Building a Docker image
  3. Tagging our Docker image with the PR number

To help you get started here's one of the Dockerfile I always go back to build my frontend projects. It uses multistage builds and the image it outputs is very small:

Dockerfile sample to build and run an app in a containerized environment

docker
1
FROM node:12.18.3 as build
2
WORKDIR /usr/src/app
3
COPY package.json yarn.lock ./
4
RUN yarn
5
COPY . ./
6
RUN yarn build
7
8
FROM node:12.18.3-stretch-slim
9
COPY --from=build /usr/src/app/build /app
10
RUN yarn global add serve
11
WORKDIR /app
12
EXPOSE 3000
13
CMD ["serve", "-p", "3000", "-s", "."]

I wouldn't use this image to run the app in production though. Here, it relies on Vercel's serve NPM package to host the built files which is recommended to only use for testing or development.

Deploying and tagging services on Google Cloud Run

Considering the elements we listed in the previous part for the perfect preview deployments experience, it seemed that leveraging a serverless solution like Google Cloud Run is a great fit to deploy and run previews:

  • it's cheap to run the different revisions of the app: you only pay for the traffic on the revisions
  • each revision can have its own URL to be accessible: by tagging revisions, you can associate a tag to a revision which generates a unique URL for that revision
  • it's fast: it only takes a few seconds to deploy services and revisions
  • it's scalable: you can spin up to 1000 revisions per service! Once you reach that number, the oldest revisions will be simply removed from your service. Thus, no need to worry about taking down our revisions once we merge our pull request.

We will now look into each of the steps necessary to deploy a service, a revision of a service, and how to tag a revision on Google Cloud Run. The commands that will be listed in this section will eventually make it into a Github Workflow that we will detail in the next part.

From this point, you will need a Google Cloud account with a project if you want to make the workflow work. Click here to learn how to create a project on Google Cloud. I'll also use PROJECTID as the placeholder project in my examples, you'll need to replace it with the project ID you set up on your end if you want to run the commands 😊.

To follow the steps detailed below on your local machine, you will need to:

  1. Install Google Cloud SDK
  2. Install the SDK's beta components
  3. Install Docker 
  4. Set your local CLI to use your project: gcloud config set project PROJECTID
  5. Configure Docker so it can pull/push from the Google Container Registry: gcloud auth configure-docker

This is totally optional as these commands will eventually run on GithubCI anyways and do not need to run locally.

Push the image to Google Cloud Registry (GCR)

First, we need to push the Docker image of our app that we built in the previous part:

1
docker push gcr.io/PROJECTID/IMAGENAME:TAG

Replace PROJECTID with your project ID,  IMAGENAME with the name of the image you built, and TAG with the tag of that image (the tag will matter the most in the next part that focuses on automating these steps)

Deploy a service on Cloud Run

Running the following command will allow us to deploy the Docker image we just pushed to GCR as a container on Cloud Run:

1
gcloud beta run deploy "myapp" --image "gcr.io/PROJECTID/IMAGENAME:TAG" --platform managed --port=3000 --region=us-east1

myapp will be the name of your service on Cloud Run, you can replace it with whatever name you want --port 3000 allows exposing the port 3000, you can replace it with whatever port your app uses

We will be asked to allow unauthenticated invocations. By selecting yes, we will allow our app to be accessible through the URL that Google Cloud will output after the deployment is done.

Our service is now deployed 🚀! We now have a URL for our service. Now let's look at the commands to deploy and tag a revision.

Deploy and tag a revision

Let's run the following command to deploy a revision for our services (remember to replace the name, project ID, image name, and tag with yours!)

1
gcloud beta run deploy "myapp" --image "gcr.io/PROJECTID/IMAGENAME:TAG" --platform managed --revision-suffix=revision1 --port=3000 --region=us-east1

We now have a new revision for our service! This new revision uses the same Docker image and tag as our service. Eventually, we would want to deploy different versions of our app for each revision, which will result in each revision containing a change. We'll see in the next section how we can leverage Pull Request numbers and commit hashes to do that :smile.

One of the key element of the pipeline is tagging revisions: tagging a revision will let us have a unique URL for that revision.

If we have a service URL like https://myapp-abcdef123-ab.a.run.app, tagging it with "test" would give us the URL https://test---myapp-abcdef123-ab.a.run.app. To tag a revision we can run the following command:

1
gcloud beta run beta update-traffic "myapp" --update-tags test=revision1 --platform=managed --region=us-east1

We now have all the key commands to deploy a service and a revision on Cloud Run and get back a unique URL for each revision! The next step is my personal favorite: automation.

Automating the deployments

In this part, we will create a Github workflow to execute the commands we just looked at on every Pull Request event.

The key part of this implementation resides in the revision suffixes and tags:

  • Suffixes: revision suffixes must be unique thus every suffix will contain the PR number and the commit hash of the latest commit.
  • Tags: For revision tags, we can solely rely on the PR Number, we want the revision URL to remain constant, even when a commit is added to the PR.

Thus when deploying PR #1234  with the HEAD commit hash abcd123 the suffix of the revision will be pr-1234-abcd123 and the tag associated with that revision will be pr-1234.

The Github Workflow we're about to build is based on the Google Cloud Platform Github Actions, and more particularly we will implement a workflow that is similar to their Cloud Run Github Workflow example. I invite you to follow the README in this repository before continuing, it details how to:

  • Create a service account
  • Set up the service account's key and name as secrets of your project's Github repository.

I will use the same secret labels that they use in their workflow to make things easier for you to follow 😊.

In this part, we'll use a service account as the account that will run our commnands in the automated pipeline. This type of account is more suited than user accounts for that kind of tasks.

Here are some links you might be interested in to get yourself familiar with service accounts which are mentioned in the Google Cloud Platform Cloud Run example I linked above:

Considering that we now have a service account created and its key and name set as a secret of our Github repository, let's look at each step of the workflow on their own before looking at the entire pipeline:

  • First, we have to set up our workflow to run on every pull requests against our main branch:
1
name: Preview Deployment
2
3
on:
4
pull_request:
5
branches:
6
- "main"
7
...
  • Run the checkout action and setup node action:
1
...
2
steps:
3
- name: Checkout Commit
4
uses: actions/checkout@v2
5
with:
6
ref: ${{ github.event.pull_request.head.sha }}
7
- name: Use Node.js ${{ matrix.node-version }}
8
uses: actions/setup-node@v1
9
with:
10
node-version: ${{ matrix.node-version }}
11
...
  • Then we need to install and configure the GCloud SDK and beta components using our service account name and secret key:
1
...
2
- name: Setup Google Cloud SDK
3
uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
4
with:
5
project_id: ${{ secrets.PROJECTID }}
6
service_account_key: ${{ secrets.RUN_SA_KEY }}
7
export_default_credentials: true
8
- name: Install Google Cloud SDK Beta Components
9
run: gcloud components install beta
10
...
  • Let's not forget to configure Docker, as we showed earlier, to be able to push on GCR
1
...
2
- name: Setup Docker for GCR
3
run: gcloud auth configure-docker
4
...
  • Build and Push our Docker image using the PR number as a tag:
1
...
2
- name: Build Docker Image
3
run: docker build -t gcr.io/${secrets.PROJECTID}/IMAGENAME:${{github.event.number}}
4
- name: Push Docker Image To GCR
5
run: docker push gcr.io/${secrets.PROJECTID}/IMAGENAME:${{github.event.number}}
6
...
  • Get the commit hash of the HEAD commit of this PR. This is necessary because every revision suffix must be unique, and commit hashes are very handy to generate unique strings 😊:
1
...
2
- name: Get HEAD Commit Hash
3
id: commit
4
run: echo "::set-output name=hash::$(git rev-parse --short HEAD)"
5
...
  • Deploy a new revision on Cloud Run. 

Before running the pipeline for the first time, we will have to deploy our service manually beforehand to have what I'd call a "base revision". The workflow will only deploy new revisions for that service.

To do so, you can head to the Google Cloud Run UI and create a service with the same name you'll end up using in your automated pipeline or if you set up the Google Cloud SDK on your local machine, you can run the deploy service command we saw in the previous section of this post.

1
...
2
- name: Deploy Revision On Cloud Run
3
run: gcloud beta run deploy "myapp" --image "gcr.io/${secrets.PROJECTID}/IMAGENAME:${{github.event.number}}" --no-traffic --platform managed --revision-suffix=${{github.event.number}}-${{steps.commit.outputs.hash}} --port=3000 --region=us-east1
4
...
  • Tag the revision:
1
...
2
- name: Tag Revision On Cloud Run
3
run: gcloud beta run services update-traffic "myapp" --update-tags pr-${{github.event.number}}=myapp-${{github.event.number}}-${{steps.commit.outputs.hash}} --platform=managed --region=us-east1
4
...
  • Post the comment on the PR containing the URL! This will let your reviewers know how to access the revision that's just been deployed. I used the add-pr-comment Github Action. You could use any other action or even build your own (!), as long as you can pass your revision URL as an argument:
1
...
2
- name: Post PR comment with preview deployment URL
3
uses: mshick/add-pr-comment@v1
4
env:
5
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6
with:
7
message: |
8
Successfully deployed preview revision at https://pr-${{github.event.number}}---myapp-abcdef123-ab.a.run.app
9
allow-repeats: false
10
...

Here's how the full workflow file looks like:

Preview Deployment Github Workflow

yaml
1
name: Preview Deployment
2
3
on:
4
pull_request:
5
branches:
6
- "main"
7
8
jobs:
9
deploy-to-cloud-run:
10
runs-on: ubuntu-20.04
11
strategy:
12
matrix:
13
node-version: [12.x]
14
steps:
15
- name: Checkout Commit
16
uses: actions/checkout@v2
17
with:
18
ref: ${{ github.event.pull_request.head.sha }}
19
- name: Use Node.js ${{ matrix.node-version }}
20
uses: actions/setup-node@v1
21
with:
22
node-version: ${{ matrix.node-version }}
23
- name: Setup Google Cloud SDK
24
uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
25
with:
26
project_id: ${{ secrets.PROJECTID }}
27
service_account_key: ${{ secrets.RUN_SA_KEY }}
28
export_default_credentials: true
29
- name: Install Google Cloud SDK Beta Components
30
run: gcloud components install beta
31
- name: Setup Docker for GCR
32
run: gcloud auth configure-docker
33
- name: Build Docker Image
34
run: docker build -t gcr.io/${secrets.PROJECTID}/IMAGENAME:${{github.event.number}}
35
- name: Push Docker Image To GCR
36
run: docker push gcr.io/${secrets.PROJECTID}/IMAGENAME:${{github.event.number}}
37
- name: Get HEAD Commit Hash
38
id: commit
39
run: echo "::set-output name=hash::$(git rev-parse --short HEAD)"
40
- name: Deploy Revision On Cloud Run
41
run: gcloud beta run deploy "myapp" --image "gcr.io/${secrets.PROJECTID}/IMAGENAME:${{github.event.number}}" --no-traffic --platform managed --revision-suffix=${{github.event.number}}-${{steps.commit.outputs.hash}} --port=3000 --region=us-east1
42
- name: Tag Revision On Cloud Run
43
run: gcloud beta run services update-traffic "myapp" --update-tags pr-${{github.event.number}}=myapp-${{github.event.number}}-${{steps.commit.outputs.hash}} --platform=managed --region=us-east1
44
- name: Post PR comment with preview deployment URL
45
uses: mshick/add-pr-comment@v1
46
env:
47
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48
with:
49
message: |
50
Successfully deployed preview revision at https://pr-${{github.event.number}}---myapp-abcdef123-ab.a.run.app
51
allow-repeats: false

We now have a fully functional preview deployment workflow! Now let's circle back to the first part and go through the checklist to see whether or not this automated preview deployment pipeline covers all the criteria we established:

  • automated: thanks to the workflow we just detailed above, our preview deployment service will automatically deploy our app on every PR against the main branch
  • easily accessible: the last step of our workflow covers that as it will post the URL of a given deployment as a PR comment.

I tried to have the URL of the deployment replace the "details" URL of the Github Workflow like Netlify does. Sadly that's not available for Github actions alone, for that, I'd have had to build a Github App  which was more complicated to put together than a workflow.

  • fast: it only takes a few minutes to build and ship our app! Additionally, we leveraged multi-stage build to make the Docker Image of our app lighter which speeds up a bit the workflow when it comes to pushing the image to GCR.
  • self-actualized: for every new commit, the workflow will be executed, plus, thanks to the way we tag our revisions, the URL will remain constant through changes for a given PR!
  • running in a consistent environment: each revision is built following the same recipe: the Dockerfile we introduced in the second part!

I had a lot of fun building this (I'm a big fan of automation!) and I hope you liked this post and that it will inspire you to build more automation for your team to make it ship amazing things even faster 🚀! If you and your team are also in the process of establishing other elements of a CI/CD pipeline on top of the one we just saw, I'd recommend checking out The little guide to CI/CD for frontend developers that summarizes everything I know about CI/CD that can make team unstoppable!

Fetching Replies...

Do you have any questions, comments or simply wish to contact me privately? Don’t hesitate to shoot me a DM on Twitter.


Have a wonderful day.
Maxime


© 2020 Maxime Heckel —— Made in SF. Polished in NY.