This blueprint ships a full-stack serverless application on AWS: 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 Amazon CloudFront
- A AWS Lambda function with one route,
GET /api/random, that runsSELECT floor(random()*100)::int AS nagainst Aurora Serverless v2 for PostgreSQL - the S3 bucket origin serves
/*and the Lambda Function URL origin serves/api/*so the browser stays on a single origin and never sees CORS - Database credentials generated during
pulumi upand stored in AWS Secrets Manager - injected into the Lambda through aSECRET_ARNenv var, read on first invocation withGetSecretValue - Private networking: the database runs on a Lambda VPC config targeting the landing-zone private subnets 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 AWS
The Pulumi program is split into two reusable components plus an entrypoint that wires them together:
Database- provisions Aurora Serverless v2 for PostgreSQL at theserverlessv2ScalingConfiguration.minCapacity: 0tier, attaches it to the landing-zone private network, generates arandom.RandomPassword, and stores the DB URL in AWS Secrets Manager.Edge- creates the bucket for the SPA, the AWS Lambda that runs the API handler, and Amazon CloudFront with the S3 bucket origin serves/*and the Lambda Function URL origin serves/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: Lambda scales to zero when no requests are in flight. Aurora Serverless v2 scales ACUs to zero after inactivity and restarts on first query.
The AWS variant uses AWS Lambda behind a Function URL, Aurora Serverless v2 for PostgreSQL with minCapacity: 0 so the database scales compute to zero during idle periods, AWS Secrets Manager for the DB password, and CloudFront as the same-origin CDN. The Lambda runs inside the landing-zone VPC so it can reach the private Aurora endpoint without exposing the database to the public internet.
Prerequisites
- Pulumi account and CLI
- Node.js 20 or newer and npm
- an AWS account where the Pulumi landing-zone stack is already deployed and you have permission to create RDS, Lambda, CloudFront, S3, Secrets Manager, 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 AWS 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/aws stack through pulumi.StackReference:
networkId- the VPC id where Aurora and the Lambda VPC config runprivateSubnetIds- the two private subnets the DB cluster and Lambda ENIs usesecretsStore- the prefix under which Secrets Manager entries for this stack are created
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 AWS 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 aws:region us-west-2
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 aws.rds.Cluster + aws.rds.ClusterInstance at the serverlessv2ScalingConfiguration.minCapacity: 0 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 AWS Secrets Manager (
aws.secretsmanager.Secret+aws.secretsmanager.SecretVersion). - The AWS Lambda function is configured so the connection string is injected into the Lambda through a
SECRET_ARNenv var, read on first invocation withGetSecretValue.
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 AWS Secrets Manager in plaintext, and rotates automatically if you change the config.
Database networking: a Lambda VPC config targeting the landing-zone private subnets. 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 Amazon CloudFront URL that serves the SPAapiUrl- the same hostname plus/api, useful for integration testsdbSecretId- the handle to the database secret stored in AWS Secrets Manager
Cloud-specific outputs on this variant:
dbClusterArn- the Aurora cluster ARN for CloudWatch alarms or IAM scoping in other stackslambdaFunctionName- the Lambda function name so you can tail logs withsam logsoraws logs tailcloudfrontDistributionId- the CloudFront distribution id for cache invalidations after SPA rebuilds
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 land in the CloudWatch log group
/aws/lambda/<stack>-api. Aurora Serverless v2 reports scaling events in
the RDS console under “Logs & events”. The CloudFront distribution is
stack-scoped so you can invalidate the SPA cache with
aws cloudfront create-invalidation --distribution-id <id> --paths "/*"
after each npm run build in website/.
Cost
Idle cost is dominated by the Aurora Serverless v2 minimum (0 ACUs when paused, plus storage and backups), the CloudFront distribution minimum charges, and the S3 bucket storage. Lambda and the Function URL cost nothing when no requests arrive.
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") ?? "16.4";
const functionMemoryMB = config.getNumber("functionMemoryMB") ?? 512;
const websiteDistPath = config.get("websiteDistPath") ?? "./website/dist";
const apiHandlerPath = config.get("apiHandlerPath") ?? "./api/dist";
const landingZone = new pulumi.StackReference(landingZoneStackName);
const vpcId = landingZone.requireOutput("networkId") as pulumi.Output<string>;
const privateSubnetIds = landingZone.requireOutput("privateSubnetIds") as pulumi.Output<string[]>;
const secretsStore = landingZone.requireOutput("secretsStore") as pulumi.Output<string>;
const projectName = `${pulumi.getStack()}-serverless-react-postgres`;
const commonTags: Record<string, string> = {
environment: pulumi.getStack(),
"solution-family": "serverless-react-postgres",
cloud: "aws",
language: "typescript",
};
const database = new Database("db", {
vpcId,
privateSubnetIds,
secretsStore,
engineVersion: dbEngineVersion,
namePrefix: projectName,
tags: commonTags,
});
const edge = new Edge("edge", {
databaseSecretArn: database.secretArn,
databaseSecurityGroupId: database.securityGroupId,
vpcId,
privateSubnetIds,
websiteDistPath,
apiHandlerPath,
functionMemoryMB,
namePrefix: projectName,
tags: commonTags,
});
export const siteUrl = edge.siteUrl;
export const apiUrl = edge.apiUrl;
export const dbSecretId = database.secretArn;
export const cloudfrontDistributionId = edge.distributionId;
export const lambdaFunctionName = edge.functionName;
export const dbClusterArn = database.clusterArn;
export const bucketName = edge.bucketName;
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 "16.4"
function_memory_mb = config.get_int("functionMemoryMB") or 512
website_dist_path = config.get("websiteDistPath") or "./website/dist"
api_handler_path = config.get("apiHandlerPath") or "./api/dist"
landing_zone = pulumi.StackReference(landing_zone_stack_name)
vpc_id = landing_zone.require_output("networkId")
private_subnet_ids = landing_zone.require_output("privateSubnetIds")
secrets_store = landing_zone.require_output("secretsStore")
project_name = f"{pulumi.get_stack()}-serverless-react-postgres"
common_tags = {
"environment": pulumi.get_stack(),
"solution-family": "serverless-react-postgres",
"cloud": "aws",
"language": "python",
}
database = Database(
"db",
DatabaseArgs(
vpc_id=vpc_id,
private_subnet_ids=private_subnet_ids,
secrets_store=secrets_store,
engine_version=db_engine_version,
name_prefix=project_name,
tags=common_tags,
),
)
edge = Edge(
"edge",
EdgeArgs(
database_secret_arn=database.secret_arn,
database_security_group_id=database.security_group_id,
vpc_id=vpc_id,
private_subnet_ids=private_subnet_ids,
website_dist_path=website_dist_path,
api_handler_path=api_handler_path,
function_memory_mb=function_memory_mb,
name_prefix=project_name,
tags=common_tags,
),
)
pulumi.export("siteUrl", edge.site_url)
pulumi.export("apiUrl", edge.api_url)
pulumi.export("dbSecretId", database.secret_arn)
pulumi.export("cloudfrontDistributionId", edge.distribution_id)
pulumi.export("lambdaFunctionName", edge.function_name)
pulumi.export("dbClusterArn", database.cluster_arn)
pulumi.export("bucketName", edge.bucket_name)
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-aws/database"
"serverless-react-postgres-aws/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 = "16.4"
}
functionMemoryMB := cfg.GetInt("functionMemoryMB")
if functionMemoryMB == 0 {
functionMemoryMB = 512
}
websiteDistPath := cfg.Get("websiteDistPath")
if websiteDistPath == "" {
websiteDistPath = "./website/dist"
}
apiHandlerPath := cfg.Get("apiHandlerPath")
if apiHandlerPath == "" {
apiHandlerPath = "./api/dist"
}
landingZone, err := pulumi.NewStackReference(ctx, landingZoneStackName, nil)
if err != nil {
return err
}
vpcId := landingZone.GetStringOutput(pulumi.String("networkId"))
privateSubnetIds := landingZone.GetOutput(pulumi.String("privateSubnetIds")).ApplyT(func(v interface{}) []string {
return castStringSlice(v)
}).(pulumi.StringArrayOutput)
secretsStore := landingZone.GetStringOutput(pulumi.String("secretsStore"))
projectName := fmt.Sprintf("%s-serverless-react-postgres", ctx.Stack())
tags := pulumi.StringMap{
"environment": pulumi.String(ctx.Stack()),
"solution-family": pulumi.String("serverless-react-postgres"),
"cloud": pulumi.String("aws"),
"language": pulumi.String("go"),
}
db, err := database.New(ctx, "db", &database.Args{
VpcId: vpcId,
PrivateSubnetIds: privateSubnetIds,
SecretsStore: secretsStore,
EngineVersion: pulumi.String(dbEngineVersion),
NamePrefix: pulumi.String(projectName),
Tags: tags,
})
if err != nil {
return err
}
ed, err := edge.New(ctx, "edge", &edge.Args{
DatabaseSecretArn: db.SecretArn,
DatabaseSecurityGroupId: db.SecurityGroupId,
VpcId: vpcId,
PrivateSubnetIds: privateSubnetIds,
WebsiteDistPath: websiteDistPath,
ApiHandlerPath: apiHandlerPath,
FunctionMemoryMB: pulumi.Int(functionMemoryMB),
NamePrefix: pulumi.String(projectName),
Tags: tags,
})
if err != nil {
return err
}
ctx.Export("siteUrl", ed.SiteUrl)
ctx.Export("apiUrl", ed.ApiUrl)
ctx.Export("dbSecretId", db.SecretArn)
ctx.Export("cloudfrontDistributionId", ed.DistributionId)
ctx.Export("lambdaFunctionName", ed.FunctionName)
ctx.Export("dbClusterArn", db.ClusterArn)
ctx.Export("bucketName", ed.BucketName)
ctx.Export("escEnvironment", pulumi.Sprintf("%s-serverless-react-postgres", ctx.Stack()))
return nil
}
func castStringSlice(v interface{}) []string {
if v == nil {
return nil
}
raw, ok := v.([]interface{})
if !ok {
return nil
}
result := make([]string, 0, len(raw))
for _, item := range raw {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
}
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 Aurora Serverless v2 instance on the landing-zone private network, generates a strong database password, and stores it in AWS Secrets Manager for the function to read.
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
import * as random from "@pulumi/random";
export interface DatabaseArgs {
vpcId: pulumi.Input<string>;
privateSubnetIds: pulumi.Input<pulumi.Input<string>[]>;
secretsStore: pulumi.Input<string>;
engineVersion: pulumi.Input<string>;
namePrefix: pulumi.Input<string>;
tags: Record<string, string>;
}
export class Database extends pulumi.ComponentResource {
public readonly clusterArn: pulumi.Output<string>;
public readonly secretArn: pulumi.Output<string>;
public readonly securityGroupId: pulumi.Output<string>;
public readonly databaseName: pulumi.Output<string>;
constructor(name: string, args: DatabaseArgs, opts?: pulumi.ComponentResourceOptions) {
super("serverless-react-postgres:aws:Database", name, {}, opts);
const parent = { parent: this };
const databaseName = "appdb";
const masterUsername = "pgadmin";
const password = new random.RandomPassword(`${name}-password`, {
length: 32,
special: false,
}, parent);
const securityGroup = new aws.ec2.SecurityGroup(`${name}-sg`, {
vpcId: args.vpcId,
description: "Aurora Serverless v2 PostgreSQL access",
tags: args.tags,
}, parent);
const subnetGroup = new aws.rds.SubnetGroup(`${name}-subnets`, {
subnetIds: args.privateSubnetIds,
description: "Private subnets for Aurora Serverless v2 PostgreSQL",
tags: args.tags,
}, parent);
const cluster = new aws.rds.Cluster(`${name}-cluster`, {
engine: aws.rds.EngineType.AuroraPostgresql,
engineMode: "provisioned",
engineVersion: args.engineVersion,
databaseName: databaseName,
masterUsername: masterUsername,
masterPassword: password.result,
dbSubnetGroupName: subnetGroup.name,
vpcSecurityGroupIds: [securityGroup.id],
storageEncrypted: true,
skipFinalSnapshot: true,
serverlessv2ScalingConfiguration: {
minCapacity: 0,
maxCapacity: 2,
},
tags: args.tags,
}, parent);
const instance = new aws.rds.ClusterInstance(`${name}-instance`, {
clusterIdentifier: cluster.id,
instanceClass: "db.serverless",
engine: aws.rds.EngineType.AuroraPostgresql,
engineVersion: cluster.engineVersion,
dbSubnetGroupName: subnetGroup.name,
publiclyAccessible: false,
tags: args.tags,
}, parent);
const connectionUrl = pulumi.all([
password.result,
cluster.endpoint,
]).apply(([pw, host]) =>
`postgresql://${masterUsername}:${encodeURIComponent(pw)}@${host}:5432/${databaseName}?sslmode=require`,
);
const secretName = pulumi.interpolate`${args.secretsStore}/${args.namePrefix}/database-url`;
const secret = new aws.secretsmanager.Secret(`${name}-secret`, {
name: secretName,
description: "Aurora Serverless v2 PostgreSQL connection URL",
recoveryWindowInDays: 0,
tags: args.tags,
}, { ...parent, dependsOn: [instance] });
const secretVersion = new aws.secretsmanager.SecretVersion(`${name}-secret-version`, {
secretId: secret.id,
secretString: connectionUrl.apply((url) => JSON.stringify({ DATABASE_URL: url })),
}, parent);
this.clusterArn = cluster.arn;
this.secretArn = secret.arn;
this.securityGroupId = securityGroup.id;
this.databaseName = pulumi.output(databaseName);
this.registerOutputs({
clusterArn: this.clusterArn,
secretArn: this.secretArn,
securityGroupId: this.securityGroupId,
databaseName: this.databaseName,
});
}
}
components/edge.ts
Provisions the AWS Lambda function that runs the API, uploads the SPA to object storage, and wires Amazon CloudFront so /* serves the SPA and /api/* reaches the function.
import * as fs from "fs";
import * as path from "path";
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
export interface EdgeArgs {
databaseSecretArn: pulumi.Input<string>;
databaseSecurityGroupId: pulumi.Input<string>;
vpcId: pulumi.Input<string>;
privateSubnetIds: pulumi.Input<pulumi.Input<string>[]>;
websiteDistPath: string;
apiHandlerPath: string;
functionMemoryMB: pulumi.Input<number>;
namePrefix: pulumi.Input<string>;
tags: 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;
}
const MANAGED_CACHING_OPTIMIZED_ID = "658327ea-f89d-4fab-a63d-7e88639e58f6";
const MANAGED_CACHING_DISABLED_ID = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
const MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ID = "b689b0a8-53d0-40ab-baf2-68738e2966ac";
export class Edge extends pulumi.ComponentResource {
public readonly siteUrl: pulumi.Output<string>;
public readonly apiUrl: pulumi.Output<string>;
public readonly distributionId: pulumi.Output<string>;
public readonly functionName: pulumi.Output<string>;
public readonly bucketName: pulumi.Output<string>;
constructor(name: string, args: EdgeArgs, opts?: pulumi.ComponentResourceOptions) {
super("serverless-react-postgres:aws:Edge", name, {}, opts);
const parent = { parent: this };
const lambdaSecurityGroup = new aws.ec2.SecurityGroup(`${name}-fn-sg`, {
vpcId: args.vpcId,
description: "Egress for Lambda to reach Aurora PostgreSQL",
tags: args.tags,
}, parent);
new aws.vpc.SecurityGroupEgressRule(`${name}-fn-egress-db`, {
securityGroupId: lambdaSecurityGroup.id,
ipProtocol: "tcp",
fromPort: 5432,
toPort: 5432,
referencedSecurityGroupId: args.databaseSecurityGroupId,
description: "PostgreSQL to DB",
}, parent);
new aws.vpc.SecurityGroupIngressRule(`${name}-db-ingress-fn`, {
securityGroupId: args.databaseSecurityGroupId,
ipProtocol: "tcp",
fromPort: 5432,
toPort: 5432,
referencedSecurityGroupId: lambdaSecurityGroup.id,
description: "PostgreSQL from Lambda",
}, parent);
const lambdaRole = new aws.iam.Role(`${name}-fn-role`, {
assumeRolePolicy: JSON.stringify({
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Principal: { Service: "lambda.amazonaws.com" },
Action: "sts:AssumeRole",
}],
}),
tags: args.tags,
}, parent);
new aws.iam.RolePolicyAttachment(`${name}-fn-vpc-access`, {
role: lambdaRole.name,
policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole",
}, parent);
new aws.iam.RolePolicy(`${name}-fn-secret`, {
role: lambdaRole.id,
policy: pulumi.output(args.databaseSecretArn).apply((arn) => JSON.stringify({
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Action: ["secretsmanager:GetSecretValue"],
Resource: arn,
}],
})),
}, parent);
const lambdaFunction = new aws.lambda.Function(`${name}-fn`, {
role: lambdaRole.arn,
runtime: aws.lambda.Runtime.NodeJS20dX,
handler: "handler.handler",
code: new pulumi.asset.FileArchive(args.apiHandlerPath),
memorySize: args.functionMemoryMB,
timeout: 30,
environment: {
variables: {
SECRET_ARN: pulumi.output(args.databaseSecretArn),
},
},
vpcConfig: {
subnetIds: args.privateSubnetIds,
securityGroupIds: [lambdaSecurityGroup.id],
},
tags: args.tags,
}, parent);
const functionUrl = new aws.lambda.FunctionUrl(`${name}-fn-url`, {
functionName: lambdaFunction.name,
authorizationType: "NONE",
}, parent);
const functionUrlHost = functionUrl.functionUrl.apply((url) => {
const stripped = url.replace(/^https:\/\//, "");
return stripped.replace(/\/$/, "");
});
const bucket = new aws.s3.BucketV2(`${name}-site`, {
forceDestroy: true,
tags: args.tags,
}, parent);
new aws.s3.BucketOwnershipControls(`${name}-site-ownership`, {
bucket: bucket.id,
rule: { objectOwnership: "BucketOwnerEnforced" },
}, parent);
new aws.s3.BucketPublicAccessBlock(`${name}-site-pab`, {
bucket: bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
}, 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 aws.s3.BucketObjectv2(`${name}-site-${urlSafeKey}`, {
bucket: bucket.id,
key,
source: new pulumi.asset.FileAsset(file),
contentType: contentTypeFor(file),
}, parent);
}
const originAccessControl = new aws.cloudfront.OriginAccessControl(`${name}-oac`, {
originAccessControlOriginType: "s3",
signingBehavior: "always",
signingProtocol: "sigv4",
}, parent);
const s3OriginId = "s3-site";
const apiOriginId = "lambda-api";
const distribution = new aws.cloudfront.Distribution(`${name}-cdn`, {
enabled: true,
isIpv6Enabled: true,
defaultRootObject: "index.html",
priceClass: "PriceClass_100",
origins: [
{
originId: s3OriginId,
domainName: bucket.bucketRegionalDomainName,
originAccessControlId: originAccessControl.id,
s3OriginConfig: {
originAccessIdentity: "",
},
},
{
originId: apiOriginId,
domainName: functionUrlHost,
customOriginConfig: {
httpPort: 80,
httpsPort: 443,
originProtocolPolicy: "https-only",
originSslProtocols: ["TLSv1.2"],
},
},
],
defaultCacheBehavior: {
targetOriginId: s3OriginId,
viewerProtocolPolicy: "redirect-to-https",
allowedMethods: ["GET", "HEAD"],
cachedMethods: ["GET", "HEAD"],
compress: true,
cachePolicyId: MANAGED_CACHING_OPTIMIZED_ID,
},
orderedCacheBehaviors: [
{
pathPattern: "/api/*",
targetOriginId: apiOriginId,
viewerProtocolPolicy: "redirect-to-https",
allowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
cachedMethods: ["GET", "HEAD"],
compress: true,
cachePolicyId: MANAGED_CACHING_DISABLED_ID,
originRequestPolicyId: MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ID,
},
],
customErrorResponses: [
{ errorCode: 403, responseCode: 200, responsePagePath: "/index.html" },
{ errorCode: 404, responseCode: 200, responsePagePath: "/index.html" },
],
restrictions: {
geoRestriction: { restrictionType: "none" },
},
viewerCertificate: {
cloudfrontDefaultCertificate: true,
},
tags: args.tags,
}, parent);
const bucketPolicyDocument = aws.iam.getPolicyDocumentOutput({
statements: [
{
sid: "AllowCloudFrontServicePrincipal",
effect: "Allow",
principals: [{
type: "Service",
identifiers: ["cloudfront.amazonaws.com"],
}],
actions: ["s3:GetObject"],
resources: [pulumi.interpolate`${bucket.arn}/*`],
conditions: [{
test: "StringEquals",
variable: "AWS:SourceArn",
values: [distribution.arn],
}],
},
] as aws.types.input.iam.GetPolicyDocumentStatementArgs[],
});
new aws.s3.BucketPolicy(`${name}-site-policy`, {
bucket: bucket.id,
policy: bucketPolicyDocument.json,
}, parent);
this.siteUrl = distribution.domainName.apply((d) => `https://${d}`);
this.apiUrl = distribution.domainName.apply((d) => `https://${d}/api`);
this.distributionId = distribution.id;
this.functionName = lambdaFunction.name;
this.bucketName = bucket.bucket;
this.registerOutputs({
siteUrl: this.siteUrl,
apiUrl: this.apiUrl,
distributionId: this.distributionId,
functionName: this.functionName,
bucketName: this.bucketName,
});
}
}
components/database.py
Provisions the Aurora Serverless v2 instance on the landing-zone private network, generates a strong database password, and stores it in AWS Secrets Manager for the function to read.
import json
from dataclasses import dataclass, field
from typing import Mapping, Sequence
from urllib.parse import quote
import pulumi
import pulumi_aws as aws
import pulumi_random as random
@dataclass
class DatabaseArgs:
vpc_id: pulumi.Input[str]
private_subnet_ids: pulumi.Input[Sequence[pulumi.Input[str]]]
secrets_store: pulumi.Input[str]
engine_version: pulumi.Input[str]
name_prefix: pulumi.Input[str]
tags: 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:aws:Database", name, None, opts)
parent = pulumi.ResourceOptions(parent=self)
database_name = "appdb"
master_username = "pgadmin"
password = random.RandomPassword(
f"{name}-password",
length=32,
special=False,
opts=parent,
)
security_group = aws.ec2.SecurityGroup(
f"{name}-sg",
vpc_id=args.vpc_id,
description="Aurora Serverless v2 PostgreSQL access",
tags=dict(args.tags),
opts=parent,
)
subnet_group = aws.rds.SubnetGroup(
f"{name}-subnets",
subnet_ids=args.private_subnet_ids,
description="Private subnets for Aurora Serverless v2 PostgreSQL",
tags=dict(args.tags),
opts=parent,
)
cluster = aws.rds.Cluster(
f"{name}-cluster",
engine="aurora-postgresql",
engine_mode="provisioned",
engine_version=args.engine_version,
database_name=database_name,
master_username=master_username,
master_password=password.result,
db_subnet_group_name=subnet_group.name,
vpc_security_group_ids=[security_group.id],
storage_encrypted=True,
skip_final_snapshot=True,
serverlessv2_scaling_configuration=aws.rds.ClusterServerlessv2ScalingConfigurationArgs(
min_capacity=0,
max_capacity=2,
),
tags=dict(args.tags),
opts=parent,
)
instance = aws.rds.ClusterInstance(
f"{name}-instance",
cluster_identifier=cluster.id,
instance_class="db.serverless",
engine="aurora-postgresql",
engine_version=cluster.engine_version,
db_subnet_group_name=subnet_group.name,
publicly_accessible=False,
tags=dict(args.tags),
opts=parent,
)
def _build_url(values):
pw, host = values
return f"postgresql://{master_username}:{quote(pw, safe='')}@{host}:5432/{database_name}?sslmode=require"
connection_url = pulumi.Output.all(password.result, cluster.endpoint).apply(_build_url)
secret_name = pulumi.Output.all(args.secrets_store, args.name_prefix).apply(
lambda vs: f"{vs[0]}/{vs[1]}/database-url"
)
secret = aws.secretsmanager.Secret(
f"{name}-secret",
name=secret_name,
description="Aurora Serverless v2 PostgreSQL connection URL",
recovery_window_in_days=0,
tags=dict(args.tags),
opts=pulumi.ResourceOptions(parent=self, depends_on=[instance]),
)
aws.secretsmanager.SecretVersion(
f"{name}-secret-version",
secret_id=secret.id,
secret_string=connection_url.apply(lambda url: json.dumps({"DATABASE_URL": url})),
opts=parent,
)
self.cluster_arn = cluster.arn
self.secret_arn = secret.arn
self.security_group_id = security_group.id
self.database_name = pulumi.Output.from_input(database_name)
self.register_outputs({
"cluster_arn": self.cluster_arn,
"secret_arn": self.secret_arn,
"security_group_id": self.security_group_id,
"database_name": self.database_name,
})
components/edge.py
Provisions the AWS Lambda function that runs the API, uploads the SPA to object storage, and wires Amazon CloudFront so /* serves the SPA and /api/* reaches the function.
import json
import os
import re
from dataclasses import dataclass, field
from typing import Mapping, Sequence
import pulumi
import pulumi_aws as aws
_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",
}
_MANAGED_CACHING_OPTIMIZED_ID = "658327ea-f89d-4fab-a63d-7e88639e58f6"
_MANAGED_CACHING_DISABLED_ID = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
_MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ID = "b689b0a8-53d0-40ab-baf2-68738e2966ac"
_SAFE_KEY_RE = re.compile(r"[^A-Za-z0-9._-]")
def _content_type_for(path: str) -> str:
_, ext = os.path.splitext(path)
return _MIME_TYPES.get(ext.lower(), "application/octet-stream")
@dataclass
class EdgeArgs:
database_secret_arn: pulumi.Input[str]
database_security_group_id: pulumi.Input[str]
vpc_id: pulumi.Input[str]
private_subnet_ids: pulumi.Input[Sequence[pulumi.Input[str]]]
website_dist_path: str
api_handler_path: str
function_memory_mb: pulumi.Input[int]
name_prefix: pulumi.Input[str]
tags: Mapping[str, str] = field(default_factory=dict)
class Edge(pulumi.ComponentResource):
def __init__(self, name: str, args: EdgeArgs, opts: pulumi.ResourceOptions | None = None):
super().__init__("serverless-react-postgres:aws:Edge", name, None, opts)
parent = pulumi.ResourceOptions(parent=self)
lambda_security_group = aws.ec2.SecurityGroup(
f"{name}-fn-sg",
vpc_id=args.vpc_id,
description="Egress for Lambda to reach Aurora PostgreSQL",
tags=dict(args.tags),
opts=parent,
)
aws.vpc.SecurityGroupEgressRule(
f"{name}-fn-egress-db",
security_group_id=lambda_security_group.id,
ip_protocol="tcp",
from_port=5432,
to_port=5432,
referenced_security_group_id=args.database_security_group_id,
description="PostgreSQL to DB",
opts=parent,
)
aws.vpc.SecurityGroupIngressRule(
f"{name}-db-ingress-fn",
security_group_id=args.database_security_group_id,
ip_protocol="tcp",
from_port=5432,
to_port=5432,
referenced_security_group_id=lambda_security_group.id,
description="PostgreSQL from Lambda",
opts=parent,
)
lambda_role = aws.iam.Role(
f"{name}-fn-role",
assume_role_policy=json.dumps({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole",
}],
}),
tags=dict(args.tags),
opts=parent,
)
aws.iam.RolePolicyAttachment(
f"{name}-fn-vpc-access",
role=lambda_role.name,
policy_arn="arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole",
opts=parent,
)
aws.iam.RolePolicy(
f"{name}-fn-secret",
role=lambda_role.id,
policy=pulumi.Output.from_input(args.database_secret_arn).apply(lambda arn: json.dumps({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": arn,
}],
})),
opts=parent,
)
lambda_function = aws.lambda_.Function(
f"{name}-fn",
role=lambda_role.arn,
runtime="nodejs20.x",
handler="handler.handler",
code=pulumi.FileArchive(args.api_handler_path),
memory_size=args.function_memory_mb,
timeout=30,
environment=aws.lambda_.FunctionEnvironmentArgs(
variables={
"SECRET_ARN": pulumi.Output.from_input(args.database_secret_arn),
},
),
vpc_config=aws.lambda_.FunctionVpcConfigArgs(
subnet_ids=args.private_subnet_ids,
security_group_ids=[lambda_security_group.id],
),
tags=dict(args.tags),
opts=parent,
)
function_url = aws.lambda_.FunctionUrl(
f"{name}-fn-url",
function_name=lambda_function.name,
authorization_type="NONE",
opts=parent,
)
def _strip_scheme(url: str) -> str:
if url.startswith("https://"):
url = url[len("https://"):]
return url.rstrip("/")
function_url_host = function_url.function_url.apply(_strip_scheme)
bucket = aws.s3.BucketV2(
f"{name}-site",
force_destroy=True,
tags=dict(args.tags),
opts=parent,
)
aws.s3.BucketOwnershipControls(
f"{name}-site-ownership",
bucket=bucket.id,
rule=aws.s3.BucketOwnershipControlsRuleArgs(object_ownership="BucketOwnerEnforced"),
opts=parent,
)
aws.s3.BucketPublicAccessBlock(
f"{name}-site-pab",
bucket=bucket.id,
block_public_acls=True,
block_public_policy=True,
ignore_public_acls=True,
restrict_public_buckets=True,
opts=parent,
)
for root, _dirs, files in os.walk(args.website_dist_path):
for file_name in files:
full_path = os.path.join(root, file_name)
rel_path = os.path.relpath(full_path, args.website_dist_path).replace(os.sep, "/")
safe_suffix = _SAFE_KEY_RE.sub("_", rel_path)
aws.s3.BucketObjectv2(
f"{name}-site-{safe_suffix}",
bucket=bucket.id,
key=rel_path,
source=pulumi.FileAsset(full_path),
content_type=_content_type_for(full_path),
opts=parent,
)
origin_access_control = aws.cloudfront.OriginAccessControl(
f"{name}-oac",
origin_access_control_origin_type="s3",
signing_behavior="always",
signing_protocol="sigv4",
opts=parent,
)
s3_origin_id = "s3-site"
api_origin_id = "lambda-api"
distribution = aws.cloudfront.Distribution(
f"{name}-cdn",
enabled=True,
is_ipv6_enabled=True,
default_root_object="index.html",
price_class="PriceClass_100",
origins=[
aws.cloudfront.DistributionOriginArgs(
origin_id=s3_origin_id,
domain_name=bucket.bucket_regional_domain_name,
origin_access_control_id=origin_access_control.id,
s3_origin_config=aws.cloudfront.DistributionOriginS3OriginConfigArgs(
origin_access_identity="",
),
),
aws.cloudfront.DistributionOriginArgs(
origin_id=api_origin_id,
domain_name=function_url_host,
custom_origin_config=aws.cloudfront.DistributionOriginCustomOriginConfigArgs(
http_port=80,
https_port=443,
origin_protocol_policy="https-only",
origin_ssl_protocols=["TLSv1.2"],
),
),
],
default_cache_behavior=aws.cloudfront.DistributionDefaultCacheBehaviorArgs(
target_origin_id=s3_origin_id,
viewer_protocol_policy="redirect-to-https",
allowed_methods=["GET", "HEAD"],
cached_methods=["GET", "HEAD"],
compress=True,
cache_policy_id=_MANAGED_CACHING_OPTIMIZED_ID,
),
ordered_cache_behaviors=[
aws.cloudfront.DistributionOrderedCacheBehaviorArgs(
path_pattern="/api/*",
target_origin_id=api_origin_id,
viewer_protocol_policy="redirect-to-https",
allowed_methods=["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
cached_methods=["GET", "HEAD"],
compress=True,
cache_policy_id=_MANAGED_CACHING_DISABLED_ID,
origin_request_policy_id=_MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ID,
),
],
custom_error_responses=[
aws.cloudfront.DistributionCustomErrorResponseArgs(
error_code=403, response_code=200, response_page_path="/index.html",
),
aws.cloudfront.DistributionCustomErrorResponseArgs(
error_code=404, response_code=200, response_page_path="/index.html",
),
],
restrictions=aws.cloudfront.DistributionRestrictionsArgs(
geo_restriction=aws.cloudfront.DistributionRestrictionsGeoRestrictionArgs(
restriction_type="none",
),
),
viewer_certificate=aws.cloudfront.DistributionViewerCertificateArgs(
cloudfront_default_certificate=True,
),
tags=dict(args.tags),
opts=parent,
)
bucket_policy_document = aws.iam.get_policy_document_output(
statements=[
aws.iam.GetPolicyDocumentStatementArgs(
sid="AllowCloudFrontServicePrincipal",
effect="Allow",
principals=[aws.iam.GetPolicyDocumentStatementPrincipalArgs(
type="Service",
identifiers=["cloudfront.amazonaws.com"],
)],
actions=["s3:GetObject"],
resources=[bucket.arn.apply(lambda arn: f"{arn}/*")],
conditions=[aws.iam.GetPolicyDocumentStatementConditionArgs(
test="StringEquals",
variable="AWS:SourceArn",
values=[distribution.arn],
)],
),
],
)
aws.s3.BucketPolicy(
f"{name}-site-policy",
bucket=bucket.id,
policy=bucket_policy_document.json,
opts=parent,
)
self.site_url = distribution.domain_name.apply(lambda d: f"https://{d}")
self.api_url = distribution.domain_name.apply(lambda d: f"https://{d}/api")
self.distribution_id = distribution.id
self.function_name = lambda_function.name
self.bucket_name = bucket.bucket
self.register_outputs({
"site_url": self.site_url,
"api_url": self.api_url,
"distribution_id": self.distribution_id,
"function_name": self.function_name,
"bucket_name": self.bucket_name,
})
database/database.go
Provisions the Aurora Serverless v2 instance on the landing-zone private network, generates a strong database password, and stores it in AWS Secrets Manager for the function to read.
package database
import (
"encoding/json"
"fmt"
"net/url"
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/ec2"
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/rds"
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/secretsmanager"
"github.com/pulumi/pulumi-random/sdk/v4/go/random"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
type Args struct {
VpcId pulumi.StringInput
PrivateSubnetIds pulumi.StringArrayInput
SecretsStore pulumi.StringInput
EngineVersion pulumi.StringInput
NamePrefix pulumi.StringInput
Tags pulumi.StringMapInput
}
type Database struct {
pulumi.ResourceState
ClusterArn pulumi.StringOutput
SecretArn pulumi.StringOutput
SecurityGroupId pulumi.StringOutput
DatabaseName 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:aws:Database", name, component, opts...); err != nil {
return nil, err
}
parent := pulumi.Parent(component)
const databaseName = "appdb"
const masterUsername = "pgadmin"
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
}
securityGroup, err := ec2.NewSecurityGroup(ctx, fmt.Sprintf("%s-sg", name), &ec2.SecurityGroupArgs{
VpcId: args.VpcId,
Description: pulumi.String("Aurora Serverless v2 PostgreSQL access"),
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
subnetGroup, err := rds.NewSubnetGroup(ctx, fmt.Sprintf("%s-subnets", name), &rds.SubnetGroupArgs{
SubnetIds: args.PrivateSubnetIds,
Description: pulumi.String("Private subnets for Aurora Serverless v2 PostgreSQL"),
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
cluster, err := rds.NewCluster(ctx, fmt.Sprintf("%s-cluster", name), &rds.ClusterArgs{
Engine: pulumi.String("aurora-postgresql"),
EngineMode: pulumi.String("provisioned"),
EngineVersion: args.EngineVersion,
DatabaseName: pulumi.String(databaseName),
MasterUsername: pulumi.String(masterUsername),
MasterPassword: password.Result,
DbSubnetGroupName: subnetGroup.Name,
VpcSecurityGroupIds: pulumi.StringArray{securityGroup.ID().ToStringOutput()},
StorageEncrypted: pulumi.Bool(true),
SkipFinalSnapshot: pulumi.Bool(true),
Serverlessv2ScalingConfiguration: &rds.ClusterServerlessv2ScalingConfigurationArgs{
MinCapacity: pulumi.Float64(0),
MaxCapacity: pulumi.Float64(2),
},
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
instance, err := rds.NewClusterInstance(ctx, fmt.Sprintf("%s-instance", name), &rds.ClusterInstanceArgs{
ClusterIdentifier: cluster.ID(),
InstanceClass: pulumi.String("db.serverless"),
Engine: rds.EngineType("aurora-postgresql"),
EngineVersion: cluster.EngineVersion,
DbSubnetGroupName: subnetGroup.Name,
PubliclyAccessible: pulumi.Bool(false),
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
connectionUrl := pulumi.All(password.Result, cluster.Endpoint).ApplyT(func(vs []interface{}) string {
pw := vs[0].(string)
host := vs[1].(string)
return fmt.Sprintf("postgresql://%s:%s@%s:5432/%s?sslmode=require",
masterUsername, url.QueryEscape(pw), host, databaseName)
}).(pulumi.StringOutput)
secretName := pulumi.Sprintf("%s/%s/database-url", args.SecretsStore, args.NamePrefix)
secret, err := secretsmanager.NewSecret(ctx, fmt.Sprintf("%s-secret", name), &secretsmanager.SecretArgs{
Name: secretName,
Description: pulumi.String("Aurora Serverless v2 PostgreSQL connection URL"),
RecoveryWindowInDays: pulumi.Int(0),
Tags: args.Tags,
}, pulumi.Parent(component), pulumi.DependsOn([]pulumi.Resource{instance}))
if err != nil {
return nil, err
}
secretString := connectionUrl.ApplyT(func(u string) (string, error) {
data, err := json.Marshal(map[string]string{"DATABASE_URL": u})
if err != nil {
return "", err
}
return string(data), nil
}).(pulumi.StringOutput)
if _, err := secretsmanager.NewSecretVersion(ctx, fmt.Sprintf("%s-secret-version", name), &secretsmanager.SecretVersionArgs{
SecretId: secret.ID(),
SecretString: secretString,
}, parent); err != nil {
return nil, err
}
component.ClusterArn = cluster.Arn
component.SecretArn = secret.Arn
component.SecurityGroupId = securityGroup.ID().ToStringOutput()
component.DatabaseName = pulumi.String(databaseName).ToStringOutput()
if err := ctx.RegisterResourceOutputs(component, pulumi.Map{
"clusterArn": component.ClusterArn,
"secretArn": component.SecretArn,
"securityGroupId": component.SecurityGroupId,
"databaseName": component.DatabaseName,
}); err != nil {
return nil, err
}
return component, nil
}
edge/edge.go
Provisions the AWS Lambda function that runs the API, uploads the SPA to object storage, and wires Amazon CloudFront so /* serves the SPA and /api/* reaches the function.
package edge
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/cloudfront"
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/ec2"
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/iam"
awslambda "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/lambda"
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/s3"
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/vpc"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
type Args struct {
DatabaseSecretArn pulumi.StringInput
DatabaseSecurityGroupId pulumi.StringInput
VpcId pulumi.StringInput
PrivateSubnetIds pulumi.StringArrayInput
WebsiteDistPath string
ApiHandlerPath string
FunctionMemoryMB pulumi.IntInput
NamePrefix pulumi.StringInput
Tags pulumi.StringMapInput
}
type Edge struct {
pulumi.ResourceState
SiteUrl pulumi.StringOutput
ApiUrl pulumi.StringOutput
DistributionId pulumi.StringOutput
FunctionName pulumi.StringOutput
BucketName pulumi.StringOutput
}
const (
managedCachingOptimizedID = "658327ea-f89d-4fab-a63d-7e88639e58f6"
managedCachingDisabledID = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
managedAllViewerExceptHostHeaderID = "b689b0a8-53d0-40ab-baf2-68738e2966ac"
s3OriginID = "s3-site"
apiOriginID = "lambda-api"
)
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._-]`)
func contentTypeFor(path string) string {
if ct, ok := mimeTypes[strings.ToLower(filepath.Ext(path))]; ok {
return ct
}
return "application/octet-stream"
}
func New(ctx *pulumi.Context, name string, args *Args, opts ...pulumi.ResourceOption) (*Edge, error) {
component := &Edge{}
if err := ctx.RegisterComponentResource("serverless-react-postgres:aws:Edge", name, component, opts...); err != nil {
return nil, err
}
parent := pulumi.Parent(component)
lambdaSecurityGroup, err := ec2.NewSecurityGroup(ctx, fmt.Sprintf("%s-fn-sg", name), &ec2.SecurityGroupArgs{
VpcId: args.VpcId,
Description: pulumi.String("Egress for Lambda to reach Aurora PostgreSQL"),
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
if _, err := vpc.NewSecurityGroupEgressRule(ctx, fmt.Sprintf("%s-fn-egress-db", name), &vpc.SecurityGroupEgressRuleArgs{
SecurityGroupId: lambdaSecurityGroup.ID(),
IpProtocol: pulumi.String("tcp"),
FromPort: pulumi.Int(5432),
ToPort: pulumi.Int(5432),
ReferencedSecurityGroupId: args.DatabaseSecurityGroupId,
Description: pulumi.String("PostgreSQL to DB"),
}, parent); err != nil {
return nil, err
}
if _, err := vpc.NewSecurityGroupIngressRule(ctx, fmt.Sprintf("%s-db-ingress-fn", name), &vpc.SecurityGroupIngressRuleArgs{
SecurityGroupId: args.DatabaseSecurityGroupId,
IpProtocol: pulumi.String("tcp"),
FromPort: pulumi.Int(5432),
ToPort: pulumi.Int(5432),
ReferencedSecurityGroupId: lambdaSecurityGroup.ID().ToStringOutput(),
Description: pulumi.String("PostgreSQL from Lambda"),
}, parent); err != nil {
return nil, err
}
lambdaRole, err := iam.NewRole(ctx, fmt.Sprintf("%s-fn-role", name), &iam.RoleArgs{
AssumeRolePolicy: pulumi.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}`),
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
if _, err := iam.NewRolePolicyAttachment(ctx, fmt.Sprintf("%s-fn-vpc-access", name), &iam.RolePolicyAttachmentArgs{
Role: lambdaRole.Name,
PolicyArn: pulumi.String("arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"),
}, parent); err != nil {
return nil, err
}
secretPolicy := args.DatabaseSecretArn.ToStringOutput().ApplyT(func(arn string) (string, error) {
doc := map[string]any{
"Version": "2012-10-17",
"Statement": []map[string]any{{
"Effect": "Allow",
"Action": []string{"secretsmanager:GetSecretValue"},
"Resource": arn,
}},
}
data, err := json.Marshal(doc)
if err != nil {
return "", err
}
return string(data), nil
}).(pulumi.StringOutput)
if _, err := iam.NewRolePolicy(ctx, fmt.Sprintf("%s-fn-secret", name), &iam.RolePolicyArgs{
Role: lambdaRole.ID(),
Policy: secretPolicy,
}, parent); err != nil {
return nil, err
}
lambdaFunction, err := awslambda.NewFunction(ctx, fmt.Sprintf("%s-fn", name), &awslambda.FunctionArgs{
Role: lambdaRole.Arn,
Runtime: pulumi.String("nodejs20.x"),
Handler: pulumi.String("handler.handler"),
Code: pulumi.NewFileArchive(args.ApiHandlerPath),
MemorySize: args.FunctionMemoryMB,
Timeout: pulumi.Int(30),
Environment: &awslambda.FunctionEnvironmentArgs{
Variables: pulumi.StringMap{
"SECRET_ARN": args.DatabaseSecretArn,
},
},
VpcConfig: &awslambda.FunctionVpcConfigArgs{
SubnetIds: args.PrivateSubnetIds,
SecurityGroupIds: pulumi.StringArray{lambdaSecurityGroup.ID().ToStringOutput()},
},
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
functionUrl, err := awslambda.NewFunctionUrl(ctx, fmt.Sprintf("%s-fn-url", name), &awslambda.FunctionUrlArgs{
FunctionName: lambdaFunction.Name,
AuthorizationType: pulumi.String("NONE"),
}, parent)
if err != nil {
return nil, err
}
functionUrlHost := functionUrl.FunctionUrl.ApplyT(func(u string) string {
u = strings.TrimPrefix(u, "https://")
u = strings.TrimSuffix(u, "/")
return u
}).(pulumi.StringOutput)
bucket, err := s3.NewBucketV2(ctx, fmt.Sprintf("%s-site", name), &s3.BucketV2Args{
ForceDestroy: pulumi.Bool(true),
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
if _, err := s3.NewBucketOwnershipControls(ctx, fmt.Sprintf("%s-site-ownership", name), &s3.BucketOwnershipControlsArgs{
Bucket: bucket.ID(),
Rule: &s3.BucketOwnershipControlsRuleArgs{
ObjectOwnership: pulumi.String("BucketOwnerEnforced"),
},
}, parent); err != nil {
return nil, err
}
if _, err := s3.NewBucketPublicAccessBlock(ctx, fmt.Sprintf("%s-site-pab", name), &s3.BucketPublicAccessBlockArgs{
Bucket: bucket.ID(),
BlockPublicAcls: pulumi.Bool(true),
BlockPublicPolicy: pulumi.Bool(true),
IgnorePublicAcls: pulumi.Bool(true),
RestrictPublicBuckets: pulumi.Bool(true),
}, 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 := s3.NewBucketObjectv2(ctx, fmt.Sprintf("%s-site-%s", name, safe), &s3.BucketObjectv2Args{
Bucket: bucket.ID(),
Key: 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
}
originAccessControl, err := cloudfront.NewOriginAccessControl(ctx, fmt.Sprintf("%s-oac", name), &cloudfront.OriginAccessControlArgs{
OriginAccessControlOriginType: pulumi.String("s3"),
SigningBehavior: pulumi.String("always"),
SigningProtocol: pulumi.String("sigv4"),
}, parent)
if err != nil {
return nil, err
}
distribution, err := cloudfront.NewDistribution(ctx, fmt.Sprintf("%s-cdn", name), &cloudfront.DistributionArgs{
Enabled: pulumi.Bool(true),
IsIpv6Enabled: pulumi.Bool(true),
DefaultRootObject: pulumi.String("index.html"),
PriceClass: pulumi.String("PriceClass_100"),
Origins: cloudfront.DistributionOriginArray{
&cloudfront.DistributionOriginArgs{
OriginId: pulumi.String(s3OriginID),
DomainName: bucket.BucketRegionalDomainName,
OriginAccessControlId: originAccessControl.ID(),
S3OriginConfig: &cloudfront.DistributionOriginS3OriginConfigArgs{
OriginAccessIdentity: pulumi.String(""),
},
},
&cloudfront.DistributionOriginArgs{
OriginId: pulumi.String(apiOriginID),
DomainName: functionUrlHost,
CustomOriginConfig: &cloudfront.DistributionOriginCustomOriginConfigArgs{
HttpPort: pulumi.Int(80),
HttpsPort: pulumi.Int(443),
OriginProtocolPolicy: pulumi.String("https-only"),
OriginSslProtocols: pulumi.StringArray{pulumi.String("TLSv1.2")},
},
},
},
DefaultCacheBehavior: &cloudfront.DistributionDefaultCacheBehaviorArgs{
TargetOriginId: pulumi.String(s3OriginID),
ViewerProtocolPolicy: pulumi.String("redirect-to-https"),
AllowedMethods: pulumi.StringArray{pulumi.String("GET"), pulumi.String("HEAD")},
CachedMethods: pulumi.StringArray{pulumi.String("GET"), pulumi.String("HEAD")},
Compress: pulumi.Bool(true),
CachePolicyId: pulumi.String(managedCachingOptimizedID),
},
OrderedCacheBehaviors: cloudfront.DistributionOrderedCacheBehaviorArray{
&cloudfront.DistributionOrderedCacheBehaviorArgs{
PathPattern: pulumi.String("/api/*"),
TargetOriginId: pulumi.String(apiOriginID),
ViewerProtocolPolicy: pulumi.String("redirect-to-https"),
AllowedMethods: pulumi.StringArray{
pulumi.String("GET"), pulumi.String("HEAD"), pulumi.String("OPTIONS"),
pulumi.String("PUT"), pulumi.String("POST"), pulumi.String("PATCH"), pulumi.String("DELETE"),
},
CachedMethods: pulumi.StringArray{pulumi.String("GET"), pulumi.String("HEAD")},
Compress: pulumi.Bool(true),
CachePolicyId: pulumi.String(managedCachingDisabledID),
OriginRequestPolicyId: pulumi.String(managedAllViewerExceptHostHeaderID),
},
},
CustomErrorResponses: cloudfront.DistributionCustomErrorResponseArray{
&cloudfront.DistributionCustomErrorResponseArgs{
ErrorCode: pulumi.Int(403),
ResponseCode: pulumi.Int(200),
ResponsePagePath: pulumi.String("/index.html"),
},
&cloudfront.DistributionCustomErrorResponseArgs{
ErrorCode: pulumi.Int(404),
ResponseCode: pulumi.Int(200),
ResponsePagePath: pulumi.String("/index.html"),
},
},
Restrictions: &cloudfront.DistributionRestrictionsArgs{
GeoRestriction: &cloudfront.DistributionRestrictionsGeoRestrictionArgs{
RestrictionType: pulumi.String("none"),
},
},
ViewerCertificate: &cloudfront.DistributionViewerCertificateArgs{
CloudfrontDefaultCertificate: pulumi.Bool(true),
},
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
bucketPolicyDocument := iam.GetPolicyDocumentOutput(ctx, iam.GetPolicyDocumentOutputArgs{
Statements: iam.GetPolicyDocumentStatementArray{
&iam.GetPolicyDocumentStatementArgs{
Sid: pulumi.String("AllowCloudFrontServicePrincipal"),
Effect: pulumi.String("Allow"),
Principals: iam.GetPolicyDocumentStatementPrincipalArray{
&iam.GetPolicyDocumentStatementPrincipalArgs{
Type: pulumi.String("Service"),
Identifiers: pulumi.StringArray{pulumi.String("cloudfront.amazonaws.com")},
},
},
Actions: pulumi.StringArray{pulumi.String("s3:GetObject")},
Resources: pulumi.StringArray{
bucket.Arn.ApplyT(func(arn string) string { return arn + "/*" }).(pulumi.StringOutput),
},
Conditions: iam.GetPolicyDocumentStatementConditionArray{
&iam.GetPolicyDocumentStatementConditionArgs{
Test: pulumi.String("StringEquals"),
Variable: pulumi.String("AWS:SourceArn"),
Values: pulumi.StringArray{distribution.Arn},
},
},
},
},
})
if _, err := s3.NewBucketPolicy(ctx, fmt.Sprintf("%s-site-policy", name), &s3.BucketPolicyArgs{
Bucket: bucket.ID(),
Policy: bucketPolicyDocument.Json(),
}, parent); err != nil {
return nil, err
}
component.SiteUrl = distribution.DomainName.ApplyT(func(d string) string { return "https://" + d }).(pulumi.StringOutput)
component.ApiUrl = distribution.DomainName.ApplyT(func(d string) string { return "https://" + d + "/api" }).(pulumi.StringOutput)
component.DistributionId = distribution.ID().ToStringOutput()
component.FunctionName = lambdaFunction.Name
component.BucketName = bucket.Bucket
if err := ctx.RegisterResourceOutputs(component, pulumi.Map{
"siteUrl": component.SiteUrl,
"apiUrl": component.ApiUrl,
"distributionId": component.DistributionId,
"functionName": component.FunctionName,
"bucketName": component.BucketName,
}); err != nil {
return nil, err
}
return component, nil
}