Deploy a full stack Angular application to Cloud Run with Firestore using the Node.js Admin SDK

Deploy a full stack Angular application to Cloud Run with Firestore using the Node.js Admin SDK

About this codelab

subjectLast updated Apr 11, 2025
account_circleWritten by Luke Schlangen

1. Overview

Cloud Run is a fully managed platform that enables you to run your code directly on top of Google's scalable infrastructure. This Codelab will demonstrate how to connect a Angular application on Cloud Run to a Firestore database using the Node.js Admin SDK.

In this lab, you will learn how to:

  • Create a Firestore database
  • Deploy an application to Cloud Run that connects to your Firestore database

2. Prerequisites

  1. If you do not already have a Google account, you must create a Google account.
    • Use a personal account instead of a work or school account. Work and school accounts may have restrictions that prevent you from enabling the APIs needed for this lab.

3. Project setup

  1. Sign-in to the Google Cloud Console.
  2. Enable billing in the Cloud Console.
    • Completing this lab should cost less than $1 USD in Cloud resources.
    • You can follow the steps at the end of this lab to delete resources to avoid further charges.
    • New users are eligible for the $300 USD Free Trial.
  3. Create a new project or choose to reuse an existing project.

4. Open Cloud Shell Editor

  1. Navigate to Cloud Shell Editor
  2. If the terminal doesn't appear on the bottom of the screen, open it:
    • Click the hamburger menu Hamburger menu icon
    • Click Terminal
    • Click New TerminalOpen new terminal in Cloud Shell Editor
  3. In the terminal, set your project with this command:
    • Format:
      gcloud config set project [PROJECT_ID]
    • Example:
      gcloud config set project lab-project-id-example
    • If you can't remember your project id:
      • You can list all your project ids with:
        gcloud projects list | awk '/PROJECT_ID/{print $2}'
      Set project id in Cloud Shell Editor terminal
  4. If prompted to authorize, click Authorize to continue. Click to authorize Cloud Shell
  5. You should see this message:
    Updated property [core/project].
    
    If you see a WARNING and are asked Do you want to continue (Y/N)?, then you have likely entered the project ID incorrectly. Press N, press Enter, and try to run the gcloud config set project command again.

5. Enable APIs

In the terminal, enable the APIs:

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

If prompted to authorize, click Authorize to continue. Click to authorize Cloud Shell

This command may take a few minutes to complete, but it should eventually produce a successful message similar to this one:

Operation "operations/acf.p2-73d90d00-47ee-447a-b600" finished successfully.

6. Create Firestore Database

  1. Run the gcloud firestore databases create command to create a firestore database
    gcloud firestore databases create --location=nam5

7. Prepare Application

