In this codelab, you'll learn how to deploy a full-stack application with a database to Google Cloud 👉 Cloud Run. Cloud Run is a fully managed platform that enables you to run your code directly on Google's scalable infrastructure. You'll use the Cloud SQL Node.js connector to connect a Node.js backend to a Cloud SQL for PostgreSQL database, and an Angular frontend to interact with the backend.
The architecture of the application consists of the following components:
Before you begin, ensure you have a Google Account.
gcloud projects list | awk '/PROJECT_ID/{print $2}'
gcloud config set project PROJECT_ID
Replace PROJECT_ID
with your project id. For example:gcloud config set project my-project-id
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
, double check your project ID and try to run the gcloud config set project
command again.gcloud config get-value project
This should return your project id.You need to enable the following Google Cloud APIs:
Run the following command in the Cloud Shell terminal to enable these APIs:
gcloud services enable \
sqladmin.googleapis.com \
run.googleapis.com \
artifactregistry.googleapis.com \
cloudbuild.googleapis.com
After a few moments, you should see a message indicating that each service has been successfully enabled.
Similar to this:
Operation "operations/acf.p2-73d90d00-47ee-447a-b600" finished successfully.
A service account in Google Cloud is a special type of Google account that an application or a virtual machine (VM) can use to make authorized API calls. Unlike user accounts, which represent a human user, service accounts represent an application or a service. The provided commands set up a service account with specific permissions to allow a Cloud Run application to securely connect to and interact with a Cloud SQL database.
Create and configure a Google Cloud service account to be used by Cloud Run so that it has the correct permissions to connect to Cloud SQL.
gcloud iam service-accounts create
command as follows to create a new service account named quickstart-service-account
:gcloud iam service-accounts create quickstart-service-account \
--display-name="Quickstart Service Account"
gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
--member="serviceAccount:quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
--role="roles/cloudsql.client"
gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
--member="serviceAccount:quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
--role="roles/cloudsql.instanceUser"
gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
--member="serviceAccount:quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
--role="roles/logging.logWriter"
Why These Roles Are Needed
These commands ensure that the service account has the necessary permissions to interact with Cloud SQL and log activities, which is essential for applications running on Cloud Run that need to access a Cloud SQL database.
gcloud sql instances create quickstart-instance \
--database-version=POSTGRES_14 \
--cpu=4 \
--memory=16GB \
--region=us-central1 \
--database-flags=cloudsql.iam_authentication=on
This command may take a few minutes to complete.us-central1 region
with 4 CPUs
and 16GB of memory
.cloudsql.iam_authentication=on
flag enables IAM-based authentication for secure access.Creating Cloud SQL instance for POSTGRES_14...done.
Created [https://sqladmin.googleapis.com/sql/v1beta4/projects/principal-fact-471601-n1/instances/quickstart-instance].
NAME: quickstart-instance
DATABASE_VERSION: POSTGRES_14
LOCATION: us-central1-c
TIER: db-custom-4-16384
PRIMARY_ADDRESS: 34.63.128.0
PRIVATE_ADDRESS: -
STATE: RUNNABLE
gcloud sql databases create quickstart_db \
--instance=quickstart-instance
quickstart_db
in the quickstart-instance
.gcloud sql users create quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam \
--instance=quickstart-instance \
--type=cloud_iam_service_account
task-app
, use the command:npx --yes @angular/cli@20 new task-app \
--minimal \
--inline-template \
--inline-style \
--ssr \
--defaults
npx
command runs the Angular CLI without needing to install it.n
when prompted with the following message: Would you like to share pseudonymous usage data about this project with the Angular Team ...
This command creates a minimal Angular project with server-side rendering (SSR) and server-side routing enabled. Server-side rendering (SSR) is a technique where the server generates a fully rendered HTML page for each user request and sends it to the browser. This provides faster initial load times and better search engine optimization (SEO) compared to client-side rendering (where JavaScript builds the page in the browser). Server-side routing ensures that navigation between pages is handled on the server, improving performance and enabling deep linking.task-app
:cd task-app
npm install pg \
@google-cloud/cloud-sql-connector \
google-auth-library
pg
library is used to interact with your PostgreSQL database.@google-cloud/cloud-sql-connector
library provides a way to connect to Cloud SQL instances securely.google-auth-library
is used for authenticating requests to Google Cloud services.@types/pg
package as a development dependency:npm install --save-dev @types/pg
server.ts
file in the Cloud Shell Editor. Navigate to the src
folder and locate the server.ts
file.cloudshell edit src/server.ts
server.ts
file.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 pg from 'pg';
import { AuthTypes, Connector } from '@google-cloud/cloud-sql-connector';
import { GoogleAuth } from 'google-auth-library';
const auth = new GoogleAuth();
const { Pool } = pg;
type Task = {
id: string;
title: string;
status: 'IN_PROGRESS' | 'COMPLETE';
createdAt: number;
};
const projectId = await auth.getProjectId();
const connector = new Connector();
const clientOpts = await connector.getOptions({
instanceConnectionName: `${projectId}:us-central1:quickstart-instance`,
authType: AuthTypes.IAM,
});
const pool = new Pool({
...clientOpts,
user: `quickstart-service-account@${projectId}.iam`,
database: 'quickstart_db',
});
const tableCreationIfDoesNotExist = async () => {
await pool.query(`CREATE TABLE IF NOT EXISTS tasks (
id SERIAL NOT NULL,
created_at timestamp NOT NULL,
status VARCHAR(255) NOT NULL default 'IN_PROGRESS',
title VARCHAR(1024) NOT NULL,
PRIMARY KEY (id)
);`);
}
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) => {
await tableCreationIfDoesNotExist();
const { rows } = await pool.query(`SELECT id, created_at, status, title FROM tasks ORDER BY created_at DESC LIMIT 100`);
res.send(rows);
});
app.post('/api/tasks', async (req, res) => {
const newTaskTitle = req.body.title;
if (!newTaskTitle) {
res.status(400).send("Title is required");
return;
}
await tableCreationIfDoesNotExist();
await pool.query(`INSERT INTO tasks(created_at, status, title) VALUES(NOW(), 'IN_PROGRESS', $1)`, [newTaskTitle]);
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 tableCreationIfDoesNotExist();
await pool.query(
`UPDATE tasks SET status = $1, title = $2 WHERE id = $3`,
[task.status, task.title, task.id]
);
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 tableCreationIfDoesNotExist();
await pool.query(`DELETE FROM tasks WHERE id = $1`, [task.id]);
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);
tableCreationIfDoesNotExist
function ensures that the tasks table is created if it doesn't already exist.src
folder and open the app.ts
filecloudshell edit src/app.ts
app.ts
file.app.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',
imports: [FormsModule],
template: `
<main class="container">
<h1>{{ title() }}</h1>
<section class="task-input">
<input
type="text"
placeholder="New Task Title"
[(ngModel)]="newTaskTitle"
(keyup.enter)="addTask()"
/>
<button (click)="addTask()">Add new task</button>
</section>
<table class="task-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 class="task-title" [class.complete]="isComplete">{{ task.title }}</td>
<td class="task-status">{{ task.status }}</td>
<td>
<button (click)="deleteTask(task)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</main>
`,
styles: [
`
:host {
display: block;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
background-color: #f0f2f5;
color: #333;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 3rem;
font-weight: bold;
color: #1a202c;
text-align: center;
margin-bottom: 2rem;
}
.task-input {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.task-input input {
flex-grow: 1;
padding: 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 0.375rem;
font-size: 1rem;
}
.task-input button, .task-table button {
padding: 0.75rem 1.5rem;
background-color: #4299e1;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s;
}
.task-input button:hover, .task-table button:hover {
background-color: #3182ce;
}
.task-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.task-table td {
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.task-title.complete {
text-decoration: line-through;
color: #a0aec0;
}
`,
],
})
export class App {
newTaskTitle = '';
tasks = signal<Task[]>([]);
readonly title = signal('to-do-tracker');
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.
Click Open Terminal to open the Cloud Shell terminal and ensure you are in the task-app
directory. If you are not, navigate to it using:
cd ~/task-app
gcloud run deploy to-do-tracker \
--region=us-central1 \
--source=. \
--service-account="quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
--allow-unauthenticated
Cloud Run uses Cloud Native Buildpacks to build a Docker image from your source code. This process requires no Dockerfile and sets permissions via the service account, allowing unauthenticated access.--region=us-central1
: Specifies the region where the service will be deployed.--source=.
: Deploys the application from the current directory.--service-account
: Specifies the service account to use for the deployment. Use the quickstart-service-account
created earlier.--allow-unauthenticated
: Allows public access to the application.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 Service URL to see your application in action. Every time you visit the URL or refresh the page, you will see the task app. 👇
Use the application to add, update, and delete tasks. Verify that the backend and database are working as expected.
You can use tools like Postman or curl to test the API endpoints directly.
You have successfully deployed a full-stack Angular application to Google Cloud Run with a Cloud SQL backend. This application allows you to manage tasks and will scale automatically based on demand.
In this lab, you have learned how to do the following:
Cloud SQL does not have a free tier and will charge you if you continue to use it. To avoid incurring unnecessary costs, delete the Cloud project after completing the codelab.
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.
To delete the entire project and stop all billing:
gcloud projects delete ${GOOGLE_CLOUD_PROJECT}
If you prefer not to delete the entire project, you can delete individual resources:
gcloud sql instances delete quickstart-instance --quiet
gcloud run services delete to-do-tracker --region=us-central1 --quiet
rm -rf ~/task-app
sudo rm -rf $HOME
In the Cloud Shell menu, click More > Restart. Confirm the restart to provision a new VM and reset the home directory to its default state.I hope you found this codelab helpful in understanding how to deploy a full-stack Angular application to Google Cloud Run with a Cloud SQL backend.
I value your feedback! If you have any suggestions or encounter issues, please let us know by submitting feedback at GitHub Issues.