Skip to content
Open
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
241 changes: 241 additions & 0 deletions alchemy-web/src/content/docs/providers/cloudflare/vpc-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
---
title: VpcService
description: Connect Cloudflare Workers to private network services securely through Cloudflare Tunnel.
---

[Cloudflare VPC Services](https://developers.cloudflare.com/workers-vpc/configuration/vpc-services/) enable Workers to securely access private network resources through Cloudflare Tunnel.

## Minimal Example

Create a VPC service that routes to a local hostname through a tunnel:

```ts
import { Tunnel, VpcService } from "alchemy/cloudflare";

const tunnel = await Tunnel("my-tunnel", {
ingress: [{ service: "http://localhost:3000" }],
});

const vpcService = await VpcService("my-service", {
host: {
hostname: "localhost",
resolverNetwork: {
tunnel,
resolverIps: ["127.0.0.1"],
},
},
});
```

## With IPv4 Address

Route to a service using an IPv4 address:

```ts
import { Tunnel, VpcService } from "alchemy/cloudflare";

const tunnel = await Tunnel("internal-tunnel", {
ingress: [{ service: "http://192.168.1.100:8080" }],
});

const vpcService = await VpcService("internal-api", {
host: {
ipv4: "192.168.1.100",
network: { tunnel },
},
});
```

## With IPv6 Address

Route to a service using an IPv6 address:

```ts
import { Tunnel, VpcService } from "alchemy/cloudflare";

const tunnel = await Tunnel("ipv6-tunnel", {
ingress: [{ service: "http://[::1]:8080" }],
});

const vpcService = await VpcService("ipv6-service", {
host: {
ipv6: "::1",
network: { tunnel },
},
});
```

## With Dual Stack

Route to a service that supports both IPv4 and IPv6:

```ts
import { Tunnel, VpcService } from "alchemy/cloudflare";

const tunnel = await Tunnel("dual-stack-tunnel", {
ingress: [{ service: "http://localhost:8080" }],
});

const vpcService = await VpcService("dual-stack-service", {
host: {
ipv4: "192.168.1.100",
ipv6: "::1",
network: { tunnel },
},
});
```

## With Custom Ports

Configure custom HTTP and HTTPS ports:

```ts
import { Tunnel, VpcService } from "alchemy/cloudflare";

const tunnel = await Tunnel("custom-port-tunnel", {
ingress: [{ service: "http://localhost:5173" }],
});

const vpcService = await VpcService("dev-server", {
httpPort: 5173,
httpsPort: 5174,
host: {
hostname: "localhost",
resolverNetwork: {
tunnel,
resolverIps: ["127.0.0.1"],
},
},
});
```

## Bind to a Worker

Use a VPC service binding in a Cloudflare Worker to access private services:

```ts
import { Tunnel, VpcService, Worker } from "alchemy/cloudflare";

const tunnel = await Tunnel("api-tunnel", {
ingress: [{ service: "http://internal-api:8080" }],
});

const vpcService = await VpcService("private-api", {
httpPort: 8080,
host: {
hostname: "internal-api",
resolverNetwork: {
tunnel,
resolverIps: ["10.0.0.1"],
},
},
});

const worker = await Worker("api-gateway", {
entrypoint: "./src/worker.ts",
bindings: {
PRIVATE_API: vpcService,
},
});
```

Then in your Worker code, use the binding to fetch from the private service:

```ts
export default {
async fetch(request: Request, env: { PRIVATE_API: Fetcher }) {
// The VPC service routes this request through the tunnel
// to your private network
return await env.PRIVATE_API.fetch("http://internal-api/data");
},
};
```

:::note
The URL passed to `fetch()` affects HTTP headers and SNI values, but the actual routing is determined by the VPC Service configuration (host and ports).
:::

## With Existing Tunnel ID

Reference an existing tunnel by its ID instead of using a Tunnel resource:

```ts
import { VpcService } from "alchemy/cloudflare";

const vpcService = await VpcService("existing-tunnel-service", {
host: {
hostname: "internal.example.com",
resolverNetwork: {
tunnelId: "e6a0817c-79c5-40ca-9776-a1c019defe70",
resolverIps: ["10.0.0.53"],
},
},
});
```

## Adopting Existing Services

Take over management of an existing VPC service:

```ts
import { Tunnel, VpcService } from "alchemy/cloudflare";

const tunnel = await Tunnel("adopted-tunnel", {
ingress: [{ service: "http://localhost:3000" }],
});

const vpcService = await VpcService("adopted-service", {
name: "existing-service-name",
adopt: true,
host: {
hostname: "localhost",
resolverNetwork: { tunnel },
},
});
```

## Host Configuration Options

### Hostname Host

Use DNS resolution to reach the service:

| Property | Type | Description |
|----------|------|-------------|
| `hostname` | `string` | The hostname to resolve |
| `resolverNetwork.tunnel` | `Tunnel` | The tunnel resource to use |
| `resolverNetwork.tunnelId` | `string` | Alternative: existing tunnel ID |
| `resolverNetwork.resolverIps` | `string[]` | Optional DNS resolver IPs |

### IP Address Host

Use a direct IP address:

| Property | Type | Description |
|----------|------|-------------|
| `ipv4` | `string` | IPv4 address of the service |
| `ipv6` | `string` | IPv6 address of the service |
| `network.tunnel` | `Tunnel` | The tunnel resource to use |
| `network.tunnelId` | `string` | Alternative: existing tunnel ID |

## Port Configuration

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `httpPort` | `number` | 80 | Port for HTTP traffic |
| `httpsPort` | `number` | 443 | Port for HTTPS traffic |
| `tcpPort` | `number` | - | Port for TCP traffic (future support) |
| `appProtocol` | `string` | - | Application protocol identifier |

:::tip
VPC Services currently support HTTP service type. TCP support is planned for the future.
:::

## Access Control

To use VPC Services, users need the appropriate Cloudflare roles:

- **Bind to services**: Requires "Connectivity Directory Bind" role
- **Create/manage services**: Requires "Connectivity Directory Admin" role

If you use `alchemy login`, these scopes are included by default.
19 changes: 19 additions & 0 deletions alchemy/src/cloudflare/api-error.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { CloudflareApiErrorPayload } from "./api-response";

/**
* Custom error class for Cloudflare API errors
* Includes HTTP status information from the Response
Expand Down Expand Up @@ -100,3 +102,20 @@ export async function handleApiError(
// End of Selection
throw new CloudflareApiError(errorMessage, response, errors);
}

/**
* Helper function to check if an error is a Cloudflare API error.
* Optional match criteria can be provided to check for a specific HTTP status code or error code.
*/
export function isCloudflareApiError(
error: unknown,
match: { status?: number; code?: number } = {},
): error is CloudflareApiError & { errorData: CloudflareApiErrorPayload[] } {
return (
error instanceof CloudflareApiError &&
(match.status === undefined || error.status === match.status) &&
(match.code === undefined ||
(Array.isArray(error.errorData) &&
error.errorData.some((e) => "code" in e && e.code === match.code)))
);
}
19 changes: 16 additions & 3 deletions alchemy/src/cloudflare/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ import type { SecretRef as CloudflareSecretRef } from "./secret-ref.ts";
import type { Secret as CloudflareSecret } from "./secret.ts";
import type { VectorizeIndex } from "./vectorize-index.ts";
import type { VersionMetadata } from "./version-metadata.ts";
import type { VpcService } from "./vpc-service.ts";
import type { WorkerLoader } from "./worker-loader.ts";
import type { WorkerRef } from "./worker-ref.ts";
import type { WorkerStub } from "./worker-stub.ts";
import type { Worker } from "./worker.ts";
import type { WorkerLoader } from "./worker-loader.ts";
import type { Workflow } from "./workflow.ts";

export type Bindings = {
Expand Down Expand Up @@ -80,7 +81,8 @@ export type Binding =
| BrowserRendering
| VersionMetadata
| Self
| Json;
| Json
| VpcService;

export type Self<
RPC extends Rpc.WorkerEntrypointBranded = Rpc.WorkerEntrypointBranded,
Expand Down Expand Up @@ -141,7 +143,8 @@ export type WorkerBindingSpec =
| WorkerBindingVersionMetadata
| WorkerBindingWasmModule
| WorkerBindingWorkerLoader
| WorkerBindingWorkflow;
| WorkerBindingWorkflow
| WorkerBindingVpcService;

/**
* AI binding type
Expand Down Expand Up @@ -497,6 +500,16 @@ export interface WorkerBindingWorkflow {
script_name?: string;
}

export interface WorkerBindingVpcService {
/** The name of the binding */
name: string;
/** Type identifier for VPC Service binding */
type: "vpc_service";
/** VPC Service name */
service_name: string;
service_id: string;
}

/**
* Images binding type
*/
Expand Down
9 changes: 6 additions & 3 deletions alchemy/src/cloudflare/bound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { SecretRef as CloudflareSecretRef } from "./secret-ref.ts";
import type { Secret as CloudflareSecret } from "./secret.ts";
import type { VectorizeIndex as _VectorizeIndex } from "./vectorize-index.ts";
import type { VersionMetadata as _VersionMetadata } from "./version-metadata.ts";
import type { VpcService as _VpcService } from "./vpc-service.ts";
import type { WorkerLoader as _WorkerLoader } from "./worker-loader.ts";
import type { WorkerRef } from "./worker-ref.ts";
import type { WorkerStub } from "./worker-stub.ts";
Expand Down Expand Up @@ -99,6 +100,8 @@ export type Bound<T extends Binding> =
Obj &
Rpc.DurableObjectBranded
>
: T extends undefined
? undefined
: Service;
: T extends _VpcService
? Fetcher
: T extends undefined
? undefined
: Service;
1 change: 1 addition & 0 deletions alchemy/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export * from "./vectorize-index.ts";
export * from "./vectorize-metadata-index.ts";
export * from "./version-metadata.ts";
export * from "./vite/vite.ts";
export * from "./vpc-service.ts";
export * from "./website.ts";
export { WorkerLoader } from "./worker-loader.ts";
export * from "./worker-ref.ts";
Expand Down
Loading
Loading