How to automatically deploy your changes from GitHub to Cloud Run using Cloud Build

1. Introduction

Overview

In this codelab, you'll configure Cloud Run to automatically build and deploy new versions of your application whenever you push your source code changes to a GitHub repository.

This demo application saves user data to firestore, however, only a partial amount of the data is saved properly. You will configure continuous deployments such that when you push a bug fix to your GitHub repository, you will automatically see the fix become available in a new revision.

What you'll learn

  • Write an Express web application with Cloud Shell Editor
  • Connect your GitHub account to Google Cloud for continuous deployments
  • Automatically deploy your application to Cloud Run
  • Learn how to use HTMX and TailwindCSS

2. Setup and Requirements

Prerequisites

  • You have a GitHub account and are familiar with creating and pushing code to repositories.
  • You are logged into the Cloud Console.
  • You have previously deployed a Cloud Run service. For example, you can follow the deploy a web service from source code quickstart to get started.

Activate Cloud Shell

  1. From the Cloud Console, click Activate Cloud Shell d1264ca30785e435.png.

cb81e7c8e34bc8d.png

If this is your first time starting Cloud Shell, you're presented with an intermediate screen describing what it is. If you were presented with an intermediate screen, click Continue.

d95252b003979716.png

It should only take a few moments to provision and connect to Cloud Shell.

7833d5e1c5d18f54.png

This virtual machine is loaded with all the development tools needed. It offers a persistent 5 GB home directory and runs in Google Cloud, greatly enhancing network performance and authentication. Much, if not all, of your work in this codelab can be done with a browser.

Once connected to Cloud Shell, you should see that you are authenticated and that the project is set to your project ID.

  1. Run the following command in Cloud Shell to confirm that you are authenticated:
gcloud auth list

Command output

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Run the following command in Cloud Shell to confirm that the gcloud command knows about your project:
gcloud config list project

Command output

[core]
project = <PROJECT_ID>

If it is not, you can set it with this command:

gcloud config set project <PROJECT_ID>

Command output

Updated property [core/project].

3. Enable APIs and Set Environment Variables

Enable APIs

This codelab requires using the following APIs. You can enable those APIs by running the following command:

gcloud services enable run.googleapis.com \
    cloudbuild.googleapis.com \
    firestore.googleapis.com \
    iamcredentials.googleapis.com

Setup environment variables

You can set environment variables that will be used throughout this codelab.

REGION=<YOUR-REGION>
PROJECT_ID=<YOUR-PROJECT-ID>
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
SERVICE_ACCOUNT="firestore-accessor"
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com

4. Create a service account

This service account will be used by Cloud Run to call the Vertex AI Gemini API. This service account will also have permissions to read and write to Firestore and read secrets from Secret Manager.

First, create the service account by running this command:

gcloud iam service-accounts create $SERVICE_ACCOUNT \
  --display-name="Cloud Run access to Firestore"

Now, grant the service account read and write access to Firestore.

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/datastore.user

5. Create and configure a Firebase project

  1. In the Firebase console, click Add project.
  2. Enter <YOUR_PROJECT_ID> to add Firebase to one of your existing Google Cloud projects
  3. If prompted, review and accept the Firebase terms.
  4. Click Continue.
  5. Click Confirm Plan to confirm the Firebase billing plan.
  6. It is optional to Enable Google Analytics for this codelab.
  7. Click Add Firebase.
  8. When the project has been created, click Continue.
  9. From the Build menu, click Firestore database.
  10. Click Create database.
  11. Choose your region from the Location drop-down, then click Next.
  12. Use the default Start in production mode, then click Create.

6. Write the application

First, create a directory for the source code and cd into that directory.

mkdir cloud-run-github-cd-demo && cd $_

Then, create a package.json file with the following content:

{
  "name": "cloud-run-github-cd-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node app.js",
    "nodemon": "nodemon app.js",
    "tailwind-dev": "npx tailwindcss -i ./input.css -o ./public/output.css --watch",
    "tailwind": "npx tailwindcss -i ./input.css -o ./public/output.css",
    "dev": "npm run tailwind && npm run nodemon"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/firestore": "^7.3.1",
    "axios": "^1.6.7",
    "express": "^4.18.2",
    "htmx.org": "^1.9.10"
  },
  "devDependencies": {
    "nodemon": "^3.1.0",
    "tailwindcss": "^3.4.1"
  }
}

