Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 113 additions & 0 deletions examples/cloudflare-aurora-hyperdrive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# cloudflare-aurora-hyperdrive

A Cloudflare Worker reaching an AWS RDS **Aurora** database through
**Hyperdrive**, with Drizzle migrations applied at deploy time via the RDS Data
API. Aurora lives in a VPC, so there are two ways to let Hyperdrive reach it —
documented here as alternative paths with their trade-offs.

The example is split into an **infra stack** (AWS Aurora + the Hyperdrive
config) and an **app stack** (the Cloudflare Worker). The Worker binds the
Hyperdrive **by reference** (`Cloudflare.Hyperdrive.ref(id, { stack })`), so the
app stack — and the Worker bundle — stay Cloudflare-only; no AWS code or
credentials leak into the Worker. Deploy the infra stack first, then the app
stack.

```
src/schema.ts Drizzle schema (Users/Posts)
src/handler.ts shared CRUD handler (Drizzle over Hyperdrive)
src/Aurora.ts shared: VPC + Aurora + Drizzle.Schema + RDS.Schema (Data API migrations)
src/names.ts shared stack names + Hyperdrive logical id
src/PublicDb.ts public path: Aurora (public) + Hyperdrive (public origin)
src/PublicApi.ts public path: Worker binding Hyperdrive.ref(...)
public-infra.ts public path: infra stack
public-app.ts public path: app stack
test/integ.test.ts deploys infra → app, drives CRUD over HTTP, destroys
```

## Path A — public endpoint + firewall (implemented)

The Aurora cluster is publicly accessible; its security group allows the
database port from Cloudflare, and Hyperdrive connects to the public endpoint
directly.

```ts
Cloudflare.Hyperdrive("AppHyperdrive", {
origin: {
scheme: "postgres",
host: cluster.endpoint, // public writer endpoint
port: cluster.port,
database: "app",
user: "app",
password,
},
});
```

- **Pros:** simplest setup; lowest latency (Hyperdrive connects straight to RDS).
- **Cons:** the database has a public endpoint. Mitigate by restricting the
security-group ingress to [Hyperdrive's published egress IP ranges](https://developers.cloudflare.com/hyperdrive/configuration/firewall-and-networking-settings/)
rather than `0.0.0.0/0`, and require TLS.
- **Best for:** when a (firewalled) public endpoint is acceptable.

Run it:

```sh
DB_PASSWORD=<password> bun test # deploy infra → app → CRUD → destroy
```

## Path B — Cloudflare Tunnel + Access (private, recommended for production)

Keep Aurora fully private (no public endpoint). Run `cloudflared` inside the VPC
(e.g. as an ECS Fargate service) connected to a Cloudflare Tunnel that
TCP-routes a hostname to the Aurora endpoint, secure the hostname with a
Cloudflare Access self-hosted app + a Service-Auth policy + a service token, and
point Hyperdrive at an **Access-protected origin**:

```ts
Cloudflare.Hyperdrive("AppHyperdrive", {
origin: {
scheme: "postgres",
host: tunnelHostname, // no port for an Access origin
database: "app",
user: "app",
password,
accessClientId: token.clientId,
accessClientSecret: token.clientSecret,
},
dev: { /* public localhost origin for `alchemy dev` */ },
});
```

- **Pros:** the database never leaves the VPC; strongest isolation.
- **Cons:** you run and pay for `cloudflared` (ECS), and add a tunnel hop
(latency + connector cold-start).
- **Best for:** production databases that must stay private.

All the alchemy primitives exist (`Cloudflare.Tunnel`, `DnsRecord`,
`Access.Application/Policy/ServiceToken`, `Hyperdrive`'s Access origin, and
`AWS.ECS.*` for `cloudflared`). See Cloudflare's guide:
[Connect to a private database using Tunnel](https://developers.cloudflare.com/hyperdrive/configuration/connect-to-private-database/).

### Running the Tunnel path

```sh
DB_PASSWORD=<password> CLOUDFLARE_TEST_TUNNEL=1 bun test # gated; deploys cloudflared + tunnel + access
```

The Access hostname lives on a Cloudflare zone (`CLOUDFLARE_ZONE_NAME`,
default `alchemy-test-2.us`). That zone **must serve an active edge
certificate** covering the apex and `*.zone` — Hyperdrive validates the
connection at create time, and with no edge cert it fails with
`TLS handshake failed [HANDSHAKE_FAILURE_ON_CLIENT_HELLO]`.

Cloudflare's Universal SSL provides this by default. `bun nuke` can leave a
test zone with the setting reported enabled but **zero certificate packs**
(no edge cert). Re-order it once with:

```sh
ZONE=<zone> ALCHEMY_PROFILE=testing bun scripts/enable-universal-ssl.ts
```

> Cloudflare's newest option, [Workers VPC](https://developers.cloudflare.com/hyperdrive/configuration/connect-to-private-database-vpc/),
> is a cleaner future path; alchemy has a `Cloudflare.VpcService` resource, but
> Hyperdrive's origin can't yet reference it, so it's not wired here.
5 changes: 5 additions & 0 deletions examples/cloudflare-aurora-hyperdrive/cloudflared/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# cloudflared connector for the Tunnel path. The official image's ENTRYPOINT is
# `cloudflared --no-autoupdate`; `tunnel run` reads the connector token from the
# TUNNEL_TOKEN environment variable injected by the ECS task definition.
FROM --platform=linux/amd64 cloudflare/cloudflared:latest
CMD ["tunnel", "run"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE "posts" (
"id" serial PRIMARY KEY,
"user_id" integer NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);

--> statement-breakpoint
CREATE TABLE "users" (
"id" serial PRIMARY KEY,
"email" text NOT NULL UNIQUE,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);

--> statement-breakpoint
ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
{
"dialect": "postgres",
"id": "6b7086d4-87b5-40db-9764-6b989233aa5b",
"prevIds": ["00000000-0000-0000-0000-000000000000"],
"version": "8",
"ddl": [
{
"isRlsEnabled": false,
"name": "posts",
"entityType": "tables",
"schema": "public"
},
{
"isRlsEnabled": false,
"name": "users",
"entityType": "tables",
"schema": "public"
},
{
"type": "serial",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "id",
"entityType": "columns",
"schema": "public",
"table": "posts"
},
{
"type": "integer",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "user_id",
"entityType": "columns",
"schema": "public",
"table": "posts"
},
{
"type": "text",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "title",
"entityType": "columns",
"schema": "public",
"table": "posts"
},
{
"type": "text",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "body",
"entityType": "columns",
"schema": "public",
"table": "posts"
},
{
"type": "timestamp with time zone",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "now()",
"generated": null,
"identity": null,
"name": "created_at",
"entityType": "columns",
"schema": "public",
"table": "posts"
},
{
"type": "serial",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "id",
"entityType": "columns",
"schema": "public",
"table": "users"
},
{
"type": "text",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "email",
"entityType": "columns",
"schema": "public",
"table": "users"
},
{
"type": "text",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "name",
"entityType": "columns",
"schema": "public",
"table": "users"
},
{
"type": "timestamp with time zone",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "now()",
"generated": null,
"identity": null,
"name": "created_at",
"entityType": "columns",
"schema": "public",
"table": "users"
},
{
"nameExplicit": false,
"columns": ["user_id"],
"schemaTo": "public",
"tableTo": "users",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"name": "posts_user_id_users_id_fkey",
"entityType": "fks",
"schema": "public",
"table": "posts"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "posts_pkey",
"schema": "public",
"table": "posts",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "users_pkey",
"schema": "public",
"table": "users",
"entityType": "pks"
},
{
"nameExplicit": false,
"columns": ["email"],
"nullsNotDistinct": false,
"name": "users_email_key",
"schema": "public",
"table": "users",
"entityType": "uniques"
}
],
"renames": []
}
30 changes: 30 additions & 0 deletions examples/cloudflare-aurora-hyperdrive/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "cloudflare-aurora-hyperdrive",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/alchemy-run/alchemy-effect.git",
"directory": "examples/cloudflare-aurora-hyperdrive"
},
"type": "module",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@cloudflare/workers-types": "catalog:",
"@distilled.cloud/aws": "catalog:",
"@effect/platform-bun": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-pg": "catalog:",
"alchemy": "workspace:*",
"drizzle-orm": "1.0.0-rc.1",
"effect": "catalog:",
"pg": "^8.13.0"
},
"devDependencies": {
"@types/pg": "^8.11.0",
"drizzle-kit": "1.0.0-rc.1"
}
}
Loading
Loading