Skip to content

Instantly share code, notes, and snippets.

@boidushya
Created February 14, 2025 10:37
Show Gist options
  • Select an option

  • Save boidushya/e5a9d1ebc814d4a28bc7abe40aaf53c7 to your computer and use it in GitHub Desktop.

Select an option

Save boidushya/e5a9d1ebc814d4a28bc7abe40aaf53c7 to your computer and use it in GitHub Desktop.
Cloudflare Tunnel with QR Code
#!/bin/bash
# Default values
PORT=3000
PROTOCOL="http"
HOST="localhost"
ADDITIONAL_PARAMS=""
# Function to display usage
show_help() {
echo "Usage: $(basename $0) [OPTIONS]"
echo "Generate a QR code for Cloudflare tunnel URL"
echo
echo "Options:"
echo " -p, --port PORT Port number (default: 3000)"
echo " -P, --protocol PROTO Protocol (http/https) (default: http)"
echo " -H, --host HOST Host (default: localhost)"
echo " -a, --params PARAMS Additional cloudflared parameters"
echo " -h, --help Show this help message"
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-p | --port)
PORT="$2"
shift 2
;;
-P | --protocol)
PROTOCOL="$2"
shift 2
;;
-H | --host)
HOST="$2"
shift 2
;;
-a | --params)
ADDITIONAL_PARAMS="$2"
shift 2
;;
-h | --help)
show_help
exit 0
;;
*)
echo "Unknown option: $1"
show_help
exit 1
;;
esac
done
# Check if required commands exist
check_dependencies() {
local missing_deps=()
if ! command -v cloudflared >/dev/null 2>&1; then
missing_deps+=("cloudflared")
fi
if ! command -v qrencode >/dev/null 2>&1; then
missing_deps+=("qrencode")
fi
if [ ${#missing_deps[@]} -ne 0 ]; then
echo "Error: Missing required dependencies:"
for dep in "${missing_deps[@]}"; do
echo " - $dep"
done
echo -e "\nInstall missing dependencies with:"
echo "brew install cloudflare/cloudflare/cloudflared qrencode"
exit 1
fi
}
# Main execution
check_dependencies
# Create a named pipe for URL monitoring
URL_PIPE=$(mktemp -u)
mkfifo "$URL_PIPE"
# Cleanup function
cleanup() {
rm -f "$URL_PIPE"
kill $(jobs -p) 2>/dev/null
}
trap cleanup EXIT
# Run cloudflared and monitor its output in a subshell
(script -q /dev/null cloudflared tunnel --url "$PROTOCOL://$HOST:$PORT" $ADDITIONAL_PARAMS 2>&1 | tee "$URL_PIPE") &
# Process the output to generate QR code
while IFS= read -r line; do
if [[ $line == *"Your quick Tunnel has been created!"* ]]; then
# Read next line
read -r url_line
url=$(echo "$url_line" | grep -o 'https://[a-zA-Z0-9.-]*\.trycloudflare\.com')
if [ ! -z "$url" ]; then
printf "\nTunnel URL: %s\n" "$url"
printf "\nScan this QR code:\n"
qrencode -t UTF8 "$url"
printf "\n"
fi
fi
done <"$URL_PIPE" &
# Wait for background processes
wait
@kmindi
Copy link

kmindi commented Mar 6, 2026

Nice! This makes it really easy to quickly access it via the phone.
I adapted it and created this mjs to have it callable right via in my case pnpm run landing:tunnel:

grafik
  1. Install as development dependency:
pnpm add -D qrcode-terminal
  1. Add a new file scripts/tunnel.mjs
#!/usr/bin/env node
/**
 * Starts a cloudflared quick tunnel and prints a QR code for the tunnel URL.
 * Usage: node scripts/tunnel.mjs [port]
 * Default port: 4321 (Astro dev server default)
 */

import { spawn } from "child_process";
import qrcode from "qrcode-terminal";

const port = process.argv[2] ?? "4321";
const localUrl = `http://localhost:${port}`;

console.log(`\n🚇 Starting cloudflared tunnel → ${localUrl}\n`);

const cf = spawn("cloudflared", ["tunnel", "--url", localUrl], {
  stdio: ["inherit", "pipe", "pipe"],
  // Needed on Windows so cloudflared is found via PATH
  shell: process.platform === "win32",
});

let urlFound = false;

/**
 * Inspect a single line of cloudflared output and, on first match,
 * extract the trycloudflare.com URL and render a QR code.
 */
function processLine(line) {
  // Always forward cloudflared output so the user can see it
  process.stderr.write(line + "\n");

  if (urlFound) return;

  const match = line.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
  if (match) {
    urlFound = true;
    const tunnelUrl = match[0];

    console.log("\n✅ Tunnel URL: " + tunnelUrl);
    console.log("\n📱 Scan this QR code:\n");
    qrcode.generate(tunnelUrl, { small: true });
    console.log("\nPress Ctrl+C to stop the tunnel.\n");
  }
}

/**
 * Buffer a readable stream and emit complete lines.
 */
function watchStream(stream) {
  let buf = "";
  stream.on("data", (chunk) => {
    buf += chunk.toString();
    const lines = buf.split("\n");
    buf = lines.pop(); // keep the last (possibly incomplete) line
    for (const line of lines) processLine(line);
  });
  stream.on("end", () => {
    if (buf) processLine(buf);
  });
}

watchStream(cf.stdout);
watchStream(cf.stderr);

cf.on("error", (err) => {
  if (err.code === "ENOENT") {
    console.error(
      "\n❌ cloudflared not found.\n" +
        "   Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n"
    );
  } else {
    console.error("\n❌ cloudflared error:", err.message);
  }
  process.exit(1);
});

cf.on("close", (code) => {
  process.exit(code ?? 0);
});

// Forward Ctrl+C to the child process
process.on("SIGINT", () => {
  cf.kill("SIGINT");
});
  1. Adapt package.json:
"scripts": {
  "landing:tunnel": "node scripts/tunnel.mjs 4321",
}
  1. Adapt the allowedHosts for vite/node, to make sure you don't get an error like this:
Blocked request. This host ("your-host.trycloudflare.com") is not allowed.
To allow this host, add "your-host.trycloudflare.com" to `server.allowedHosts` in vite.config.js.
vite: {
    server: {
  allowedHosts: [".trycloudflare.com", "localhost"],
  }
}

@boidushya
Copy link
Author

Love it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment