Skip to content
GitHub Discord

Cloudflare Hyperdrive with AWS Postgres

Connect a Cloudflare Worker to an AWS RDS Postgres database through Cloudflare Hyperdrive. Since RDS lives in a private VPC, a Cloudflare tunnel runs on Fargate to expose the database to Hyperdrive. Cloudflare Access locks the tunnel down to Hyperdrive using a service token.

The worker uses the sst.cloudflare.Hyperdrive binding. The lambda connects directly through the VPC for comparison.

sst.config.ts
const domain = "sst-dev.org";
const vpc = new sst.aws.Vpc("Vpc", { nat: "managed" });
const cluster = new sst.aws.Cluster("Cluster", { vpc });
const postgres = new sst.aws.Postgres("Postgres", { vpc });
const zone = cloudflare.getZoneOutput({ filter: { name: domain } });
const tunnelSecret = new random.RandomString("TunnelSecret", {
length: 32,
});
const tunnel = new cloudflare.ZeroTrustTunnelCloudflared("Tunnel", {
accountId: sst.cloudflare.DEFAULT_ACCOUNT_ID,
name: `${$app.name}-${$app.stage}-tunnel`,
tunnelSecret: tunnelSecret.result.apply((v) =>
Buffer.from(v).toString("base64"),
),
});
const record = new cloudflare.DnsRecord("TunnelRecord", {
name: `hyperdrive-${$app.stage}.${domain}`,
ttl: 1,
type: "CNAME",
zoneId: zone.zoneId,
content: $interpolate`${tunnel.id}.cfargotunnel.com`,
proxied: true,
});
const tunnelConfig = new cloudflare.ZeroTrustTunnelCloudflaredConfig(
"TunnelConfig",
{
accountId: sst.cloudflare.DEFAULT_ACCOUNT_ID,
tunnelId: tunnel.id,
config: {
ingresses: [
{
hostname: record.name,
service: $interpolate`tcp://${postgres.host}:${postgres.port}`,
},
{ service: "http_status:404" },
],
},
},
);
const tunnelToken = cloudflare.getZeroTrustTunnelCloudflaredTokenOutput({
accountId: sst.cloudflare.DEFAULT_ACCOUNT_ID,
tunnelId: tunnel.id,
}).token;
const serviceToken = new cloudflare.ZeroTrustAccessServiceToken(
"HyperdriveServiceToken",
{
name: `${$app.name}-${$app.stage}-hyperdrive-token`,
accountId: sst.cloudflare.DEFAULT_ACCOUNT_ID,
},
);
new cloudflare.ZeroTrustAccessApplication("HyperdriveAccess", {
accountId: sst.cloudflare.DEFAULT_ACCOUNT_ID,
type: "self_hosted",
name: `${$app.name}-${$app.stage}-hyperdrive`,
domain: record.name,
destinations: [{ uri: record.name, type: "public" }],
appLauncherVisible: false,
policies: [
{
decision: "non_identity",
includes: [
{ serviceToken: { tokenId: serviceToken.id } },
],
name: `${$app.name}-${$app.stage}-hyperdrive-policy`,
},
],
});
const cloudflaredService = new sst.aws.Service(
"Cloudflared",
{
wait: true,
capacity: "spot",
cluster,
containers: [
{
name: "cloudflared",
image: "cloudflare/cloudflared:latest",
command: ["tunnel", "run"],
environment: {
TUNNEL_TOKEN: tunnelToken,
TUNNEL_METRICS: "0.0.0.0:20241",
},
health: {
command: [
"CMD",
"cloudflared",
"tunnel",
"--metrics",
"localhost:20241",
"ready",
],
startPeriod: "60 seconds",
timeout: "5 seconds",
interval: "30 seconds",
retries: 3,
},
dev: {
autostart: true,
command: $interpolate`docker run \
--rm \
-e TUNNEL_LOGLEVEL=info \
--network ${$app.name} \
--name ${$app.name}-${$app.stage}-cloudflared \
cloudflare/cloudflared:latest \
tunnel run --token ${tunnelToken}`,
},
},
],
},
// Make sure the tunnel's ingress rules exist before the container starts,
// otherwise cloudflared serves 503s until it re-polls the config.
{ dependsOn: [tunnelConfig] },
);
const hyperdrive = new sst.cloudflare.Hyperdrive(
"Database",
{
origin: {
host: record.name,
user: postgres.username,
password: postgres.password,
database: postgres.database,
accessClientId: serviceToken.clientId,
accessClientSecret: serviceToken.clientSecret,
scheme: "postgres",
},
},
{
dependsOn: [
postgres,
tunnel,
record,
tunnelConfig,
cloudflaredService,
cluster,
],
},
);
const worker = new sst.cloudflare.Worker("Worker", {
handler: "./worker.ts",
link: [hyperdrive],
url: true,
});
const lambda = new sst.aws.Function("Lambda", {
handler: "lambda.handler",
link: [postgres],
vpc,
url: true,
});
worker.ts
import postgres from "postgres";
import { Resource } from "sst";
export default {
async fetch(_request: Request, _env: unknown, ctx: ExecutionContext) {
const sql = postgres(Resource.Database.connectionString);
try {
const now = Date.now();
const result = await sql`select * from pg_tables limit 10`;
const delay = Date.now() - now;
ctx.waitUntil(sql.end());
return Response.json({ delay, result });
} catch (e: any) {
console.log(e);
return Response.json({ error: e.message }, { status: 500 });
}
},
};
lambda.ts
import postgres from "postgres";
import { Resource } from "sst";
const sql = postgres(
`postgres://${Resource.Postgres.username}:${Resource.Postgres.password}@${Resource.Postgres.host}:${Resource.Postgres.port}/${Resource.Postgres.database}`,
);
export async function handler() {
try {
const now = Date.now();
const result = await sql`select * from pg_tables limit 10`;
const delay = Date.now() - now;
return {
statusCode: 200,
body: JSON.stringify({ result, delay }),
};
} catch (e: any) {
return {
statusCode: 200,
body: JSON.stringify({ error: e.message }),
};
}
}

View the full example.