Cloud Run + Cloud Build for Monorepos

Cloud Run is a "serverless container compute platform" running on Google Cloud. It is a surprisingly good product, even in it's beta stage.

Today, we'll be setting up Cloud Run and Cloud Build to run inside a monorepo with a Node.js back-end (as an API), with a Next.js front-end. We will deploy each package (server, web) as an individual Cloud Run service, with continuous delivery from GitHub and a domain attached.

I'm going to assume you have a package structure similar to:

packages/
  web/ # Next.js
    .next/ # Output
    package.json
    pages/
    src/
  server/ # Node.js
    config/
      developement.json
      staging.json
      production.json
    dist/ # Output
    package.json
    src/

APIs / IAM

Let's get started by enabling the required API:

Next, go to IAM and add the following roles to the Cloud Build Service Account:

  • Service Account User
  • Cloud Run Admin
  • Cloud KMS CryptoKey Decrypter
  • Cloud Build Service Account (should already be present)

KMS (Encryption)

We want to ship our packages/server/production.json file in our build configuration, without committing it to Git. To do this, we will use Cloud Key Management Service to only commit an encrypted version.

First, let's create a keyring and key:

# Create keyring (replace with custom keyring name or project ID)
gcloud kms keyrings create my-project-keyring \
  --location=global

gcloud kms keys create cloudbuild \
  --location=global \
  --keyring=my-keyring-name \
  --purpose=encryption

Next, let's encrypt our production configuration:

gcloud kms encrypt \
  --plaintext-file=packages/server/config/production.json \
  --ciphertext-file=packages/server/config/production.json.enc \
  --location=global \
  --keyring=my-project-keyring \
  --key=cloudbuild

We will now see a production.json.enc file. Make sure to commit this.

To speed things up, it's recommended to setup some scripts:

File:

scripts/kms
#!/usr/bin/env bash set -e FILES=( "packages/server/config/staging.json" "packages/server/config/production.json" ) for FILE in "${FILES[@]}" do gcloud kms $1 \ --plaintext-file=$FILE \ --ciphertext-file=$FILE.enc \ --location=global \ --keyring=my-project-keyring \ --key=cloudbuild done

Usage:

./scripts/kms encrypt
./scripts/kms decrypt

Docker

Server

We'll be using node-config for server configuration, with TypeScript (requires a build step).

Each package will be build inside a Docker container, ran on Cloud Build. We can easily test the Docker builds locally as we go.

We will be using the slim Node images. This alone halves the image size in most cases.

File:

packages/server/Dockerfile
# Specify Node version FROM node:lts-slim # Install build dependencies RUN apt update && apt install -y python build-essential WORKDIR /tmp/build ENV NODE_ENV=production # node-config environement variable ARG NODE_CONFIG_ENV ENV NODE_CONFIG_ENV ${NODE_CONFIG_ENV:-production} # Copy only required files for fetching dependencies COPY package.json yarn.lock /tmp/build/ RUN yarn --prod RUN mv /tmp/build/node_modules /tmp/node_modules # Copy the whole app in COPY . /tmp/build # Replace node_modules RUN rm -rf /tmp/build/node_modules && mv /tmp/node_modules /tmp/build/node_modules # Build the app RUN yarn build WORKDIR /usr/src/app # Copy only app runtime (required files for app to run) RUN cp -a /tmp/build/node_modules /tmp/build/dist /tmp/build/package.json /tmp/build/config /usr/src/app/ \ && rm -rf /tmp/build # Set environment variables ENV PORT=80 # Start app CMD yarn start EXPOSE 80

File:

packages/server/.dockerignore
Dockerfile node_modules dist

This Dockerfile is optimized for caching based on npm dependencies, and contains support for NODE_CONFIG_ENV.

We can build and run this container locally:

cd packages/server
docker build -t server .
docker run -p 8081:80 server

You can access http://localhost:8081 to test access to your container.

Web

Next, for the Next.js Docker setup.

File:

packages/web/Dockerfile
# Specify Node version FROM node:lts-slim RUN apt update && apt install -y python build-essential WORKDIR /tmp/build ENV NODE_ENV=production # Copy only required files for fetching dependencies COPY package.json yarn.lock /tmp/build/ RUN yarn --prod RUN mv /tmp/build/node_modules /tmp/node_modules # Copy the whole app in COPY . /tmp/build # Replace node_modules RUN rm -rf /tmp/build/node_modules /tmp/build/.next && mv /tmp/node_modules /tmp/build/node_modules # Build the app RUN yarn build WORKDIR /usr/src/app # Copy only app runtime (required files for app to run) RUN cp -a /tmp/build/node_modules /tmp/build/.next /tmp/build/package.json /usr/src/app/ \ && rm -rf /tmp/build # Set environment variables ENV PORT=80 # Start app CMD yarn start EXPOSE 80

File:

packages/web/.dockerignore
Dockerfile node_modules .next

Same deal here:

