cdt: A Cloudflare Dev Tunnel in 300 Lines of Bash
When I’m deep in a feature branch and someone from the product team wants to test it, the options are bad. Deploy to a preview environment (slow, sometimes broken), share a screen (useless for QA), or ask them to run it locally (not happening). What I actually wanted was a fixed URL that points at whatever is running on my machine right now.
The idea
Cloudflare Tunnel already does this. You can expose a local port to the internet through their edge network with no port forwarding, no firewall rules, no public IP. But the setup involves editing YAML config, managing DNS records, remembering to clean up after yourself, and babysitting the process. I wanted one command.
What it does
cdt is a bash script (~300 lines) that wraps cloudflared into something I can use without thinking:
cdt start -p 3003
That injects the right ingress rule into the Cloudflare config, sets up the DNS route, starts the tunnel, waits for the connection to register, and prints the URL. Done.
If the app isn’t running yet, it can start that too:
cdt start -p 3003 -- nx run verification-service:serve
This launches the app, polls the port every second until something is listening (or the process dies), then starts the tunnel. One command, everything up.
The commands
cdt start -p <port> Tunnel only (app already running)
cdt start -p <port> -- <command> Start app, wait for port, then tunnel
cdt stop Stop tunnel (and app if managed)
cdt status Tunnel and port info with uptime
cdt logs [tunnel|app|all] View logs
cdt status shows what’s running:
Tunnel active
URL: https://tunnel.stoff.dev
Port: 3003
Tunnel PID: 48291
Uptime: 1h 23m
Connections: 4
App PID: 48205
App command: nx run verification-service:serve
cdt stop kills everything and cleans up the Cloudflare config. No orphaned processes, no stale ingress rules.
How it works
The interesting part is the config injection. Cloudflare tunnels use a YAML config file with an ingress section that maps hostnames to local services. Instead of maintaining a separate config, cdt modifies the existing one in place using awk:
awk -v host="$HOSTNAME" -v port="$port" '
/^ingress:/ {
print
print " - hostname: " host
print " service: http://localhost:" port
next
}
{ print }
' "$CF_CONFIG" > "$CF_CONFIG.tmp" && mv "$CF_CONFIG.tmp" "$CF_CONFIG"
It finds the ingress: line, inserts the new rule right after it, and leaves everything else alone. On stop, a matching awk script removes exactly those two lines. The config is always clean.
State lives in a flat file at ~/.cdt/state with PID numbers, the port, and the start timestamp. Simple enough that source reads it and there’s nothing to parse.
Why not just use cloudflared directly
You can. But every time I did, I’d forget a step. Edit the config, run the tunnel, forget to clean up the ingress rule, wonder why a different project’s tunnel broke later. cdt removes the ceremony. It’s the difference between knowing how to do something and actually doing it fast enough that you don’t hesitate.
The app management is the other piece. Starting a service, waiting for it to be ready, then connecting the tunnel is a workflow, not a single command. Now it is.
Using it
The tunnel runs through a named Cloudflare tunnel pointed at a fixed subdomain. You set up the tunnel once through the Cloudflare dashboard (or CLI), and cdt handles everything after that. I use tunnel.stoff.dev as my permanent endpoint. Product team has it bookmarked. When I’m working on something they need to test, I start the tunnel and tell them it’s up. When I’m done, cdt stop.
Logs are split into app and tunnel, pipeable like anything else:
cdt logs app | grep ERROR
The whole thing is a single bash script with no dependencies beyond cloudflared and standard Unix tools. No package to install, no runtime, no config file. Just drop it somewhere in your $PATH.
The source is on GitHub. Run cdt init to configure your hostname and tunnel name, then cdt start -p <port> and you’re live.