First, create an app.js source file with the content below. This file contains the entry point for the service and contains the main logic for the app.

const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
const path = require("path");
const { get } = require("axios");

const { Firestore } = require("@google-cloud/firestore");
const firestoreDb = new Firestore();

const fs = require("fs");
const util = require("util");
const { spinnerSvg } = require("./spinnerSvg.js");

const service = process.env.K_SERVICE;
const revision = process.env.K_REVISION;

app.use(express.static("public"));

app.get("/edit", async (req, res) => {
    res.send(`<form hx-post="/update" hx-target="this" hx-swap="outerHTML">
                <div>
  <p>
    <label>Name</label>    
    <input class="border-2" type="text" name="name" value="Cloud">
    </p><p>
    <label>Town</label>    
    <input class="border-2" type="text" name="town" value="Nibelheim">
    </p>
  </div>
  <div class="flex items-center mr-[10px] mt-[10px]">
  <button class="btn bg-blue-500 text-white px-4 py-2 rounded-lg text-center text-sm font-medium mr-[10px]">Submit</button>
  <button class="btn bg-gray-200 text-gray-800 px-4 py-2 rounded-lg text-center text-sm font-medium mr-[10px]" hx-get="cancel">Cancel</button>  
                ${spinnerSvg} 
                </div>
  </form>`);
});

app.post("/update", async function (req, res) {
    let name = req.body.name;
    let town = req.body.town;
    const doc = firestoreDb.doc(`demo/${name}`);

    //TODO: fix this bug
    await doc.set({
        name: name
        /* town: town */
    });

    res.send(`<div hx-target="this" hx-swap="outerHTML" hx-indicator="spinner">
                <p>
                <div><label>Name</label>: ${name}</div>
                </p><p>
                <div><label>Town</label>: ${town}</div>
                </p>
                <button
                    hx-get="/edit"
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium mt-[10px]"
                >
                    Click to update
                </button>               
            </div>`);
});

app.get("/cancel", (req, res) => {
    res.send(`<div hx-target="this" hx-swap="outerHTML">
                <p>
                <div><label>Name</label>: Cloud</div>
                </p><p>
                <div><label>Town</label>: Nibelheim</div>
                </p>
                <div>
                <button
                    hx-get="/edit"
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium mt-[10px]"
                >
                    Click to update
                </button>                
                </div>
            </div>`);
});

const port = parseInt(process.env.PORT) || 8080;
app.listen(port, async () => {
    console.log(`booth demo: listening on port ${port}`);

    //serviceMetadata = helper();
});

app.get("/helper", async (req, res) => {
    let region = "";
    let projectId = "";
    let div = "";

    try {
        // Fetch the token to make a GCF to GCF call
        const response1 = await get(
            "http://metadata.google.internal/computeMetadata/v1/project/project-id",
            {
                headers: {
                    "Metadata-Flavor": "Google"
                }
            }
        );

        // Fetch the token to make a GCF to GCF call
        const response2 = await get(
            "http://metadata.google.internal/computeMetadata/v1/instance/region",
            {
                headers: {
                    "Metadata-Flavor": "Google"
                }
            }
        );

        projectId = response1.data;
        let regionFull = response2.data;
        const index = regionFull.lastIndexOf("/");
        region = regionFull.substring(index + 1);

        div = `
        <div>
        This created the revision <code>${revision}</code> of the 
        Cloud Run service <code>${service}</code> in <code>${region}</code>
        for project <code>${projectId}</code>.
        </div>`;
    } catch (ex) {
        // running locally
        div = `<div> This is running locally.</div>`;
    }

    res.send(div);
});

Create a file called spinnerSvg.js

module.exports.spinnerSvg = `<svg id="spinner" alt="Loading..."
                    class="htmx-indicator animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500"
                    xmlns="http://www.w3.org/2000/svg"
                    fill="none"
                    viewBox="0 0 24 24"
                >
                    <circle
                        class="opacity-25"
                        cx="12"
                        cy="12"
                        r="10"
                        stroke="currentColor"
                        stroke-width="4"
                    ></circle>
                    <path
                        class="opacity-75"
                        fill="currentColor"
                        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                    ></path>
                </svg>`;

Create a input.css file for tailwindCSS

@tailwind base;
@tailwind components;
@tailwind utilities;

And create the tailwind.config.js file for tailwindCSS

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: ["./**/*.{html,js}"],
    theme: {
        extend: {}
    },
    plugins: []
};

