Skip to content

Commit a286a07

Browse files
authored
feat(cloudflare/hyperdrive): connect to private databases through Workers VPC (#2218)
1 parent 615690b commit a286a07

21 files changed

Lines changed: 1039 additions & 88 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Hyperdrive + Workers VPC + Aurora
2+
3+
[Guide](https://alchemy.run/cloudflare/data/hyperdrive-private-database/)
4+
5+
A Cloudflare Worker queries Aurora PostgreSQL in private AWS subnets through Hyperdrive. Nothing in the VPC accepts inbound traffic: `cloudflared` runs on ECS Fargate and dials out to Cloudflare.
6+
7+
```
8+
Worker → Hyperdrive → VPC Service → Tunnel → cloudflared (ECS) → Aurora
9+
```
10+
11+
- [src/Database.ts](./src/Database.ts) — VPC, security groups, Aurora
12+
- [src/Connector.ts](./src/Connector.ts) — Cloudflare Tunnel + `cloudflared` on ECS
13+
- [src/Hyperdrive.ts](./src/Hyperdrive.ts) — TCP VPC Service + Hyperdrive
14+
- [src/worker.ts](./src/worker.ts) — the Worker
15+
16+
## Deploy
17+
18+
From the repository root:
19+
20+
```sh
21+
pnpm install
22+
cd examples/cloudflare-hyperdrive-aws-vpc
23+
pnpm run deploy --profile testing --stage hyperdrive-vpc
24+
curl "<url>"
25+
```
26+
27+
Aurora incurs charges until destroyed.
28+
29+
## Destroy
30+
31+
```sh
32+
pnpm run destroy --profile testing --stage hyperdrive-vpc
33+
```
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as Alchemy from "alchemy";
2+
import * as AWS from "alchemy/AWS";
3+
import * as Cloudflare from "alchemy/Cloudflare";
4+
import * as Effect from "effect/Effect";
5+
import * as Layer from "effect/Layer";
6+
import { Connector } from "./src/Connector.ts";
7+
import { Hyperdrive } from "./src/Hyperdrive.ts";
8+
9+
export default Alchemy.Stack(
10+
"CloudflareHyperdriveAwsVpc",
11+
{
12+
providers: Layer.mergeAll(AWS.providers(), Cloudflare.providers(), Alchemy.RandomProvider()),
13+
state: Alchemy.localState(),
14+
},
15+
Effect.gen(function* () {
16+
yield* Connector;
17+
const hd = yield* Hyperdrive;
18+
const worker = yield* Cloudflare.Worker("Api", {
19+
main: "./src/worker.ts",
20+
compatibility: { flags: ["nodejs_compat"] },
21+
env: { DB: hd },
22+
});
23+
return {
24+
url: worker.url,
25+
hyperdriveId: hd.hyperdriveId,
26+
};
27+
}),
28+
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "cloudflare-hyperdrive-aws-vpc",
3+
"version": "0.0.0",
4+
"private": true,
5+
"license": "Apache-2.0",
6+
"repository": {
7+
"type": "git",
8+
"url": "git+https://github.com/alchemy-run/alchemy.git",
9+
"directory": "examples/cloudflare-hyperdrive-aws-vpc"
10+
},
11+
"type": "module",
12+
"scripts": {
13+
"build": "tsc -b",
14+
"deploy": "alchemy deploy",
15+
"destroy": "alchemy destroy"
16+
},
17+
"dependencies": {
18+
"@cloudflare/workers-types": "catalog:",
19+
"alchemy": "workspace:*",
20+
"effect": "catalog:",
21+
"pg": "catalog:"
22+
},
23+
"devDependencies": {
24+
"@types/pg": "catalog:"
25+
}
26+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as AWS from "alchemy/AWS";
2+
import * as Cloudflare from "alchemy/Cloudflare";
3+
import * as Effect from "effect/Effect";
4+
import { ConnectorSecurityGroup, Network } from "./Database.ts";
5+
6+
/** The Cloudflare Tunnel that carries Hyperdrive's traffic into the VPC. */
7+
export const Tunnel = Cloudflare.Tunnel.Tunnel("Tunnel", {
8+
ingress: [{ service: "http_status:404" }],
9+
});
10+
11+
/**
12+
* Runs `cloudflared` inside the VPC on ECS Fargate. It dials out to
13+
* Cloudflare, so the VPC needs no inbound rules. The tunnel token is kept in
14+
* Secrets Manager and injected as `TUNNEL_TOKEN`.
15+
*/
16+
export const Connector = Effect.gen(function* () {
17+
const network = yield* Network;
18+
const securityGroup = yield* ConnectorSecurityGroup;
19+
const tunnel = yield* Tunnel;
20+
const token = yield* AWS.SecretsManager.Secret("TunnelToken", {
21+
secretString: tunnel.token,
22+
});
23+
const cluster = yield* AWS.ECS.Cluster("Cluster", {});
24+
return yield* AWS.ECS.Service("Cloudflared", {
25+
cluster,
26+
image: "cloudflare/cloudflared:latest",
27+
command: ["tunnel", "--no-autoupdate", "run"],
28+
secrets: { TUNNEL_TOKEN: token.secretArn },
29+
vpcId: network.vpcId,
30+
subnets: network.publicSubnetIds,
31+
securityGroups: [securityGroup.groupId],
32+
assignPublicIp: true,
33+
});
34+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import * as Alchemy from "alchemy";
2+
import * as AWS from "alchemy/AWS";
3+
import * as Output from "alchemy/Output";
4+
import * as Effect from "effect/Effect";
5+
import * as Redacted from "effect/Redacted";
6+
7+
export const DB_USER = "app";
8+
export const DB_NAME = "app";
9+
10+
/**
11+
* A VPC with public subnets (for the tunnel connector) and private subnets
12+
* (for Aurora). Nothing in the private subnets is reachable from the internet.
13+
*/
14+
export const Network = AWS.EC2.Network("Network", {
15+
cidrBlock: "10.63.0.0/16",
16+
availabilityZones: 2,
17+
});
18+
19+
/** Security group for the `cloudflared` connector — outbound only. */
20+
export const ConnectorSecurityGroup = Effect.gen(function* () {
21+
const network = yield* Network;
22+
return yield* AWS.EC2.SecurityGroup("ConnectorSecurityGroup", {
23+
vpcId: network.vpcId,
24+
description: "cloudflared connector; no inbound connections",
25+
});
26+
});
27+
28+
/** Database password, generated once and kept in state. */
29+
export const DbPassword = Alchemy.Random("DbPassword", { bytes: 24 });
30+
31+
/** Private Aurora PostgreSQL that only admits the connector. */
32+
export const Database = Effect.gen(function* () {
33+
const network = yield* Network;
34+
const connector = yield* ConnectorSecurityGroup;
35+
const password = yield* DbPassword;
36+
const dbSecurityGroup = yield* AWS.EC2.SecurityGroup("DatabaseSecurityGroup", {
37+
vpcId: network.vpcId,
38+
description: "Postgres from the cloudflared connector only",
39+
ingress: [
40+
{
41+
ipProtocol: "tcp",
42+
fromPort: 5432,
43+
toPort: 5432,
44+
referencedGroupId: connector.groupId,
45+
},
46+
],
47+
});
48+
const secret = yield* AWS.SecretsManager.Secret("DatabaseSecret", {
49+
secretString: Output.map(password.text, (password) =>
50+
Redacted.make(JSON.stringify({ username: DB_USER, password: Redacted.value(password) })),
51+
),
52+
});
53+
return yield* AWS.RDS.Aurora("Database", {
54+
databaseName: DB_NAME,
55+
engine: "aurora-postgresql",
56+
engineVersion: "17.5",
57+
subnetIds: network.privateSubnetIds,
58+
securityGroupIds: [dbSecurityGroup.groupId],
59+
secret: { resource: secret },
60+
instance: { dbInstanceClass: "db.serverless", publiclyAccessible: false },
61+
});
62+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as Cloudflare from "alchemy/Cloudflare";
2+
import * as Output from "alchemy/Output";
3+
import * as Effect from "effect/Effect";
4+
import { Connector, Tunnel } from "./Connector.ts";
5+
import { DB_NAME, DB_USER, Database, DbPassword } from "./Database.ts";
6+
7+
/**
8+
* A TCP VPC Service for the Aurora writer endpoint. `cloudflared` resolves
9+
* the hostname with the VPC's DNS, so the private endpoint works as-is.
10+
*/
11+
export const AuroraService = Effect.gen(function* () {
12+
const tunnel = yield* Tunnel;
13+
const connector = yield* Connector;
14+
const db = yield* Database;
15+
// Hyperdrive connects to the database when it is created, so the path
16+
// must be live first: wait for the writer instance and the connector.
17+
const hostname = Output.map(
18+
Output.all(db.cluster.endpoint, db.writer.dbInstanceArn, connector.serviceArn),
19+
([endpoint]) => endpoint!,
20+
);
21+
return yield* Cloudflare.Connectivity.DirectoryService("AuroraService", {
22+
type: "tcp",
23+
tcpPort: 5432,
24+
appProtocol: "postgresql",
25+
host: {
26+
hostname,
27+
resolverNetwork: { tunnelId: tunnel.tunnelId },
28+
},
29+
// Aurora's certificate is issued by the Amazon RDS CA, which Workers VPC
30+
// does not trust yet (custom CAs are not supported). The connection is
31+
// still TLS-encrypted.
32+
tlsSettings: { certVerificationMode: "disabled" },
33+
});
34+
});
35+
36+
/** Hyperdrive egresses through the VPC Service to reach Aurora. */
37+
export const Hyperdrive = Effect.gen(function* () {
38+
const service = yield* AuroraService;
39+
const password = yield* DbPassword;
40+
return yield* Cloudflare.Hyperdrive.Connection("Hyperdrive", {
41+
origin: {
42+
scheme: "postgres",
43+
serviceId: service.serviceId,
44+
database: DB_NAME,
45+
user: DB_USER,
46+
password: password.text,
47+
},
48+
});
49+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Hyperdrive } from "@cloudflare/workers-types";
2+
import { Client } from "pg";
3+
4+
/** Queries the private Aurora database through Hyperdrive. */
5+
export default {
6+
async fetch(_request: Request, env: { DB: Hyperdrive }): Promise<Response> {
7+
const client = new Client({ connectionString: env.DB.connectionString });
8+
try {
9+
await client.connect();
10+
const result = await client.query("select version()");
11+
return Response.json(result.rows[0]);
12+
} catch (error) {
13+
return new Response(String(error), { status: 500 });
14+
} finally {
15+
await client.end().catch(() => {});
16+
}
17+
},
18+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"include": ["alchemy.run.ts", "src/**/*.ts"],
4+
"compilerOptions": {
5+
"noEmit": true,
6+
"rootDir": ".",
7+
"module": "Preserve",
8+
"moduleResolution": "Bundler",
9+
"target": "ESNext"
10+
},
11+
"references": [{ "path": "../../packages/alchemy/tsconfig.json" }]
12+
}

‎packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts‎

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type * as runtime from "@cloudflare/workers-types";
2+
import * as Data from "effect/Data";
23
import * as Effect from "effect/Effect";
34
import * as Layer from "effect/Layer";
45
import * as Redacted from "effect/Redacted";
@@ -7,7 +8,7 @@ import { defaultProviderMode, type ProviderMode } from "../../ProviderMode.ts";
78
import { Worker, WorkerEnvironment } from "../Workers/Worker.ts";
89
import { Connect, type ConnectClient } from "./Connect.ts";
910
import type { Connection } from "./Connection.ts";
10-
import { defaultPort, type DevOrigin } from "./Connection.ts";
11+
import { defaultPort, type DevOrigin, type Mtls, type Origin } from "./Connection.ts";
1112

1213
export const ConnectBinding = Layer.effect(
1314
Connect,
@@ -63,38 +64,63 @@ export const getHyperdriveDevOriginForHost = Effect.fn(function* (
6364
return mode === "local" ? getHyperdriveDevOrigin(connection) : undefined;
6465
});
6566

67+
/**
68+
* A Hyperdrive origin that `alchemy dev` cannot reach from the local
69+
* machine, bound without a `dev` override.
70+
*/
71+
export class HyperdriveDevOriginUnsupported extends Data.TaggedError(
72+
"HyperdriveDevOriginUnsupported",
73+
)<{
74+
message: string;
75+
logicalId: string;
76+
origin: "access" | "vpc";
77+
}> {}
78+
6679
export const getHyperdriveDevOrigin = (connection: Connection) => {
67-
const origin = Output.map(
68-
Output.all(connection.dev, connection.origin, connection.mtls),
69-
([dev, origin, mtls]): Required<DevOrigin> => {
70-
if (dev) {
71-
return {
72-
scheme: dev.scheme,
73-
host: dev.host,
74-
port: dev.port ?? defaultPort(dev.scheme),
75-
user: dev.user,
76-
database: dev.database,
77-
password: dev.password,
78-
sslmode: dev.sslmode ?? "prefer",
79-
};
80-
}
81-
if ("accessClientId" in origin) {
82-
throw new Error(
83-
`Hyperdrive instance ${connection.LogicalId} has an origin that requires Cloudflare Access. This is not supported in development mode. ` +
80+
const origin = Output.mapEffect(([dev, origin, mtls]: [DevOrigin | undefined, Origin, Mtls]) => {
81+
if (dev) {
82+
return Effect.succeed<Required<DevOrigin>>({
83+
scheme: dev.scheme,
84+
host: dev.host,
85+
port: dev.port ?? defaultPort(dev.scheme),
86+
user: dev.user,
87+
database: dev.database,
88+
password: dev.password,
89+
sslmode: dev.sslmode ?? "prefer",
90+
});
91+
}
92+
if ("accessClientId" in origin) {
93+
return Effect.die(
94+
new HyperdriveDevOriginUnsupported({
95+
logicalId: connection.LogicalId,
96+
origin: "access",
97+
message:
98+
`Hyperdrive instance ${connection.LogicalId} has an origin that requires Cloudflare Access. This is not supported in development mode. ` +
8499
"Select a different origin or set the `dev` property to an origin that does not require Cloudflare Access.",
85-
);
86-
}
87-
return {
88-
scheme: origin.scheme,
89-
host: origin.host,
90-
port: origin.port ?? defaultPort(origin.scheme),
91-
user: origin.user,
92-
database: origin.database,
93-
password: origin.password,
94-
sslmode: mtls?.sslmode ?? "require",
95-
};
96-
},
97-
);
100+
}),
101+
);
102+
}
103+
if ("serviceId" in origin) {
104+
return Effect.die(
105+
new HyperdriveDevOriginUnsupported({
106+
logicalId: connection.LogicalId,
107+
origin: "vpc",
108+
message:
109+
`Hyperdrive instance ${connection.LogicalId} has an origin that is reached through a Workers VPC Service. This is not supported in development mode. ` +
110+
"Set the `dev` property to an origin reachable from your machine.",
111+
}),
112+
);
113+
}
114+
return Effect.succeed<Required<DevOrigin>>({
115+
scheme: origin.scheme,
116+
host: origin.host,
117+
port: origin.port ?? defaultPort(origin.scheme),
118+
user: origin.user,
119+
database: origin.database,
120+
password: origin.password,
121+
sslmode: mtls?.sslmode ?? "require",
122+
});
123+
})(Output.all(connection.dev, connection.origin, connection.mtls));
98124
return Output.map(
99125
Output.all(connection.hyperdriveId, Output.asOutput(origin)),
100126
([id, origin]) => ({

0 commit comments

Comments
 (0)