Skip to content

cdt: A Cloudflare Dev Tunnel in 300 Lines of Bash

· 4 min read

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.