And create a .gitignore file.

node_modules/

npm-debug.log
coverage/

package-lock.json

.DS_Store

Now, create a new public directory.

mkdir public
cd public

And within that public directory, create the index.html file for the front end, which will use htmx.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
        />
        <script
            src="https://unpkg.com/htmx.org@1.9.10"
            integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
            crossorigin="anonymous"
        ></script>

        <link href="./output.css" rel="stylesheet" />
        <title>Demo 1</title>
    </head>
    <body
        class="font-sans bg-body-image bg-cover bg-center leading-relaxed"
    >
        <div class="container max-w-[700px] mt-[50px] ml-auto mr-auto">
            <div class="hero flex items-center">                    
                <div class="message text-base text-center mb-[24px]">
                    <h1 class="text-2xl font-bold mb-[10px]">
                        It's running!
                    </h1>
                    <div class="congrats text-base font-normal">
                        Congratulations, you successfully deployed your
                        service to Cloud Run. 
                    </div>
                </div>
            </div>

            <div class="details mb-[20px]">
                <p>
                    <div hx-trigger="load" hx-get="/helper" hx-swap="innerHTML" hx-target="this">Hello</div>                   
                </p>
            </div>

            <p
                class="callout text-sm text-blue-700 font-bold pt-4 pr-6 pb-4 pl-10 leading-tight"
            >
                You can deploy any container to Cloud Run that listens for
                HTTP requests on the port defined by the
                <code>PORT</code> environment variable. Cloud Run will
                scale automatically based on requests and you never have to
                worry about infrastructure.
            </p>

            <h1 class="text-2xl font-bold mt-[40px] mb-[20px]">
                Persistent Storage Example using Firestore
            </h1>
            <div hx-target="this" hx-swap="outerHTML">
                <p>
                <div><label>Name</label>: Cloud</div>
                </p><p>
                <div><label>Town</label>: Nibelheim</div>
                </p>
                <div>
                <button
                    hx-get="/edit"
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium mt-[10px]"
                >
                    Click to update
                </button>                
                </div>
            </div>

            <h1 class="text-2xl font-bold mt-[40px] mb-[20px]">
                What's next
            </h1>
            <p class="next text-base mt-4 mb-[20px]">
                You can build this demo yourself!
            </p>
            <p class="cta">
                <button
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-center text-sm font-medium"
                >
                    VIEW CODELAB
                </button>
            </p> 
        </div>
   </body>
</html>

7. Run the application locally

In this section, you'll run the application locally to confirm there is a bug in the application when the user tries to save data.

First, you'll either need to have the Datastore User role to access Firestore (if using your identity for authentication, e.g. you're running in Cloud Shell) or you can impersonate the user account previously created.

Using ADC when running locally

If you are running in Cloud Shell, you are already running on a Google Compute Engine virtual machine. Your credentials associated with this virtual machine (as shown by running gcloud auth list) will automatically be used by Application Default Credentials (ADC), so it is not necessary to use the gcloud auth application-default login command. However, your identity will still need the Datastore User role. You can skip down to the section Run the app locally.

However, if you are running on your local terminal (i.e. not in Cloud Shell), you will need to use Application Default Credentials to authenticate to Google APIs. You can either 1) login using your credentials (provided you have the Datastore User role) or 2) you can login by impersonating the service account used in this codelab.

Option 1) Using your credentials for ADC

If you want to use your credentials, you can first run gcloud auth list to verify how you are authenticated in gcloud. Next, you may need to grant your identity the Vertex AI User role. If your identity has the Owner role, you already have this Datastore User user role. If not, you can run this command to grant your identity Vertex AI user role and the Datastore User role.

USER=<YOUR_PRINCIPAL_EMAIL>

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/datastore.user

Then run the following command

gcloud auth application-default login

Option 2) Impersonating a Service Account for ADC

If you want to use the service account created in this codelab, your user account will need to have the Service Account Token Creator role. You can obtain this role by running the following command:

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/iam.serviceAccountTokenCreator

Next, you'll run the following command to use ADC with the service account

gcloud auth application-default login --impersonate-service-account=$SERVICE_ACCOUNT_ADDRESS

Run the app locally

Next, make sure you are in the root directory cloud-run-github-cd-demo for your codelab.

cd .. && pwd

Now, you'll install dependencies.

npm install

Lastly, you can start the app by running the following script. This script will also generate the output.css file from tailwindCSS.

