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.

Cloudflareedgeyour networkno inbound portsGETtunnelingressrefusedvisitoranywhereapp.example.comproxied CNAMEcloudflareddialed outapilocalhost:8080
cloudflared opens the tunnel from inside your network, so the only way in is through Cloudflare. A visitor's request rides the tunnel down, and an ingress rule sends it to the service.
import * as Cloudflare from "@alchemy.run/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.