cd packages/web
docker build -t web .
docker run -p 8080:80 web

You can customize the Dockerfile to also copy a server.js or other configuration files, such as a .env

Cloud Build

Server

Next, let's create our Cloud Build configuration for the server.

File:

packages/server/cloudbuild.yaml
steps: # Decrypt our config for the current environment (production, staging, etc) - name: "gcr.io/cloud-builders/gcloud" args: [ "kms", "decrypt", "--ciphertext-file=${_PATH}/config/${_ENV}.json.enc", "--plaintext-file=${_PATH}/config/${_ENV}.json", "--location=global", "--keyring=${_KEYRING}", "--key=${_KEY}", ] # Pull last build for caching - name: "gcr.io/cloud-builders/docker" entrypoint: "bash" args: ["-c", "docker pull gcr.io/$PROJECT_ID/${_ENV}-server:latest || exit 0"] # Build new image - name: "gcr.io/cloud-builders/docker" args: [ "build", "-t", "gcr.io/$PROJECT_ID/${_ENV}-server", "--cache-from", "gcr.io/$PROJECT_ID/${_ENV}-server", "--build-arg", "NODE_CONFIG_ENV=${_ENV}", "${_PATH}", ] # Push new image - name: "gcr.io/cloud-builders/docker" args: ["push", "gcr.io/$PROJECT_ID/${_ENV}-server"] # Deploy to Cloud Run - name: "gcr.io/cloud-builders/gcloud" args: [ "beta", "run", "deploy", "${_ENV}-server", "--image", "gcr.io/$PROJECT_ID/${_ENV}-server", "--region", "us-central1", ] substitutions: _ENV: production _PATH: . _KEYRING: my-project-keyring _KEY: cloudbuild images: - "gcr.io/$PROJECT_ID/${_ENV}-server"

Also, don't forgot to add an ignore file for Cloud Build:

File:

packages/server/.gcloudignore
node_modules dist

Web

Next, let's do the same with the web.

File:

packages/web/cloudbuild.yaml
steps: # Pull last build for caching - name: "gcr.io/cloud-builders/docker" entrypoint: "bash" args: ["-c", "docker pull gcr.io/$PROJECT_ID/${_ENV}-web:latest || exit 0"] # Build new image - name: "gcr.io/cloud-builders/docker" args: [ "build", "-t", "gcr.io/$PROJECT_ID/${_ENV}-web", "--cache-from", "gcr.io/$PROJECT_ID/${_ENV}-web", "${_PATH}", ] # Push new image - name: "gcr.io/cloud-builders/docker" args: ["push", "gcr.io/$PROJECT_ID/${_ENV}-web"] # Deploy to Cloud Run - name: "gcr.io/cloud-builders/gcloud" args: [ "beta", "run", "deploy", "${_ENV}-web", "--image", "gcr.io/$PROJECT_ID/${_ENV}-web", "--region", "us-central1", ] substitutions: _ENV: production _PATH: . images: - "gcr.io/$PROJECT_ID/${_ENV}-web"

File:

packages/web/.gcloudignore
node_modules .next

Deploy

Now that we have our Cloud Build configuration done, we can go ahead and simply deploy our server:

# Deploy production from root of project
gcloud builds submit --config packages/server/cloudbuild.yaml --substitutions=_PATH=packages/server

# Deploy staging from root of project
gcloud builds submit --config packages/server/cloudbuild.yaml --substitutions=_PATH=packages/server,_ENV=staging

# Deploy production from packages/server
gcloud builds submit --config cloudbuild.yaml

And web:

# Deploy production from root of project
gcloud builds submit --config packages/web/cloudbuild.yaml --substitutions=_PATH=packages/web

# Deploy staging from root of project
gcloud builds submit --config packages/web/cloudbuild.yaml --substitutions=_PATH=packages/web,_ENV=staging

# Deploy production from packages/web
gcloud builds submit --config cloudbuild.yaml

CD / Domains

Our services should be deploy! Go in the Cloud Run section of the Google Cloud Console and you'll be able to see them running.

The last thing is enabling Cloud Build triggers, and domains.

You can view triggers under the trigger section of Cloud Build.

To add CD from GitHub:

  • Click "Add trigger"
  • Connect GitHub/other VCS
  • Select repo
  • Configure branch (for only master, set it to master)
  • Click to use Cloud Build configuration file
  • Enter path packages/server/cloudbuild.yaml
  • Add a substitution for _PATH to packages/server
  • Repeat for web (s/server/web)

Now, when you push, it will automatically trigger building. It is recommended to only use this with your staging/testing environment, not production.

Add another substitution for _ENV as staging to deploy to staging

For domains, go to the domain mapping section of Cloud Run to setup domains to point to your services.

Avatar

Who am I?

Hi, I'm @Cretezy (also known as Charles), a software developer. I write about programming, personal projects, and more. I also make YouTube videos.

See more articles here, or view the home page