npm run dev

Now open your web browser to http://localhost:8080. If you are in Cloud Shell, you can open the website by opening the Web Preview button and selecting Preview Port 8080.

web preview - preview on port 8080 button

Enter text for the name and town input fields and hit save. Then refresh the page. You'll notice that the town field did not persist. You will fix this bug in the subsequent section.

Stop the express app from running locally (e.g. Ctrl^c on MacOS).

8. Create a GitHub Repository

In your local directory, create a new repo with main as the default branch name.

git init
git branch -M main

Commit the current codebase that contains the bug. You will fix the bug after continuous deployment is configured.

git add .
git commit -m "first commit for express application"

Go to GitHub and create an empty repository that is either private to you or public. This codelab recommends naming your repository cloud-run-auto-deploy-codelab To create an empty repository, you will leave all the default settings unchecked or set to none such that no content will be in the repo by default when created, e.g.

GitHub default settings

If you completed this step correctly, you'll see the following instructions on the empty repository page:

Empty GitHub repo instructions

You will follow the push an existing repository from the command line instructions by running the following commands:

First, add the remote repository by running

git remote add origin <YOUR-REPO-URL-PER-GITHUB-INSTRUCTIONS>

then push the main branch to the upstream repo.

git push -u origin main

9. Setup Continuous Deployment

Now that you have code in a GitHub, you can setup continuous deployment. Go to the Cloud Console for Cloud Run.

  • Click Create a Service
  • Click Continuously deploy from a repository
  • Click SET UP CLOUD BUILD.
  • Under Source repository
    • Select GitHub as the Repository Provider
    • Click Manage connected repositories to configure Cloud Build access to the repo
    • Select your repository and click Next
  • Under Build Configuration
    • Leave Branch as ^main$
    • For Build Type, select Go, Node.js, Python, Java, .NET Core, Ruby or PHP via Google Cloud's buildpacks
  • Leave Build context directory as /
  • Click Save
  • Under Authentication
    • Click Allow unauthenticated invocations
  • Under Container(s), Volumes, Networking, Security
    • Under the Security tab, select the service account you created in an earlier step, e.g. Cloud Run access to Firestore
  • Click CREATE

This will deploy the Cloud Run service containing the bug that you will fix in the next section.

10. Fix the bug

Fix the bug in the code

In Cloud Shell Editor, ppen the app.js file and go the comment that says //TODO: fix this bug

change the following line from

 //TODO: fix this bug
    await doc.set({
        name: name
    });

to

//fixed town bug
    await doc.set({
        name: name,
        town: town
    });

Verify the fix by running

npm run start

and open your web browser. Save data again for town, and refresh. You'll see the newly entered town data has persisted correctly on refresh.

Now that you've verified your fix, you're ready to deploy it. First, commit the fix.

git add .
git commit -m "fixed town bug"

and then push it to the upstream repository on GitHub.

git push origin main

Cloud Build will automatically deploy your changes. You can go to the Cloud Console for your Cloud Run service to monitor deployment changes.

Verify the fix in production

Once the Cloud Console for your Cloud Run service shows a 2nd revision is now serving 100% traffic, e.g. https://console.cloud.google.com/run/detail/<YOUR_REGION>/<YOUR_SERVICE_NAME>/revisions, you can open the Cloud Run service URL in your browser and verify the newly entered town data is persisted after refreshing the page.

11. Congratulations!

Congratulations for completing the codelab!

We recommend reviewing the documentation Cloud Run and continuous deployment from git.

What we've covered

  • Write an Express web application with Cloud Shell Editor
  • Connect your GitHub account to Google Cloud for continuous deployments
  • Automatically deploy your application to Cloud Run
  • Learn how to use HTMX and TailwindCSS

12. Clean up

To avoid inadvertent charges, (for example, if the Cloud Run services are inadvertently invoked more times than your monthly Cloud Run invokement allocation in the free tier), you can either delete the Cloud Run or delete the project you created in Step 2.

To delete the Cloud Run service, go to the Cloud Run Cloud Console at https://console.cloud.google.com/run and delete the Cloud Run service you created in this codelab, e.g. delete the cloud-run-auto-deploy-codelab service.

If you choose to delete the entire project, you can go to https://console.cloud.google.com/cloud-resource-manager, select the project you created in Step 2, and choose Delete. If you delete the project, you'll need to change projects in your Cloud SDK. You can view the list of all available projects by running gcloud projects list.