Prepare a Next.js application that responds to HTTP requests.

  1. To create a new Next.js project named task-app, use the command:
    npx --yes @angular/cli@19.2.5 new task-app \
        --minimal \
        --inline-template \
        --inline-style \
        --ssr \
        --server-routing \
        --defaults
  2. Change directory into task-app:
    cd task-app
  1. Install firebase-admin to interact with the Firestore database.
    npm install firebase-admin
  1. Open the server.ts file in Cloud Shell Editor:
    cloudshell edit src/server.ts
    An file should now appear in the top part of the screen. This is where you can edit this server.ts file. Show that code goes in the top section of the screen
  2. Delete the existing contents of the server.ts file.
  3. Copy the following code and paste it into the opened server.ts file:
    import {
      AngularNodeAppEngine,
      createNodeRequestHandler,
      isMainModule,
      writeResponseToNodeResponse,
    } from '@angular/ssr/node';
    import express from 'express';
    import { dirname, resolve } from 'node:path';
    import { fileURLToPath } from 'node:url';
    import { initializeApp, applicationDefault, getApps } from 'firebase-admin/app';
    import { getFirestore } from 'firebase-admin/firestore';

    type Task = {
      id: string;
      title: string;
      status: 'IN_PROGRESS' | 'COMPLETE';
      createdAt: number;
    };

    const credential = applicationDefault();

    // Only initialize app if it does not already exist
    if (getApps().length === 0) {
      initializeApp({ credential });
    }

    const db = getFirestore();
    const tasksRef = db.collection('tasks');

    const serverDistFolder = dirname(fileURLToPath(import.meta.url));
    const browserDistFolder = resolve(serverDistFolder, '../browser');

    const app = express();
    const angularApp = new AngularNodeAppEngine();

    app.use(express.json());

    app.get('/api/tasks', async (req, res) => {
      const snapshot = await tasksRef.orderBy('createdAt', 'desc').limit(100).get();
      const tasks: Task[] = snapshot.docs.map(doc => ({
        id: doc.id,
        title: doc.data()['title'],
        status: doc.data()['status'],
        createdAt: doc.data()['createdAt'],
      }));
      res.send(tasks);
    });

    app.post('/api/tasks', async (req, res) => {
      const newTaskTitle = req.body.title;
      if(!newTaskTitle){
        res.status(400).send("Title is required");
        return;
      }
      await tasksRef.doc().create({
        title: newTaskTitle,
        status: 'IN_PROGRESS',
        createdAt: Date.now(),
      });
      res.sendStatus(200);
    });

    app.put('/api/tasks', async (req, res) => {
      const task: Task = req.body;
      if (!task || !task.id || !task.title || !task.status) {
        res.status(400).send("Invalid task data");
        return;
      }
      await tasksRef.doc(task.id).set(task);
      res.sendStatus(200);
    });

    app.delete('/api/tasks', async (req, res) => {
      const task: Task = req.body;
      if(!task || !task.id){
        res.status(400).send("Task ID is required");
        return;
      }
      await tasksRef.doc(task.id).delete();
      res.sendStatus(200);
    });

    /**
    * Serve static files from /browser
    */
    app.use(
      express.static(browserDistFolder, {
        maxAge: '1y',
        index: false,
        redirect: false,
      }),
    );

    /**
    * Handle all other requests by rendering the Angular application.
    */
    app.use('/**', (req, res, next) => {
      angularApp
        .handle(req)
        .then((response) =>
          response ? writeResponseToNodeResponse(response, res) : next(),
        )
        .catch(next);
    });

    /**
    * Start the server if this module is the main entry point.
    * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
    */
    if (isMainModule(import.meta.url)) {
      const port = process.env['PORT'] || 4000;
      app.listen(port, () => {
        console.log(`Node Express server listening on http://localhost:${port}`);
      });
    }

    /**
    * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
    */
    export const reqHandler = createNodeRequestHandler(app);
  4. Open the angular.json file in Cloud Shell Editor:
    cloudshell edit angular.json
    We will now add the "externalDependencies": ["firebase-admin"] line to the angular.json file.
  5. Delete the existing contents of the angular.json file.
  6. Copy the following code and paste it into the opened angular.json file:
    {
      "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
      "version": 1,
      "newProjectRoot": "projects",
      "projects": {
        "task-app": {
          "projectType": "application",
          "schematics": {
            "@schematics/angular:component": {
              "inlineTemplate": true,
              "inlineStyle": true,
              "skipTests": true
            },
            "@schematics/angular:class": {
              "skipTests": true
            },
            "@schematics/angular:directive": {
              "skipTests": true
            },
            "@schematics/angular:guard": {
              "skipTests": true
            },
            "@schematics/angular:interceptor": {
              "skipTests": true
            },
            "@schematics/angular:pipe": {
              "skipTests": true
            },
            "@schematics/angular:resolver": {
              "skipTests": true
            },
            "@schematics/angular:service": {
              "skipTests": true
            }
          },
          "root": "",
          "sourceRoot": "src",
          "prefix": "app",
          "architect": {
            "build": {
              "builder": "@angular-devkit/build-angular:application",
              "options": {
                "outputPath": "dist/task-app",
                "index": "src/index.html",
                "browser": "src/main.ts",
                "polyfills": [
                  "zone.js"
                ],
                "tsConfig": "tsconfig.app.json",
                "assets": [
                  {
                    "glob": "**/*",
                    "input": "public"
                  }
                ],
                "styles": [
                  "src/styles.css"
                ],
                "scripts": [],
                "server": "src/main.server.ts",
                "outputMode": "server",
                "ssr": {
                  "entry": "src/server.ts"
                },
                "externalDependencies": ["firebase-admin"]
              },
              "configurations": {
                "production": {
                  "budgets": [
                    {
                      "type": "initial",
                      "maximumWarning": "500kB",
                      "maximumError": "1MB"
                    },
                    {
                      "type": "anyComponentStyle",
                      "maximumWarning": "4kB",
                      "maximumError": "8kB"
                    }
                  ],
                  "outputHashing": "all"
                },
                "development": {
                  "optimization": false,
                  "extractLicenses": false,
                  "sourceMap": true
                }
              },
              "defaultConfiguration": "production"
            },
            "serve": {
              "builder": "@angular-devkit/build-angular:dev-server",
              "configurations": {
                "production": {
                  "buildTarget": "task-app:build:production"
                },
                "development": {
                  "buildTarget": "task-app:build:development"
                }
              },
              "defaultConfiguration": "development"
            },
            "extract-i18n": {
              "builder": "@angular-devkit/build-angular:extract-i18n"
            }
          }
        }
      }
    }

