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/.dockerignoreDockerfile 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/.dockerignoreDockerfile 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.yamlsteps: # 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/.gcloudignorenode_modules dist
Web
Next, let's do the same with the web.
File:
packages/web/cloudbuild.yamlsteps: # 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/.gcloudignorenode_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
topackages/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
asstaging
to deploy to staging
For domains, go to the domain mapping section of Cloud Run to setup domains to point to your services.
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