This blueprint ships a full-stack serverless application on GCP: a React single-page app served from a CDN, a single serverless function that runs a Postgres query, and a managed PostgreSQL database. One pulumi up provisions everything and returns a URL you can open in a browser.
The blueprint covers:
- A static React + Vite bundle served from Google Cloud CDN in front of a global external HTTPS load balancer
- A Google Cloud Run functions (2nd gen) function with one route,
GET /api/random, that runsSELECT floor(random()*100)::int AS nagainst Cloud SQL for PostgreSQL - a URL map with two backends - a backend bucket for
/*and a serverless NEG pointing at the function for/api/*so the browser stays on a single origin and never sees CORS - Database credentials generated during
pulumi upand stored in Google Secret Manager - mounted into the Cloud Run function as a secret volume at/secrets/db-password - Private networking: the database runs on a Serverless VPC Access connector that lets the Cloud Run function reach the Cloud SQL private IP attached to the landing-zone private subnets, never exposed to the public internet
The blueprint consumes the Pulumi landing-zone stack through a StackReference so every downstream project reuses the same network and secret conventions.
Architecture on GCP
The Pulumi program is split into two reusable components plus an entrypoint that wires them together:
Database- provisions Cloud SQL for PostgreSQL at thedb-f1-microwith private IP on the landing-zone VPC tier, attaches it to the landing-zone private network, generates arandom.RandomPassword, and stores the DB URL in Google Secret Manager.Edge- creates the bucket for the SPA, the Google Cloud Run functions (2nd gen) that runs the API handler, and Google Cloud CDN in front of a global external HTTPS load balancer with a URL map with two backends - a backend bucket for/*and a serverless NEG pointing at the function for/api/*. The entrypoint wires these together and exports the public site URL.
The function runs Node.js 20 and ships as a bundled handler.js (esbuild) that imports pg and queries the database. The SPA is a plain Vite + React project with one page and one fetch("/api/random") call; because the API is same-origin, there is no CORS setup and no bearer token in the browser.
Scaling: Cloud Run functions scale to zero when idle. Cloud SQL does not scale compute to zero; the blueprint picks the cheapest db-f1-micro size and the cost + cleanup section shows how to stop the instance manually.
The GCP variant uses Google Cloud Run functions (2nd generation, which run on Cloud Run under the hood) for scale-to-zero HTTP compute, Cloud SQL for PostgreSQL on the db-f1-micro tier with private IP on the landing-zone VPC, Secret Manager for the DB password (mounted as a secret volume inside the function), and a global external HTTPS load balancer fronted by Cloud CDN for same-origin routing.
Prerequisites
- Pulumi account and CLI
- Node.js 20 or newer and npm
- a Google Cloud project where the Pulumi landing-zone stack is already deployed and you have permission to create Cloud SQL instances, Cloud Run functions, Cloud Storage buckets, global HTTPS load balancers, Secret Manager secrets, and related resources
- Node.js 20 or newer and npm for building the React SPA and the API bundle (both ship as Node packages regardless of the Pulumi language you pick)
- A deployed Pulumi landing-zone stack in the same GCP account; see the blueprint prerequisite banner at the top of this page for the link
Landing-zone inputs
The blueprint reads these outputs from your landing-zone/gcp stack through pulumi.StackReference:
networkName- the VPC the Cloud SQL instance and the Serverless VPC Access connector attach toprivateSubnetName- the subnet where the Serverless VPC Access connector runssecretsStore- the Secret Manager location the blueprint writes the DB password into
If the reference is missing or the output keys are not present, pulumi up fails fast at preview time. Point the stack reference at the right name with:
pulumi config set landingZoneStack <your-org>/landing-zone/<stack>
You do not have to re-deploy the landing-zone stack to iterate on this blueprint. Once the outputs exist, every change here is additive.
Download the blueprint
Use the Download blueprint button at the top of this page to grab the GCP zip for the Pulumi language you selected in the chooser. Each zip contains:
index.tsas the Pulumi entrypointcomponents/database.tsandcomponents/edge.tsas the reusable moduleswebsite/(React + Vite) andapi/(Node handler) as the application codepackage.jsonandtsconfig.jsonfor the root Pulumi project
Unzip, change into the directory, and continue with the quickstart below.
Quickstart
Build the SPA and the API bundle, initialize the Pulumi stack, and deploy. The Pulumi program uploads the built artifacts - it does not run the build itself, so you can iterate on the app and redeploy without any Pulumi-side changes.
# 1. Build the React SPA
cd website
npm install
npm run build
cd ..
# 2. Build the API bundle
cd api
npm install
npm run build
cd ..
# 3. Install root Pulumi dependencies
npm install
# 4. Initialize the stack and point it at your landing zone
pulumi stack init dev
pulumi config set gcp:project my-project-id
pulumi config set gcp:region us-central1
pulumi config set landingZoneStack <your-org>/landing-zone/dev
# 5. Deploy
pulumi up
pulumi up finishes in 5-10 minutes on a cold account, mostly waiting for the database to become available. When it completes, Pulumi prints a siteUrl output; open it in a browser and you should see the SPA showing the random number the API returned from the database.
App walkthrough
The application ships as two Node packages inside the downloadable zip, both independent of the Pulumi language you chose:
website/- a Vite + React project. One page,App.tsx, fetches/api/randomand shows the number. There is no client-side router, no state library, and no auth; it is the smallest possible proof that the frontend reaches the backend.api/- a Node.js 20 TypeScript package. One router insrc/handler.ts, one route (GET /api/random), and onepgpool insrc/db.ts.esbuildbundles the whole thing todist/handler.jsso the Pulumi program uploads a single file.
The handler
// api/src/handler.ts
import { pool } from "./db";
export async function handle(path: string) {
if (path === "/api/random") {
const result = await pool.query<{ n: number }>(
"SELECT floor(random()*100)::int AS n",
);
return { status: 200, body: JSON.stringify({ n: result.rows[0].n }) };
}
return { status: 404, body: JSON.stringify({ error: "not found" }) };
}
Adding more API routes is an edit + npm run build + pulumi up cycle. No function-specific glue; the handler is pure TypeScript.
The SPA
// website/src/App.tsx
import { useEffect, useState } from "react";
export default function App() {
const [n, setN] = useState<number | null>(null);
useEffect(() => {
fetch("/api/random")
.then((r) => r.json())
.then((data) => setN(data.n));
}, []);
return <main>Backend says your lucky number is: {n ?? "…"}</main>;
}
Database and secret wiring
The Database component provisions gcp.sql.DatabaseInstance with settings.ipConfiguration.privateNetwork at the db-f1-micro with private IP on the landing-zone VPC tier and a fresh database named after the Pulumi stack. During pulumi up:
- Pulumi generates a
random.RandomPassword(32 characters, no shell-unsafe symbols). - The password, plus the DB host, port, and database name, is assembled into a Postgres connection string and written to Google Secret Manager (
gcp.secretmanager.Secret+gcp.secretmanager.SecretVersion). - The Google Cloud Run functions (2nd gen) function is configured so the connection string is mounted into the Cloud Run function as a secret volume at
/secrets/db-password.
The function reads the secret once on cold start and reuses the pg.Pool across invocations. The password never appears in Pulumi stack outputs, never leaves Google Secret Manager in plaintext, and rotates automatically if you change the config.
Database networking: a Serverless VPC Access connector that lets the Cloud Run function reach the Cloud SQL private IP. The DB endpoint has no public IP, so the function is the only path into it.
Deploy
Run pulumi up once the SPA and API builds are in place. The preview shows three components’ worth of resources: the Database cluster plus secret, the Edge bucket + function + CDN, and the wiring that routes requests between them. Approve the preview and Pulumi deploys the whole stack.
When the deploy finishes, Pulumi prints:
siteUrl- the public CDN URL; open it to verify the SPA calls the API and renders the random numberapiUrl- the same hostname plus/api, useful when iterating on the SPA locally withnpm run dev- stack-scoped identifiers (DB name, function name, CDN id) that make log tailing and cache invalidation straightforward
Re-running pulumi up after an app change is fast: Pulumi only updates the function code and re-uploads the SPA, leaving the database and CDN alone.
Stack outputs
Every variant exports the same core outputs so downstream Pulumi projects can consume them with StackReference or Pulumi ESC:
siteUrl- the public Google Cloud CDN in front of a global external HTTPS load balancer URL that serves the SPAapiUrl- the same hostname plus/api, useful for integration testsdbSecretId- the handle to the database secret stored in Google Secret Manager
Cloud-specific outputs on this variant:
dbInstanceName- the Cloud SQL instance name for portal navigation andgcloud sql connectfunctionName- the Cloud Run functions name so you can tail logs withgcloud functions logs readloadBalancerIpAddress- the global external IP, useful for DNS A records
Run pulumi stack output to see the full list with values. Secret-typed outputs require --show-secrets.
Operations and cost
Logs and cache
Function logs go to Cloud Logging under the Cloud Run functions resource.
Cloud SQL metrics are on the instance page. Invalidate the CDN cache with
gcloud compute url-maps invalidate-cdn-cache <url-map> --path "/*" after
each npm run build in website/.
Cost
Cloud Run functions charges per-request + CPU time, so idle cost is zero.
Cloud SQL runs continuously on the db-f1-micro tier; stop it with
gcloud sql instances patch <name> --activation-policy=NEVER when you
are not using it. The global HTTPS load balancer has a small hourly minimum
plus per-GB charges.
Cleanup
pulumi destroy tears the whole stack down. The object-storage bucket is emptied during destroy so the delete succeeds; if you attached extra objects outside Pulumi, remove them first.
The landing-zone stack is a separate project; destroying this blueprint does not touch it.
Blueprint Pulumi program
The entrypoint reads the landing-zone outputs, creates the database component, then the edge component, and exports the site URL you can open in a browser once pulumi up completes.
import * as pulumi from "@pulumi/pulumi";
import { Database } from "./components/database";
import { Edge } from "./components/edge";
const config = new pulumi.Config();
const landingZoneStackName = config.require("landingZoneStack");
const dbEngineVersion = config.get("dbEngineVersion") ?? "POSTGRES_16";
const functionMemory = config.get("functionMemory") ?? "512Mi";
const websiteDistPath = config.get("websiteDistPath") ?? "./website/dist";
const apiHandlerPath = config.get("apiHandlerPath") ?? "./api/dist";
const gcpConfig = new pulumi.Config("gcp");
const project = gcpConfig.require("project");
const region = gcpConfig.require("region");
const landingZone = new pulumi.StackReference(landingZoneStackName);
const networkName = landingZone.requireOutput("networkName") as pulumi.Output<string>;
const privateSubnetName = landingZone.requireOutput("privateSubnetName") as pulumi.Output<string>;
const projectName = `${pulumi.getStack()}-serverless-react-postgres`;
const commonLabels: Record<string, string> = {
environment: pulumi.getStack(),
"solution-family": "serverless-react-postgres",
cloud: "gcp",
language: "typescript",
};
const database = new Database("db", {
project,
region,
networkName,
dbVersion: dbEngineVersion,
namePrefix: projectName,
labels: commonLabels,
});
const edge = new Edge("edge", {
project,
region,
networkName,
privateSubnetName,
databaseSecretName: database.secretName,
databaseSecretVersionId: database.secretVersionId,
websiteDistPath,
apiHandlerPath,
functionMemory,
namePrefix: projectName,
labels: commonLabels,
});
export const siteUrl = edge.siteUrl;
export const apiUrl = edge.apiUrl;
export const dbSecretId = database.secretId;
export const dbInstanceName = database.instanceName;
export const functionName = edge.functionName;
export const loadBalancerIpAddress = edge.loadBalancerIp;
export const escEnvironment = `${pulumi.getStack()}-serverless-react-postgres`;
import pulumi
from components.database import Database, DatabaseArgs
from components.edge import Edge, EdgeArgs
config = pulumi.Config()
landing_zone_stack_name = config.require("landingZoneStack")
db_engine_version = config.get("dbEngineVersion") or "POSTGRES_16"
function_memory = config.get("functionMemory") or "512Mi"
website_dist_path = config.get("websiteDistPath") or "./website/dist"
api_handler_path = config.get("apiHandlerPath") or "./api/dist"
gcp_config = pulumi.Config("gcp")
project = gcp_config.require("project")
region = gcp_config.require("region")
landing_zone = pulumi.StackReference(landing_zone_stack_name)
network_name = landing_zone.require_output("networkName")
private_subnet_name = landing_zone.require_output("privateSubnetName")
project_name = f"{pulumi.get_stack()}-serverless-react-postgres"
common_labels = {
"environment": pulumi.get_stack(),
"solution-family": "serverless-react-postgres",
"cloud": "gcp",
"language": "python",
}
database = Database(
"db",
DatabaseArgs(
project=project,
region=region,
network_name=network_name,
db_version=db_engine_version,
name_prefix=project_name,
labels=common_labels,
),
)
edge = Edge(
"edge",
EdgeArgs(
project=project,
region=region,
network_name=network_name,
private_subnet_name=private_subnet_name,
database_secret_name=database.secret_name,
database_secret_version_id=database.secret_version_id,
website_dist_path=website_dist_path,
api_handler_path=api_handler_path,
function_memory=function_memory,
name_prefix=project_name,
labels=common_labels,
),
)
pulumi.export("siteUrl", edge.site_url)
pulumi.export("apiUrl", edge.api_url)
pulumi.export("dbSecretId", database.secret_id)
pulumi.export("dbInstanceName", database.instance_name)
pulumi.export("functionName", edge.function_name)
pulumi.export("loadBalancerIpAddress", edge.load_balancer_ip)
pulumi.export("escEnvironment", f"{pulumi.get_stack()}-serverless-react-postgres")
package main
import (
"fmt"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
"serverless-react-postgres-gcp/database"
"serverless-react-postgres-gcp/edge"
)
func main() {
pulumi.Run(Program)
}
func Program(ctx *pulumi.Context) error {
cfg := config.New(ctx, "")
landingZoneStackName := cfg.Require("landingZoneStack")
dbEngineVersion := cfg.Get("dbEngineVersion")
if dbEngineVersion == "" {
dbEngineVersion = "POSTGRES_16"
}
functionMemory := cfg.Get("functionMemory")
if functionMemory == "" {
functionMemory = "512Mi"
}
websiteDistPath := cfg.Get("websiteDistPath")
if websiteDistPath == "" {
websiteDistPath = "./website/dist"
}
apiHandlerPath := cfg.Get("apiHandlerPath")
if apiHandlerPath == "" {
apiHandlerPath = "./api/dist"
}
gcpConfig := config.New(ctx, "gcp")
project := gcpConfig.Require("project")
region := gcpConfig.Require("region")
landingZone, err := pulumi.NewStackReference(ctx, landingZoneStackName, nil)
if err != nil {
return err
}
networkName := landingZone.GetStringOutput(pulumi.String("networkName"))
privateSubnetName := landingZone.GetStringOutput(pulumi.String("privateSubnetName"))
projectName := fmt.Sprintf("%s-serverless-react-postgres", ctx.Stack())
commonLabels := pulumi.StringMap{
"environment": pulumi.String(ctx.Stack()),
"solution-family": pulumi.String("serverless-react-postgres"),
"cloud": pulumi.String("gcp"),
"language": pulumi.String("go"),
}
db, err := database.New(ctx, "db", &database.Args{
Project: pulumi.String(project),
Region: pulumi.String(region),
NetworkName: networkName,
DbVersion: pulumi.String(dbEngineVersion),
NamePrefix: pulumi.String(projectName),
Labels: commonLabels,
})
if err != nil {
return err
}
e, err := edge.New(ctx, "edge", &edge.Args{
Project: pulumi.String(project),
Region: pulumi.String(region),
NetworkName: networkName,
PrivateSubnetName: privateSubnetName,
DatabaseSecretName: db.SecretName,
DatabaseSecretVersionId: db.SecretVersionId,
WebsiteDistPath: websiteDistPath,
ApiHandlerPath: apiHandlerPath,
FunctionMemory: pulumi.String(functionMemory),
NamePrefix: pulumi.String(projectName),
Labels: commonLabels,
})
if err != nil {
return err
}
ctx.Export("siteUrl", e.SiteUrl)
ctx.Export("apiUrl", e.ApiUrl)
ctx.Export("dbSecretId", db.SecretId)
ctx.Export("dbInstanceName", db.InstanceName)
ctx.Export("functionName", e.FunctionName)
ctx.Export("loadBalancerIpAddress", e.LoadBalancerIp)
ctx.Export("escEnvironment", pulumi.Sprintf("%s-serverless-react-postgres", ctx.Stack()))
return nil
}
Reusable components
The database wiring and the CDN + function + bucket wiring each live in a reusable module. Copy them into other Pulumi projects or adapt per team.
components/database.ts
Provisions the Cloud SQL instance on the landing-zone private network, generates a strong database password, and stores it in Google Secret Manager for the function to read.
import * as gcp from "@pulumi/gcp";
import * as pulumi from "@pulumi/pulumi";
import * as random from "@pulumi/random";
export interface DatabaseArgs {
project: pulumi.Input<string>;
region: pulumi.Input<string>;
networkName: pulumi.Input<string>;
dbVersion: pulumi.Input<string>;
namePrefix: pulumi.Input<string>;
labels: Record<string, string>;
}
export class Database extends pulumi.ComponentResource {
public readonly instanceName: pulumi.Output<string>;
public readonly connectionName: pulumi.Output<string>;
public readonly secretId: pulumi.Output<string>;
public readonly secretName: pulumi.Output<string>;
public readonly secretVersionId: pulumi.Output<string>;
public readonly databaseName: pulumi.Output<string>;
public readonly user: pulumi.Output<string>;
constructor(name: string, args: DatabaseArgs, opts?: pulumi.ComponentResourceOptions) {
super("serverless-react-postgres:gcp:Database", name, {}, opts);
const parent = { parent: this };
const databaseName = "appdb";
const dbUser = "appuser";
const password = new random.RandomPassword(`${name}-password`, {
length: 32,
special: false,
}, parent);
// The landing zone exposes only a VPC name, so this blueprint allocates its
// own VPC peering range and creates the service-networking connection within
// the stack. If your landing zone already peers the VPC with
// servicenetworking.googleapis.com, you can delete these two resources and
// point Cloud SQL's privateNetwork at the shared network directly.
const networkSelfLink = pulumi.interpolate`projects/${args.project}/global/networks/${args.networkName}`;
const peeringRange = new gcp.compute.GlobalAddress(`${name}-peer-range`, {
project: args.project,
name: pulumi.interpolate`${args.namePrefix}-sql-peer`,
purpose: "VPC_PEERING",
addressType: "INTERNAL",
prefixLength: 16,
network: networkSelfLink,
labels: args.labels,
}, parent);
const networkingConnection = new gcp.servicenetworking.Connection(`${name}-sn-conn`, {
network: networkSelfLink,
service: "servicenetworking.googleapis.com",
reservedPeeringRanges: [peeringRange.name],
}, parent);
const instance = new gcp.sql.DatabaseInstance(`${name}-pg`, {
project: args.project,
region: args.region,
databaseVersion: args.dbVersion,
deletionProtection: false,
settings: {
tier: "db-f1-micro",
ipConfiguration: {
ipv4Enabled: false,
privateNetwork: networkSelfLink,
},
backupConfiguration: {
enabled: true,
},
userLabels: args.labels,
},
}, { ...parent, dependsOn: [networkingConnection] });
new gcp.sql.Database(`${name}-db`, {
project: args.project,
name: databaseName,
instance: instance.name,
}, parent);
const user = new gcp.sql.User(`${name}-user`, {
project: args.project,
name: dbUser,
instance: instance.name,
password: password.result,
}, parent);
const connectionUrl = pulumi.all([
password.result,
instance.privateIpAddress,
]).apply(([pw, host]) =>
`postgresql://${dbUser}:${encodeURIComponent(pw)}@${host}:5432/${databaseName}?sslmode=require`,
);
const secret = new gcp.secretmanager.Secret(`${name}-secret`, {
project: args.project,
secretId: pulumi.interpolate`${args.namePrefix}-database-url`,
replication: {
auto: {},
},
labels: args.labels,
}, parent);
const secretVersion = new gcp.secretmanager.SecretVersion(`${name}-secret-version`, {
secret: secret.id,
secretData: connectionUrl,
}, { ...parent, dependsOn: [user] });
this.instanceName = instance.name;
this.connectionName = instance.connectionName;
this.secretId = secret.id;
this.secretName = secret.secretId;
this.secretVersionId = secretVersion.id;
this.databaseName = pulumi.output(databaseName);
this.user = pulumi.output(dbUser);
this.registerOutputs({
instanceName: this.instanceName,
connectionName: this.connectionName,
secretId: this.secretId,
secretName: this.secretName,
secretVersionId: this.secretVersionId,
databaseName: this.databaseName,
user: this.user,
});
}
}
components/edge.ts
Provisions the Google Cloud Run functions (2nd gen) function that runs the API, uploads the SPA to object storage, and wires Google Cloud CDN in front of a global external HTTPS load balancer so /* serves the SPA and /api/* reaches the function.
import * as fs from "fs";
import * as path from "path";
import * as gcp from "@pulumi/gcp";
import * as pulumi from "@pulumi/pulumi";
export interface EdgeArgs {
project: pulumi.Input<string>;
region: pulumi.Input<string>;
networkName: pulumi.Input<string>;
privateSubnetName: pulumi.Input<string>;
databaseSecretName: pulumi.Input<string>;
databaseSecretVersionId: pulumi.Input<string>;
websiteDistPath: string;
apiHandlerPath: string;
functionMemory: pulumi.Input<string>;
namePrefix: pulumi.Input<string>;
labels: Record<string, string>;
}
const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".txt": "text/plain; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
};
function contentTypeFor(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return MIME_TYPES[ext] ?? "application/octet-stream";
}
function walk(dir: string): string[] {
const results: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walk(full));
} else if (entry.isFile()) {
results.push(full);
}
}
return results;
}
export class Edge extends pulumi.ComponentResource {
public readonly siteUrl: pulumi.Output<string>;
public readonly apiUrl: pulumi.Output<string>;
public readonly functionName: pulumi.Output<string>;
public readonly serviceAccountEmail: pulumi.Output<string>;
public readonly loadBalancerIp: pulumi.Output<string>;
constructor(name: string, args: EdgeArgs, opts?: pulumi.ComponentResourceOptions) {
super("serverless-react-postgres:gcp:Edge", name, {}, opts);
const parent = { parent: this };
const networkSelfLink = pulumi.interpolate`projects/${args.project}/global/networks/${args.networkName}`;
// Serverless VPC Access connector so the Cloud Run function can reach
// Cloud SQL on the landing-zone VPC's private IP.
const connector = new gcp.vpcaccess.Connector(`${name}-vpc-conn`, {
project: args.project,
region: args.region,
network: args.networkName,
ipCidrRange: "10.124.0.0/28",
minInstances: 2,
maxInstances: 3,
machineType: "e2-micro",
}, parent);
// Dedicated service account for the function: secret access + Cloud SQL client.
const functionSa = new gcp.serviceaccount.Account(`${name}-fn-sa`, {
project: args.project,
accountId: pulumi.interpolate`${args.namePrefix}-fn`.apply((s) =>
s.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 30),
),
displayName: "serverless-react-postgres function",
}, parent);
new gcp.secretmanager.SecretIamMember(`${name}-fn-secret-access`, {
project: args.project,
secretId: args.databaseSecretName,
role: "roles/secretmanager.secretAccessor",
member: pulumi.interpolate`serviceAccount:${functionSa.email}`,
}, parent);
new gcp.projects.IAMMember(`${name}-fn-sql-client`, {
project: args.project,
role: "roles/cloudsql.client",
member: pulumi.interpolate`serviceAccount:${functionSa.email}`,
}, parent);
// Bucket that holds the zipped function source.
const functionSourceBucket = new gcp.storage.Bucket(`${name}-fn-src`, {
project: args.project,
location: args.region,
uniformBucketLevelAccess: true,
forceDestroy: true,
labels: args.labels,
}, parent);
const apiArchive = new pulumi.asset.FileArchive(args.apiHandlerPath);
const functionSourceObject = new gcp.storage.BucketObject(`${name}-fn-src-zip`, {
bucket: functionSourceBucket.name,
name: "api.zip",
source: apiArchive,
}, parent);
// Cloud Run function (v2). Runs on Cloud Run under the hood so IAM is managed
// through the Cloud Run invoker role.
const fn = new gcp.cloudfunctionsv2.Function(`${name}-fn`, {
project: args.project,
location: args.region,
name: pulumi.interpolate`${args.namePrefix}-api`.apply((s) =>
s.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 62),
),
buildConfig: {
runtime: "nodejs20",
entryPoint: "api",
source: {
storageSource: {
bucket: functionSourceBucket.name,
object: functionSourceObject.name,
},
},
},
serviceConfig: {
availableMemory: args.functionMemory,
timeoutSeconds: 30,
serviceAccountEmail: functionSa.email,
vpcConnector: connector.id,
vpcConnectorEgressSettings: "PRIVATE_RANGES_ONLY",
ingressSettings: "ALLOW_ALL",
secretVolumes: [{
mountPath: "/secrets",
projectId: args.project,
secret: args.databaseSecretName,
versions: [{ version: "latest", path: "database-url" }],
}],
},
labels: args.labels,
}, parent);
const cloudRunService = fn.serviceConfig.apply((sc) => sc?.service ?? "");
const functionUri = fn.serviceConfig.apply((sc) => sc?.uri ?? "");
// Make the function publicly invokable via the Cloud Run v2 IAM binding.
new gcp.cloudrunv2.ServiceIamMember(`${name}-fn-invoker`, {
project: args.project,
location: args.region,
name: cloudRunService,
role: "roles/run.invoker",
member: "allUsers",
}, parent);
// Static site bucket - uniform access + allUsers object viewer so the
// BackendBucket origin can fetch anonymously.
const siteBucket = new gcp.storage.Bucket(`${name}-site`, {
project: args.project,
location: args.region,
uniformBucketLevelAccess: true,
forceDestroy: true,
website: {
mainPageSuffix: "index.html",
notFoundPage: "index.html",
},
labels: args.labels,
}, parent);
new gcp.storage.BucketIAMMember(`${name}-site-public`, {
bucket: siteBucket.name,
role: "roles/storage.objectViewer",
member: "allUsers",
}, parent);
const websiteFiles = walk(args.websiteDistPath);
for (const file of websiteFiles) {
const key = path.relative(args.websiteDistPath, file).split(path.sep).join("/");
const urlSafeKey = key.replace(/[^A-Za-z0-9._-]/g, "_");
new gcp.storage.BucketObject(`${name}-site-${urlSafeKey}`, {
bucket: siteBucket.name,
name: key,
source: new pulumi.asset.FileAsset(file),
contentType: contentTypeFor(file),
}, parent);
}
// Global external HTTP load balancer. Demo blueprint: HTTP-only, no custom
// domain. Users who want HTTPS can attach a ManagedSslCertificate + target
// HTTPS proxy once they have a real domain pointing at the LB IP.
const backendBucket = new gcp.compute.BackendBucket(`${name}-site-backend`, {
project: args.project,
bucketName: siteBucket.name,
enableCdn: true,
}, parent);
const apiNeg = new gcp.compute.RegionNetworkEndpointGroup(`${name}-api-neg`, {
project: args.project,
region: args.region,
networkEndpointType: "SERVERLESS",
cloudRun: {
service: cloudRunService,
},
}, parent);
const apiBackend = new gcp.compute.BackendService(`${name}-api-backend`, {
project: args.project,
protocol: "HTTPS",
loadBalancingScheme: "EXTERNAL_MANAGED",
enableCdn: false,
backends: [{
group: apiNeg.id,
}],
}, parent);
const urlMap = new gcp.compute.URLMap(`${name}-urlmap`, {
project: args.project,
defaultService: backendBucket.id,
hostRules: [{
hosts: ["*"],
pathMatcher: "main",
}],
pathMatchers: [{
name: "main",
defaultService: backendBucket.id,
pathRules: [{
paths: ["/api", "/api/*"],
service: apiBackend.id,
}],
}],
}, parent);
const httpProxy = new gcp.compute.TargetHttpProxy(`${name}-http-proxy`, {
project: args.project,
urlMap: urlMap.id,
}, parent);
const lbAddress = new gcp.compute.GlobalAddress(`${name}-lb-ip`, {
project: args.project,
addressType: "EXTERNAL",
}, parent);
new gcp.compute.GlobalForwardingRule(`${name}-fwd`, {
project: args.project,
target: httpProxy.id,
portRange: "80",
ipAddress: lbAddress.address,
loadBalancingScheme: "EXTERNAL_MANAGED",
}, parent);
this.siteUrl = pulumi.interpolate`http://${lbAddress.address}/`;
this.apiUrl = pulumi.interpolate`http://${lbAddress.address}/api`;
this.functionName = fn.name;
this.serviceAccountEmail = functionSa.email;
this.loadBalancerIp = lbAddress.address;
// Silence unused-variable lint on the URI without changing resource graph.
void functionUri;
this.registerOutputs({
siteUrl: this.siteUrl,
apiUrl: this.apiUrl,
functionName: this.functionName,
serviceAccountEmail: this.serviceAccountEmail,
loadBalancerIp: this.loadBalancerIp,
});
}
}
components/database.py
Provisions the Cloud SQL instance on the landing-zone private network, generates a strong database password, and stores it in Google Secret Manager for the function to read.
from dataclasses import dataclass, field
from typing import Mapping
from urllib.parse import quote
import pulumi
import pulumi_gcp as gcp
import pulumi_random as random
@dataclass
class DatabaseArgs:
project: pulumi.Input[str]
region: pulumi.Input[str]
network_name: pulumi.Input[str]
db_version: pulumi.Input[str]
name_prefix: pulumi.Input[str]
labels: Mapping[str, str] = field(default_factory=dict)
class Database(pulumi.ComponentResource):
def __init__(self, name: str, args: DatabaseArgs, opts: pulumi.ResourceOptions | None = None):
super().__init__("serverless-react-postgres:gcp:Database", name, None, opts)
parent = pulumi.ResourceOptions(parent=self)
labels = dict(args.labels)
database_name = "appdb"
db_user = "appuser"
password = random.RandomPassword(
f"{name}-password",
length=32,
special=False,
opts=parent,
)
# The landing zone exposes only a VPC name, so this blueprint allocates its
# own VPC peering range and creates the service-networking connection within
# the stack. If your landing zone already peers the VPC with
# servicenetworking.googleapis.com, you can delete these two resources and
# point Cloud SQL's private_network at the shared network directly.
network_self_link = pulumi.Output.all(args.project, args.network_name).apply(
lambda vs: f"projects/{vs[0]}/global/networks/{vs[1]}"
)
peering_range = gcp.compute.GlobalAddress(
f"{name}-peer-range",
project=args.project,
name=pulumi.Output.concat(args.name_prefix, "-sql-peer"),
purpose="VPC_PEERING",
address_type="INTERNAL",
prefix_length=16,
network=network_self_link,
labels=labels,
opts=parent,
)
networking_connection = gcp.servicenetworking.Connection(
f"{name}-sn-conn",
network=network_self_link,
service="servicenetworking.googleapis.com",
reserved_peering_ranges=[peering_range.name],
opts=parent,
)
instance = gcp.sql.DatabaseInstance(
f"{name}-pg",
project=args.project,
region=args.region,
database_version=args.db_version,
deletion_protection=False,
settings=gcp.sql.DatabaseInstanceSettingsArgs(
tier="db-f1-micro",
ip_configuration=gcp.sql.DatabaseInstanceSettingsIpConfigurationArgs(
ipv4_enabled=False,
private_network=network_self_link,
),
backup_configuration=gcp.sql.DatabaseInstanceSettingsBackupConfigurationArgs(
enabled=True,
),
user_labels=labels,
),
opts=pulumi.ResourceOptions(parent=self, depends_on=[networking_connection]),
)
gcp.sql.Database(
f"{name}-db",
project=args.project,
name=database_name,
instance=instance.name,
opts=parent,
)
user = gcp.sql.User(
f"{name}-user",
project=args.project,
name=db_user,
instance=instance.name,
password=password.result,
opts=parent,
)
def _build_url(values):
pw, host = values
return f"postgresql://{db_user}:{quote(pw, safe='')}@{host}:5432/{database_name}?sslmode=require"
connection_url = pulumi.Output.all(password.result, instance.private_ip_address).apply(_build_url)
secret = gcp.secretmanager.Secret(
f"{name}-secret",
project=args.project,
secret_id=pulumi.Output.concat(args.name_prefix, "-database-url"),
replication=gcp.secretmanager.SecretReplicationArgs(
auto=gcp.secretmanager.SecretReplicationAutoArgs(),
),
labels=labels,
opts=parent,
)
secret_version = gcp.secretmanager.SecretVersion(
f"{name}-secret-version",
secret=secret.id,
secret_data=connection_url,
opts=pulumi.ResourceOptions(parent=self, depends_on=[user]),
)
self.instance_name = instance.name
self.connection_name = instance.connection_name
self.secret_id = secret.id
self.secret_name = secret.secret_id
self.secret_version_id = secret_version.id
self.database_name = pulumi.Output.from_input(database_name)
self.user = pulumi.Output.from_input(db_user)
self.register_outputs({
"instance_name": self.instance_name,
"connection_name": self.connection_name,
"secret_id": self.secret_id,
"secret_name": self.secret_name,
"secret_version_id": self.secret_version_id,
"database_name": self.database_name,
"user": self.user,
})
components/edge.py
Provisions the Google Cloud Run functions (2nd gen) function that runs the API, uploads the SPA to object storage, and wires Google Cloud CDN in front of a global external HTTPS load balancer so /* serves the SPA and /api/* reaches the function.
import os
import re
from dataclasses import dataclass, field
from typing import Mapping
import pulumi
import pulumi_gcp as gcp
@dataclass
class EdgeArgs:
project: pulumi.Input[str]
region: pulumi.Input[str]
network_name: pulumi.Input[str]
private_subnet_name: pulumi.Input[str]
database_secret_name: pulumi.Input[str]
database_secret_version_id: pulumi.Input[str]
website_dist_path: str
api_handler_path: str
function_memory: pulumi.Input[str]
name_prefix: pulumi.Input[str]
labels: Mapping[str, str] = field(default_factory=dict)
MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".txt": "text/plain; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
}
def _content_type(path: str) -> str:
_, ext = os.path.splitext(path)
return MIME_TYPES.get(ext.lower(), "application/octet-stream")
def _walk(root: str):
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
yield os.path.join(dirpath, fn)
def _sanitize_id(value: str, max_len: int) -> str:
cleaned = re.sub(r"[^a-z0-9-]", "-", value.lower())
return cleaned[:max_len]
class Edge(pulumi.ComponentResource):
def __init__(self, name: str, args: EdgeArgs, opts: pulumi.ResourceOptions | None = None):
super().__init__("serverless-react-postgres:gcp:Edge", name, None, opts)
parent = pulumi.ResourceOptions(parent=self)
labels = dict(args.labels)
# Serverless VPC Access connector so the Cloud Run function can reach
# Cloud SQL on the landing-zone VPC's private IP.
connector = gcp.vpcaccess.Connector(
f"{name}-vpc-conn",
project=args.project,
region=args.region,
network=args.network_name,
ip_cidr_range="10.124.0.0/28",
min_instances=2,
max_instances=3,
machine_type="e2-micro",
opts=parent,
)
# Dedicated service account for the function: secret access + Cloud SQL client.
function_sa = gcp.serviceaccount.Account(
f"{name}-fn-sa",
project=args.project,
account_id=pulumi.Output.concat(args.name_prefix, "-fn").apply(
lambda s: _sanitize_id(s, 30)
),
display_name="serverless-react-postgres function",
opts=parent,
)
gcp.secretmanager.SecretIamMember(
f"{name}-fn-secret-access",
project=args.project,
secret_id=args.database_secret_name,
role="roles/secretmanager.secretAccessor",
member=pulumi.Output.concat("serviceAccount:", function_sa.email),
opts=parent,
)
gcp.projects.IAMMember(
f"{name}-fn-sql-client",
project=args.project,
role="roles/cloudsql.client",
member=pulumi.Output.concat("serviceAccount:", function_sa.email),
opts=parent,
)
# Bucket that holds the zipped function source.
function_source_bucket = gcp.storage.Bucket(
f"{name}-fn-src",
project=args.project,
location=args.region,
uniform_bucket_level_access=True,
force_destroy=True,
labels=labels,
opts=parent,
)
api_archive = pulumi.FileArchive(args.api_handler_path)
function_source_object = gcp.storage.BucketObject(
f"{name}-fn-src-zip",
bucket=function_source_bucket.name,
name="api.zip",
source=api_archive,
opts=parent,
)
fn_name = pulumi.Output.concat(args.name_prefix, "-api").apply(
lambda s: _sanitize_id(s, 62)
)
fn = gcp.cloudfunctionsv2.Function(
f"{name}-fn",
project=args.project,
location=args.region,
name=fn_name,
build_config=gcp.cloudfunctionsv2.FunctionBuildConfigArgs(
runtime="nodejs20",
entry_point="api",
source=gcp.cloudfunctionsv2.FunctionBuildConfigSourceArgs(
storage_source=gcp.cloudfunctionsv2.FunctionBuildConfigSourceStorageSourceArgs(
bucket=function_source_bucket.name,
object=function_source_object.name,
),
),
),
service_config=gcp.cloudfunctionsv2.FunctionServiceConfigArgs(
available_memory=args.function_memory,
timeout_seconds=30,
service_account_email=function_sa.email,
vpc_connector=connector.id,
vpc_connector_egress_settings="PRIVATE_RANGES_ONLY",
ingress_settings="ALLOW_ALL",
secret_volumes=[
gcp.cloudfunctionsv2.FunctionServiceConfigSecretVolumeArgs(
mount_path="/secrets",
project_id=args.project,
secret=args.database_secret_name,
versions=[
gcp.cloudfunctionsv2.FunctionServiceConfigSecretVolumeVersionArgs(
version="latest",
path="database-url",
),
],
),
],
),
labels=labels,
opts=parent,
)
cloud_run_service = fn.service_config.apply(lambda sc: sc.service if sc else "")
# Make the function publicly invokable via the Cloud Run v2 IAM binding.
gcp.cloudrunv2.ServiceIamMember(
f"{name}-fn-invoker",
project=args.project,
location=args.region,
name=cloud_run_service,
role="roles/run.invoker",
member="allUsers",
opts=parent,
)
# Static site bucket - uniform access + allUsers object viewer so the
# BackendBucket origin can fetch anonymously.
site_bucket = gcp.storage.Bucket(
f"{name}-site",
project=args.project,
location=args.region,
uniform_bucket_level_access=True,
force_destroy=True,
website=gcp.storage.BucketWebsiteArgs(
main_page_suffix="index.html",
not_found_page="index.html",
),
labels=labels,
opts=parent,
)
gcp.storage.BucketIAMMember(
f"{name}-site-public",
bucket=site_bucket.name,
role="roles/storage.objectViewer",
member="allUsers",
opts=parent,
)
for file_path in _walk(args.website_dist_path):
key = os.path.relpath(file_path, args.website_dist_path).replace(os.sep, "/")
url_safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key)
gcp.storage.BucketObject(
f"{name}-site-{url_safe_key}",
bucket=site_bucket.name,
name=key,
source=pulumi.FileAsset(file_path),
content_type=_content_type(file_path),
opts=parent,
)
# Global external HTTP load balancer. Demo blueprint: HTTP-only, no custom
# domain. Users who want HTTPS can attach a ManagedSslCertificate + target
# HTTPS proxy once they have a real domain pointing at the LB IP.
backend_bucket = gcp.compute.BackendBucket(
f"{name}-site-backend",
project=args.project,
bucket_name=site_bucket.name,
enable_cdn=True,
opts=parent,
)
api_neg = gcp.compute.RegionNetworkEndpointGroup(
f"{name}-api-neg",
project=args.project,
region=args.region,
network_endpoint_type="SERVERLESS",
cloud_run=gcp.compute.RegionNetworkEndpointGroupCloudRunArgs(
service=cloud_run_service,
),
opts=parent,
)
api_backend = gcp.compute.BackendService(
f"{name}-api-backend",
project=args.project,
protocol="HTTPS",
load_balancing_scheme="EXTERNAL_MANAGED",
enable_cdn=False,
backends=[
gcp.compute.BackendServiceBackendArgs(group=api_neg.id),
],
opts=parent,
)
url_map = gcp.compute.URLMap(
f"{name}-urlmap",
project=args.project,
default_service=backend_bucket.id,
host_rules=[
gcp.compute.URLMapHostRuleArgs(hosts=["*"], path_matcher="main"),
],
path_matchers=[
gcp.compute.URLMapPathMatcherArgs(
name="main",
default_service=backend_bucket.id,
path_rules=[
gcp.compute.URLMapPathMatcherPathRuleArgs(
paths=["/api", "/api/*"],
service=api_backend.id,
),
],
),
],
opts=parent,
)
http_proxy = gcp.compute.TargetHttpProxy(
f"{name}-http-proxy",
project=args.project,
url_map=url_map.id,
opts=parent,
)
lb_address = gcp.compute.GlobalAddress(
f"{name}-lb-ip",
project=args.project,
address_type="EXTERNAL",
opts=parent,
)
gcp.compute.GlobalForwardingRule(
f"{name}-fwd",
project=args.project,
target=http_proxy.id,
port_range="80",
ip_address=lb_address.address,
load_balancing_scheme="EXTERNAL_MANAGED",
opts=parent,
)
self.site_url = lb_address.address.apply(lambda a: f"http://{a}/")
self.api_url = lb_address.address.apply(lambda a: f"http://{a}/api")
self.function_name = fn.name
self.service_account_email = function_sa.email
self.load_balancer_ip = lb_address.address
self.register_outputs({
"site_url": self.site_url,
"api_url": self.api_url,
"function_name": self.function_name,
"service_account_email": self.service_account_email,
"load_balancer_ip": self.load_balancer_ip,
})
database/database.go
Provisions the Cloud SQL instance on the landing-zone private network, generates a strong database password, and stores it in Google Secret Manager for the function to read.
package database
import (
"fmt"
"net/url"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/secretmanager"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/servicenetworking"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/sql"
"github.com/pulumi/pulumi-random/sdk/v4/go/random"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
type Args struct {
Project pulumi.StringInput
Region pulumi.StringInput
NetworkName pulumi.StringInput
DbVersion pulumi.StringInput
NamePrefix pulumi.StringInput
Labels pulumi.StringMapInput
}
type Database struct {
pulumi.ResourceState
InstanceName pulumi.StringOutput
ConnectionName pulumi.StringOutput
SecretId pulumi.StringOutput
SecretName pulumi.StringOutput
SecretVersionId pulumi.StringOutput
DatabaseName pulumi.StringOutput
User pulumi.StringOutput
}
func New(ctx *pulumi.Context, name string, args *Args, opts ...pulumi.ResourceOption) (*Database, error) {
component := &Database{}
if err := ctx.RegisterComponentResource("serverless-react-postgres:gcp:Database", name, component, opts...); err != nil {
return nil, err
}
parent := pulumi.Parent(component)
const databaseName = "appdb"
const dbUser = "appuser"
password, err := random.NewRandomPassword(ctx, fmt.Sprintf("%s-password", name), &random.RandomPasswordArgs{
Length: pulumi.Int(32),
Special: pulumi.Bool(false),
}, parent)
if err != nil {
return nil, err
}
// The landing zone exposes only a VPC name, so this blueprint allocates its
// own VPC peering range and creates the service-networking connection within
// the stack. If your landing zone already peers the VPC with
// servicenetworking.googleapis.com, you can delete these two resources and
// point Cloud SQL's PrivateNetwork at the shared network directly.
networkSelfLink := pulumi.All(args.Project, args.NetworkName).ApplyT(func(vs []interface{}) string {
return fmt.Sprintf("projects/%s/global/networks/%s", vs[0].(string), vs[1].(string))
}).(pulumi.StringOutput)
peeringRangeName := args.NamePrefix.ToStringOutput().ApplyT(func(p string) string {
return p + "-sql-peer"
}).(pulumi.StringOutput)
peeringRange, err := compute.NewGlobalAddress(ctx, fmt.Sprintf("%s-peer-range", name), &compute.GlobalAddressArgs{
Project: args.Project,
Name: peeringRangeName,
Purpose: pulumi.String("VPC_PEERING"),
AddressType: pulumi.String("INTERNAL"),
PrefixLength: pulumi.Int(16),
Network: networkSelfLink,
Labels: args.Labels,
}, parent)
if err != nil {
return nil, err
}
networkingConnection, err := servicenetworking.NewConnection(ctx, fmt.Sprintf("%s-sn-conn", name), &servicenetworking.ConnectionArgs{
Network: networkSelfLink,
Service: pulumi.String("servicenetworking.googleapis.com"),
ReservedPeeringRanges: pulumi.StringArray{
peeringRange.Name,
},
}, parent)
if err != nil {
return nil, err
}
instance, err := sql.NewDatabaseInstance(ctx, fmt.Sprintf("%s-pg", name), &sql.DatabaseInstanceArgs{
Project: args.Project,
Region: args.Region,
DatabaseVersion: args.DbVersion,
DeletionProtection: pulumi.Bool(false),
Settings: &sql.DatabaseInstanceSettingsArgs{
Tier: pulumi.String("db-f1-micro"),
IpConfiguration: &sql.DatabaseInstanceSettingsIpConfigurationArgs{
Ipv4Enabled: pulumi.Bool(false),
PrivateNetwork: networkSelfLink,
},
BackupConfiguration: &sql.DatabaseInstanceSettingsBackupConfigurationArgs{
Enabled: pulumi.Bool(true),
},
UserLabels: args.Labels,
},
}, pulumi.Parent(component), pulumi.DependsOn([]pulumi.Resource{networkingConnection}))
if err != nil {
return nil, err
}
if _, err := sql.NewDatabase(ctx, fmt.Sprintf("%s-db", name), &sql.DatabaseArgs{
Project: args.Project,
Name: pulumi.String(databaseName),
Instance: instance.Name,
}, parent); err != nil {
return nil, err
}
user, err := sql.NewUser(ctx, fmt.Sprintf("%s-user", name), &sql.UserArgs{
Project: args.Project,
Name: pulumi.String(dbUser),
Instance: instance.Name,
Password: password.Result,
}, parent)
if err != nil {
return nil, err
}
connectionUrl := pulumi.All(password.Result, instance.PrivateIpAddress).ApplyT(func(vs []interface{}) string {
pw := vs[0].(string)
host := vs[1].(string)
return fmt.Sprintf("postgresql://%s:%s@%s:5432/%s?sslmode=require",
dbUser, url.QueryEscape(pw), host, databaseName)
}).(pulumi.StringOutput)
secretId := args.NamePrefix.ToStringOutput().ApplyT(func(p string) string {
return p + "-database-url"
}).(pulumi.StringOutput)
secret, err := secretmanager.NewSecret(ctx, fmt.Sprintf("%s-secret", name), &secretmanager.SecretArgs{
Project: args.Project,
SecretId: secretId,
Replication: &secretmanager.SecretReplicationArgs{
Auto: &secretmanager.SecretReplicationAutoArgs{},
},
Labels: args.Labels,
}, parent)
if err != nil {
return nil, err
}
secretVersion, err := secretmanager.NewSecretVersion(ctx, fmt.Sprintf("%s-secret-version", name), &secretmanager.SecretVersionArgs{
Secret: secret.ID(),
SecretData: connectionUrl,
}, pulumi.Parent(component), pulumi.DependsOn([]pulumi.Resource{user}))
if err != nil {
return nil, err
}
component.InstanceName = instance.Name
component.ConnectionName = instance.ConnectionName
component.SecretId = secret.ID().ToStringOutput()
component.SecretName = secret.SecretId
component.SecretVersionId = secretVersion.ID().ToStringOutput()
component.DatabaseName = pulumi.String(databaseName).ToStringOutput()
component.User = pulumi.String(dbUser).ToStringOutput()
if err := ctx.RegisterResourceOutputs(component, pulumi.Map{
"instanceName": component.InstanceName,
"connectionName": component.ConnectionName,
"secretId": component.SecretId,
"secretName": component.SecretName,
"secretVersionId": component.SecretVersionId,
"databaseName": component.DatabaseName,
"user": component.User,
}); err != nil {
return nil, err
}
return component, nil
}
edge/edge.go
Provisions the Google Cloud Run functions (2nd gen) function that runs the API, uploads the SPA to object storage, and wires Google Cloud CDN in front of a global external HTTPS load balancer so /* serves the SPA and /api/* reaches the function.
package edge
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/cloudfunctionsv2"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/cloudrunv2"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/projects"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/secretmanager"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/storage"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/vpcaccess"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
type Args struct {
Project pulumi.StringInput
Region pulumi.StringInput
NetworkName pulumi.StringInput
PrivateSubnetName pulumi.StringInput
DatabaseSecretName pulumi.StringInput
DatabaseSecretVersionId pulumi.StringInput
WebsiteDistPath string
ApiHandlerPath string
FunctionMemory pulumi.StringInput
NamePrefix pulumi.StringInput
Labels pulumi.StringMapInput
}
type Edge struct {
pulumi.ResourceState
SiteUrl pulumi.StringOutput
ApiUrl pulumi.StringOutput
FunctionName pulumi.StringOutput
ServiceAccountEmail pulumi.StringOutput
LoadBalancerIp pulumi.StringOutput
}
var mimeTypes = map[string]string{
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".txt": "text/plain; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
}
var safeKeyRE = regexp.MustCompile(`[^A-Za-z0-9._-]`)
var idSanitizeRE = regexp.MustCompile(`[^a-z0-9-]`)
func contentTypeFor(path string) string {
if ct, ok := mimeTypes[strings.ToLower(filepath.Ext(path))]; ok {
return ct
}
return "application/octet-stream"
}
func sanitizeID(value string, maxLen int) string {
cleaned := idSanitizeRE.ReplaceAllString(strings.ToLower(value), "-")
if len(cleaned) > maxLen {
cleaned = cleaned[:maxLen]
}
return cleaned
}
func New(ctx *pulumi.Context, name string, args *Args, opts ...pulumi.ResourceOption) (*Edge, error) {
component := &Edge{}
if err := ctx.RegisterComponentResource("serverless-react-postgres:gcp:Edge", name, component, opts...); err != nil {
return nil, err
}
parent := pulumi.Parent(component)
// Serverless VPC Access connector so the Cloud Run function can reach
// Cloud SQL on the landing-zone VPC's private IP.
connector, err := vpcaccess.NewConnector(ctx, fmt.Sprintf("%s-vpc-conn", name), &vpcaccess.ConnectorArgs{
Project: args.Project,
Region: args.Region,
Network: args.NetworkName,
IpCidrRange: pulumi.String("10.124.0.0/28"),
MinInstances: pulumi.Int(2),
MaxInstances: pulumi.Int(3),
MachineType: pulumi.String("e2-micro"),
}, parent)
if err != nil {
return nil, err
}
// Dedicated service account for the function: secret access + Cloud SQL client.
saAccountId := args.NamePrefix.ToStringOutput().ApplyT(func(p string) string {
return sanitizeID(p+"-fn", 30)
}).(pulumi.StringOutput)
functionSa, err := serviceaccount.NewAccount(ctx, fmt.Sprintf("%s-fn-sa", name), &serviceaccount.AccountArgs{
Project: args.Project,
AccountId: saAccountId,
DisplayName: pulumi.String("serverless-react-postgres function"),
}, parent)
if err != nil {
return nil, err
}
saMember := functionSa.Email.ApplyT(func(e string) string { return "serviceAccount:" + e }).(pulumi.StringOutput)
if _, err := secretmanager.NewSecretIamMember(ctx, fmt.Sprintf("%s-fn-secret-access", name), &secretmanager.SecretIamMemberArgs{
Project: args.Project,
SecretId: args.DatabaseSecretName,
Role: pulumi.String("roles/secretmanager.secretAccessor"),
Member: saMember,
}, parent); err != nil {
return nil, err
}
if _, err := projects.NewIAMMember(ctx, fmt.Sprintf("%s-fn-sql-client", name), &projects.IAMMemberArgs{
Project: args.Project,
Role: pulumi.String("roles/cloudsql.client"),
Member: saMember,
}, parent); err != nil {
return nil, err
}
// Bucket that holds the zipped function source.
functionSourceBucket, err := storage.NewBucket(ctx, fmt.Sprintf("%s-fn-src", name), &storage.BucketArgs{
Project: args.Project,
Location: args.Region,
UniformBucketLevelAccess: pulumi.Bool(true),
ForceDestroy: pulumi.Bool(true),
Labels: args.Labels,
}, parent)
if err != nil {
return nil, err
}
functionSourceObject, err := storage.NewBucketObject(ctx, fmt.Sprintf("%s-fn-src-zip", name), &storage.BucketObjectArgs{
Bucket: functionSourceBucket.Name,
Name: pulumi.String("api.zip"),
Source: pulumi.NewFileArchive(args.ApiHandlerPath),
}, parent)
if err != nil {
return nil, err
}
fnName := args.NamePrefix.ToStringOutput().ApplyT(func(p string) string {
return sanitizeID(p+"-api", 62)
}).(pulumi.StringOutput)
fn, err := cloudfunctionsv2.NewFunction(ctx, fmt.Sprintf("%s-fn", name), &cloudfunctionsv2.FunctionArgs{
Project: args.Project,
Location: args.Region,
Name: fnName,
BuildConfig: &cloudfunctionsv2.FunctionBuildConfigArgs{
Runtime: pulumi.String("nodejs20"),
EntryPoint: pulumi.String("api"),
Source: &cloudfunctionsv2.FunctionBuildConfigSourceArgs{
StorageSource: &cloudfunctionsv2.FunctionBuildConfigSourceStorageSourceArgs{
Bucket: functionSourceBucket.Name,
Object: functionSourceObject.Name,
},
},
},
ServiceConfig: &cloudfunctionsv2.FunctionServiceConfigArgs{
AvailableMemory: args.FunctionMemory,
TimeoutSeconds: pulumi.Int(30),
ServiceAccountEmail: functionSa.Email,
VpcConnector: connector.ID(),
VpcConnectorEgressSettings: pulumi.String("PRIVATE_RANGES_ONLY"),
IngressSettings: pulumi.String("ALLOW_ALL"),
SecretVolumes: cloudfunctionsv2.FunctionServiceConfigSecretVolumeArray{
&cloudfunctionsv2.FunctionServiceConfigSecretVolumeArgs{
MountPath: pulumi.String("/secrets"),
ProjectId: args.Project,
Secret: args.DatabaseSecretName,
Versions: cloudfunctionsv2.FunctionServiceConfigSecretVolumeVersionArray{
&cloudfunctionsv2.FunctionServiceConfigSecretVolumeVersionArgs{
Version: pulumi.String("latest"),
Path: pulumi.String("database-url"),
},
},
},
},
},
Labels: args.Labels,
}, parent)
if err != nil {
return nil, err
}
cloudRunService := fn.ServiceConfig.ApplyT(func(sc *cloudfunctionsv2.FunctionServiceConfig) string {
if sc == nil || sc.Service == nil {
return ""
}
return *sc.Service
}).(pulumi.StringOutput)
// Make the function publicly invokable via the Cloud Run v2 IAM binding.
if _, err := cloudrunv2.NewServiceIamMember(ctx, fmt.Sprintf("%s-fn-invoker", name), &cloudrunv2.ServiceIamMemberArgs{
Project: args.Project,
Location: args.Region,
Name: cloudRunService,
Role: pulumi.String("roles/run.invoker"),
Member: pulumi.String("allUsers"),
}, parent); err != nil {
return nil, err
}
// Static site bucket - uniform access + allUsers object viewer so the
// BackendBucket origin can fetch anonymously.
siteBucket, err := storage.NewBucket(ctx, fmt.Sprintf("%s-site", name), &storage.BucketArgs{
Project: args.Project,
Location: args.Region,
UniformBucketLevelAccess: pulumi.Bool(true),
ForceDestroy: pulumi.Bool(true),
Website: &storage.BucketWebsiteArgs{
MainPageSuffix: pulumi.String("index.html"),
NotFoundPage: pulumi.String("index.html"),
},
Labels: args.Labels,
}, parent)
if err != nil {
return nil, err
}
if _, err := storage.NewBucketIAMMember(ctx, fmt.Sprintf("%s-site-public", name), &storage.BucketIAMMemberArgs{
Bucket: siteBucket.Name,
Role: pulumi.String("roles/storage.objectViewer"),
Member: pulumi.String("allUsers"),
}, parent); err != nil {
return nil, err
}
walkErr := filepath.Walk(args.WebsiteDistPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(args.WebsiteDistPath, path)
if err != nil {
return err
}
key := filepath.ToSlash(rel)
safe := safeKeyRE.ReplaceAllString(key, "_")
if _, err := storage.NewBucketObject(ctx, fmt.Sprintf("%s-site-%s", name, safe), &storage.BucketObjectArgs{
Bucket: siteBucket.Name,
Name: pulumi.String(key),
Source: pulumi.NewFileAsset(path),
ContentType: pulumi.String(contentTypeFor(path)),
}, parent); err != nil {
return err
}
return nil
})
if walkErr != nil {
return nil, walkErr
}
// Global external HTTP load balancer. Demo blueprint: HTTP-only, no custom
// domain. Users who want HTTPS can attach a ManagedSslCertificate + target
// HTTPS proxy once they have a real domain pointing at the LB IP.
backendBucket, err := compute.NewBackendBucket(ctx, fmt.Sprintf("%s-site-backend", name), &compute.BackendBucketArgs{
Project: args.Project,
BucketName: siteBucket.Name,
EnableCdn: pulumi.Bool(true),
}, parent)
if err != nil {
return nil, err
}
apiNeg, err := compute.NewRegionNetworkEndpointGroup(ctx, fmt.Sprintf("%s-api-neg", name), &compute.RegionNetworkEndpointGroupArgs{
Project: args.Project,
Region: args.Region,
NetworkEndpointType: pulumi.String("SERVERLESS"),
CloudRun: &compute.RegionNetworkEndpointGroupCloudRunArgs{
Service: cloudRunService,
},
}, parent)
if err != nil {
return nil, err
}
apiBackend, err := compute.NewBackendService(ctx, fmt.Sprintf("%s-api-backend", name), &compute.BackendServiceArgs{
Project: args.Project,
Protocol: pulumi.String("HTTPS"),
LoadBalancingScheme: pulumi.String("EXTERNAL_MANAGED"),
EnableCdn: pulumi.Bool(false),
Backends: compute.BackendServiceBackendArray{
&compute.BackendServiceBackendArgs{
Group: apiNeg.ID(),
},
},
}, parent)
if err != nil {
return nil, err
}
urlMap, err := compute.NewURLMap(ctx, fmt.Sprintf("%s-urlmap", name), &compute.URLMapArgs{
Project: args.Project,
DefaultService: backendBucket.ID(),
HostRules: compute.URLMapHostRuleArray{
&compute.URLMapHostRuleArgs{
Hosts: pulumi.StringArray{pulumi.String("*")},
PathMatcher: pulumi.String("main"),
},
},
PathMatchers: compute.URLMapPathMatcherArray{
&compute.URLMapPathMatcherArgs{
Name: pulumi.String("main"),
DefaultService: backendBucket.ID(),
PathRules: compute.URLMapPathMatcherPathRuleArray{
&compute.URLMapPathMatcherPathRuleArgs{
Paths: pulumi.StringArray{
pulumi.String("/api"),
pulumi.String("/api/*"),
},
Service: apiBackend.ID(),
},
},
},
},
}, parent)
if err != nil {
return nil, err
}
httpProxy, err := compute.NewTargetHttpProxy(ctx, fmt.Sprintf("%s-http-proxy", name), &compute.TargetHttpProxyArgs{
Project: args.Project,
UrlMap: urlMap.ID(),
}, parent)
if err != nil {
return nil, err
}
lbAddress, err := compute.NewGlobalAddress(ctx, fmt.Sprintf("%s-lb-ip", name), &compute.GlobalAddressArgs{
Project: args.Project,
AddressType: pulumi.String("EXTERNAL"),
}, parent)
if err != nil {
return nil, err
}
if _, err := compute.NewGlobalForwardingRule(ctx, fmt.Sprintf("%s-fwd", name), &compute.GlobalForwardingRuleArgs{
Project: args.Project,
Target: httpProxy.ID(),
PortRange: pulumi.String("80"),
IpAddress: lbAddress.Address,
LoadBalancingScheme: pulumi.String("EXTERNAL_MANAGED"),
}, parent); err != nil {
return nil, err
}
component.SiteUrl = lbAddress.Address.ApplyT(func(a string) string { return "http://" + a + "/" }).(pulumi.StringOutput)
component.ApiUrl = lbAddress.Address.ApplyT(func(a string) string { return "http://" + a + "/api" }).(pulumi.StringOutput)
component.FunctionName = fn.Name
component.ServiceAccountEmail = functionSa.Email
component.LoadBalancerIp = lbAddress.Address
if err := ctx.RegisterResourceOutputs(component, pulumi.Map{
"siteUrl": component.SiteUrl,
"apiUrl": component.ApiUrl,
"functionName": component.FunctionName,
"serviceAccountEmail": component.ServiceAccountEmail,
"loadBalancerIp": component.LoadBalancerIp,
}); err != nil {
return nil, err
}
return component, nil
}