@MaximeHeckel

Build your own preview deployment service

August 25, 2020 / 15 min read

Last Updated: August 25, 2020

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

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:

  • ArrowAn icon representing an arrow
    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.
  • ArrowAn icon representing an arrow
    easily accessible: once deployed, your preview deployment should have a unique link that allows anyone to access that specific version of your frontend app.
  • ArrowAn icon representing an arrow
    fast: the whole process of getting your app deployed should last no more than a couple of minutes
  • ArrowAn icon representing an arrow
    self-actualized: each new change on the same branch or pull request should be deployed (preferably) on top of the other one
  • ArrowAn icon representing an arrow
    running in a consistent environment: each preview should run in the same replicated environment

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

  • ArrowAn icon representing an arrow
    their portability ensure that the environment of the preview deployments is constant.
  • ArrowAn icon representing an arrow
    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. ArrowAn icon representing an arrow
    Building our UI
  2. ArrowAn icon representing an arrow
    Building a Docker image
  3. ArrowAn icon representing an arrow
    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

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", "."]

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:

  • ArrowAn icon representing an arrow
    it's cheap to run the different revisions of the app: you only pay for the traffic on the revisions
  • ArrowAn icon representing an arrow
    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
  • ArrowAn icon representing an arrow
    it's fast: it only takes a few seconds to deploy services and revisions
  • ArrowAn icon representing an arrow
    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.

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

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

  • ArrowAn icon representing an arrow
    Create a service account
  • ArrowAn icon representing an arrow
    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.

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:

  • ArrowAn icon representing an arrow
    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'
  • ArrowAn icon representing an arrow
    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 }}
  • ArrowAn icon representing an arrow
    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
  • ArrowAn icon representing an arrow
    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
  • ArrowAn icon representing an arrow
    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}}
  • ArrowAn icon representing an arrow
    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)"
  • ArrowAn icon representing an arrow
    **Deploy a new revision on Cloud Run. **
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
  • ArrowAn icon representing an arrow
    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
  • ArrowAn icon representing an arrow
    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

Here's how the full workflow file looks like:

Preview Deployment Github Workflow

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:

  • ArrowAn icon representing an arrow
    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
  • ArrowAn icon representing an arrow
    easily accessible: the last step of our workflow covers that as it will post the URL of a given deployment as a PR comment.
  • ArrowAn icon representing an arrow
    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.
  • ArrowAn icon representing an arrow
    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!
  • ArrowAn icon representing an arrow
    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!

Liked this article? Share it with a friend on Bluesky or Twitter or support me to take on more ambitious projects to write about. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.

Have a wonderful day.

– Maxime

Do you want to increase your team's development velocity and collaboration? Preview Deployments are one of the key pieces for that, and this post details how to implement your own serverless deployment service based on Google Cloud Run.