Skip to content

Expose private origins with Tunnel

A Cloudflare Tunnel establishes an outbound connection from your origin — a home lab, a Kubernetes cluster, a VM with no public IP — to Cloudflare’s edge. No open inbound ports, no public address. In alchemy the tunnel, its routing rules, and the DNS record that publishes it are all ordinary resources in one stack.

import * as Cloudflare from "alchemy/Cloudflare";
const tunnel = yield* Cloudflare.Tunnel.Tunnel("MyTunnel");
// Run the connector on your origin:
// cloudflared tunnel run --token <Redacted.value(tunnel.token)>

The resource creates a remotely-managed cfd_tunnel and exposes tunnelId plus a redacted connector token — hand the token to cloudflared on the origin and it dials out to the edge. If you omit name, a unique one is generated from the app, stage, and logical ID; tunnel names are immutable, so changing the name (or the tunnelSecret) triggers a replacement.

A remotely-managed tunnel has a single routing document: an ordered list of ingress rules that cloudflared matches top-down, first match wins. The Configuration resource owns that document:

yield* Cloudflare.Tunnel.Configuration("Ingress", {
tunnelId: tunnel.tunnelId,
ingress: [
{ hostname: "ui.internal", service: "http://ui.app.svc.cluster.local:80" },
{ hostname: "api.internal", service: "http://api.app.svc.cluster.local:8080" },
],
});

service is the upstream each hostname forwards to — a private IP, a Kubernetes Service DNS name, or a pseudo-service like http_status:404. The required catch-all rule (a final entry with no hostname) is appended automatically, since Cloudflare rejects a config whose last rule has a hostname; override what it returns with catchAllService: "http_status:503".

Publishing a hostname through the tunnel is a proxied CNAME to <tunnelId>.cfargotunnel.com:

yield* Cloudflare.DNS.Record("AppCname", {
zoneId: zone.zoneId,
name: "app.example.com",
type: "CNAME",
content: `${tunnel.tunnelId}.cfargotunnel.com`,
proxied: true,
});

proxied: true is what makes it work — traffic must enter Cloudflare’s edge to be forwarded down the tunnel. See Domains & DNS for creating or adopting the zone this record lives in.

For origins that should only be reachable by WARP clients — no public DNS at all — route the hostname through the tunnel with a HostnameRoute:

yield* Cloudflare.Tunnel.HostnameRoute("AppRoute", {
hostname: "app.internal.example.com",
tunnelId: tunnel.tunnelId,
comment: "internal wiki behind the datacenter tunnel",
});

Hostname, tunnel, and comment are all mutable in place — updates converge on the same route rather than replacing it. Hostnames are unique per account.

To route entire private CIDR ranges — including overlapping ones — create a VirtualNetwork and target it from a Route:

const vnet = yield* Cloudflare.Tunnel.VirtualNetwork("Staging", {
comment: "staging private network",
});
yield* Cloudflare.Tunnel.Route("StagingNet", {
tunnelId: tunnel.tunnelId,
network: "10.4.0.0/16",
virtualNetworkId: vnet.virtualNetworkId,
});

Each virtual network is an isolated routing namespace, so two environments can both claim 10.4.0.0/16 and WARP clients pick which copy they see. Passing vnet.virtualNetworkId into the Route also gets destroy ordering right — a virtual network can’t be deleted while routes still reference it.