"externalDependencies": ["firebase-admin"]

  1. Open the app.component.ts file in Cloud Shell Editor:
    cloudshell edit src/app/app.component.ts
    An existing file should now appear in the top part of the screen. This is where you can edit this app.component.ts file. Show that code goes in the top section of the screen
  2. Delete the existing contents of the app.component.ts file.
  3. Copy the following code and paste it into the opened app.component.ts file:
    import { afterNextRender, Component, signal } from '@angular/core';
    import { FormsModule } from '@angular/forms';

    type Task = {
      id: string;
      title: string;
      status: 'IN_PROGRESS' | 'COMPLETE';
      createdAt: number;
    };

    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [FormsModule],
      template: `
        <section>
          <input
            type="text"
            placeholder="New Task Title"
            [(ngModel)]="newTaskTitle"
            class="text-black border-2 p-2 m-2 rounded"
          />
          <button (click)="addTask()">Add new task</button>
          <table>
            <tbody>
              @for (task of tasks(); track task) {
                @let isComplete = task.status === 'COMPLETE';
                <tr>
                  <td>
                    <input
                      (click)="updateTask(task, { status: isComplete ? 'IN_PROGRESS' : 'COMPLETE' })"
                      type="checkbox"
                      [checked]="isComplete"
                    />
                  </td>
                  <td>{{ task.title }}</td>
                  <td>{{ task.status }}</td>
                  <td>
                    <button (click)="deleteTask(task)">Delete</button>
                  </td>
                </tr>
              }
            </tbody>
          </table>
        </section>
      `,
      styles: '',
    })
    export class AppComponent {
      newTaskTitle = '';
      tasks = signal<Task[]>([]);

      constructor() {
        afterNextRender({
          earlyRead: () => this.getTasks()
        });
      }

      async getTasks() {
        const response = await fetch(`/api/tasks`);
        const tasks = await response.json();
        this.tasks.set(tasks);
      }

      async addTask() {
        await fetch(`/api/tasks`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            title: this.newTaskTitle,
            status: 'IN_PROGRESS',
            createdAt: Date.now(),
          }),
        });
        this.newTaskTitle = '';
        await this.getTasks();
      }

      async updateTask(task: Task, newTaskValues: Partial<Task>) {
        await fetch(`/api/tasks`, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ ...task, ...newTaskValues }),
        });
        await this.getTasks();
      }

      async deleteTask(task: any) {
        await fetch('/api/tasks', {
          method: 'DELETE',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(task),
        });
        await this.getTasks();
      }
    }

The application is now ready to be deployed.

8. Deploy the application to Cloud Run

  1. Run the command below to deploy your application to Cloud Run:
    gcloud run deploy helloworld \
      --region=us-central1 \
      --source=.
  2. If prompted, press Y and Enter to confirm that you would like to continue:
    Do you want to continue (Y/n)? Y
    

After a few minutes, the application should provide a URL for you to visit.

Navigate to the URL to see your application in action. Every time you visit the URL or refresh the page, you will see the task app.

9. Congratulations

In this lab, you have learned how to do the following:

  • Create a Cloud SQL for PostgreSQL instance
  • Deploy an application to Cloud Run that connects to your Cloud SQL database

Clean up

Cloud SQL does not have a free tier and will charge you if you continue to use it. You can delete your Cloud project to avoid incurring additional charges.

While Cloud Run does not charge when the service is not in use, you might still be charged for storing the container image in Artifact Registry. Deleting your Cloud project stops billing for all the resources used within that project.

If you would like, delete the project:

gcloud projects delete $GOOGLE_CLOUD_PROJECT

You may also wish to delete unnecessary resources from your cloudshell disk. You can:

  1. Delete the codelab project directory:
    rm -rf ~/task-app
  2. Warning! This next action is can't be undone! If you would like to delete everything on your Cloud Shell to free up space, you can delete your whole home directory. Be careful that everything you want to keep is saved somewhere else.
    sudo rm -rf $HOME

Keep learning