Cloudflare Tunnel, no need for NGROK

Cloudflare Tunnel is a solid “next step” beyond Ngrok. Here’s a breakdown of what it is, how it works, and how much it costs (or doesn’t).

What is Cloudflare Tunnel

  • Cloudflare Tunnel (formerly “Argo Tunnel”) lets you expose your internal server (web, SSH, etc.) to the Internet without opening inbound ports or exposing your origin IP. Cloudflare Docs+1
  • You run a small daemon called cloudflared on your server or box. It maintains outbound-only TLS-encrypted connections to Cloudflare’s network. Then you configure DNS or “ingress” rules so that traffic hitting Cloudflare is forwarded through that tunnel to your origin. Cloudflare Docs
  • Because the connection is outbound-only, you don’t have to punch holes in your firewall or open ports — it’s safer and works well behind NAT. Cloudflare Docs+1
  • You can also combine it with Cloudflare Access / Zero Trust rules, WAF, rate limiting, etc., to gate or protect who can reach your origin. Cloudflare Docs+1
  • It supports running multiple “connectors” (instances) in the same tunnel for redundancy, and you can have “named tunnels” for persistence. The Cloudflare Blog+1

Cost / Pricing​

This is the sweet part — it can be quite cheap (or free) depending on how you use it:
  • In 2021, Cloudflare made Tunnel free for everyone (as a standalone feature) — you do not need to pay just to run a basic tunnel. The Cloudflare Blog
  • The “tunnel itself” (i.e. just exposing your service via Cloudflare’s network) is free of charge under Cloudflare’s base / free offerings. The Cloudflare Blog+1
  • What is not free (or may incur costs) are add-on or premium features:
    • Argo Smart Routing / Traffic Acceleration — this is a performance enhancement across Cloudflare’s network. If you enable it, you pay. Cloudflare+4Cloudflare+4UnderDefense+4
    • Load Balancers for tunnels / advanced traffic steering & failover logic — may have base fees. Medium
    • Zero Trust / Access / Gatekeeping / Security features — often tied into Cloudflare’s “Zero Trust / Cloudflare One” suite which has its own pricing tiers. Cloudflare+1
  • On Cloudflare’s “Zero Trust & SASE” pricing page, some tiers are free, others start at ~$7/user/month depending on the features you enable. But that’s more for user access policies, SaaS access, etc. Cloudflare
  • For a simple “tunnel + DNS to route Twilio → your origin” use case, you can use Cloudflare Tunnel in the free tier.

Practical Comparison: Ngrok vs Cloudflare Tunnel​

FeatureNgrok (free or paid)Cloudflare Tunnel
Public URL / TunnelYes, ephemeral (free) or reserved (paid)Yes, via your DNS or a Cloudflare hostname
CostsFree tier has limits; paid plans for reserved domains, TCP forwarding, etc.Basic tunnel is free; only pay for premium features
Security / Hidden origin IPPartial (exposes ngrok domain)Better — your origin’s IP is hidden; only Cloudflare connects to it
Integration with DNS / firewallMore manualDeep integration with DNS, Cloudflare’s policies, Access, WAF
Performance enhancements / routingSome built-inCan augment with Argo Smart Routing (paid)

What You Should Expect Month-to-Month​

If you just use the tunnel and basic DNS-based routing (no paid features), your monthly cost is $0 for that part. You only incur cost when you:
  • Enable premium traffic routing (Argo Smart)
  • Use load balancing or advanced failover
  • Add features from Cloudflare’s Zero Trust / enterprise suite
  • Put heavy demands on bandwidth or want SLAs
So, for your use case (Twilio → node via tunnel), Cloudflare Tunnel is a very cost-effective upgrade over Ngrok. You set it once, it’s stable, and you don’t have to keep pasting new URLs when your tunnel restarts.

If you like, I can walk you through exact setup steps (cloudflared config, DNS, Twilio webhook, failover) for your Python + Jetson node — want me to send that next?
 
👍 — let’s walk through setting up Cloudflare Tunnel as your permanent replacement for Ngrok. This way, Twilio always hits a stable HTTPS URL → routed through Cloudflare → into your node.




🔧 Step-by-Step Setup (Cloudflare Tunnel for Twilio + Node)​


1. Prerequisites​


  • You own a domain in Cloudflare (easy to transfer or add).
  • You’ve installed your Twilio number already.
  • Your node runs a web app (Flask/FastAPI/etc.) on some local port (e.g., 5000).



2. Install Cloudflared​


On your node (Pi/Jetson/server):



<span><span>curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb -o cloudflared.deb<br>sudo dpkg -i cloudflared.deb<br></span></span>

Check version:




<span><span>cloudflared --version<br></span></span>



3. Authenticate with Cloudflare​




<span><span>cloudflared tunnel login<br></span></span>

  • This opens a browser, asks you to log in, and lets you pick your domain.
  • Cloudflare then drops a cert file in ~/.cloudflared/cert.pem.



4. Create a Named Tunnel​




<span><span>cloudflared tunnel create my-node-tunnel<br></span></span>

This gives you a tunnel UUID and writes it into ~/.cloudflared.




5. Configure Tunnel Routing​


Create a config file: ~/.cloudflared/config.yml




<span><span><span>tunnel:</span></span><span> </span><span><span>my-node-tunnel</span></span><span><br></span><span><span>credentials-file:</span></span><span> </span><span><span>/home/pi/.cloudflared/&lt;UUID&gt;.json</span></span><span><br><br></span><span><span>ingress:</span></span><span><br> </span><span><span>-</span></span><span> </span><span><span>hostname:</span></span><span> </span><span><span>node.yourdomain.com</span></span><span><br> </span><span><span>service:</span></span><span> </span><span><span>http://localhost:5000</span></span><span><br> </span><span><span>-</span></span><span> </span><span><span>service:</span></span><span> </span><span><span>http_status:404</span></span><span><br></span></span>

  • node.yourdomain.com will be the permanent public URL.
  • Requests to that DNS will flow → Cloudflare → your local app on port 5000.



6. Add DNS Record Automatically​




<span><span>cloudflared tunnel route dns my-node-tunnel node.yourdomain.com<br></span></span>

Cloudflare adds a CNAME → your tunnel UUID.




7. Run the Tunnel​


Dev run:




<span><span>cloudflared tunnel run my-node-tunnel<br></span></span>

For auto-start at boot:




<span><span>sudo cloudflared service install<br></span></span>



8. Point Twilio to It​


In Twilio Console → Phone Numbers → Messaging → Webhook URL:




<span><span><span>https:</span></span><span>/</span><span><span>/node.yourdomain.com/api</span></span><span><span>/twilio/inbound</span></span><span><br></span></span>

Now Twilio has a permanent endpoint for inbound SMS.




💰 Cost​


  • Tunnel itself = $0/month.
  • Cloudflare DNS (Free plan) also $0.
  • Only costs if you add extras (Argo Smart Routing, Zero Trust seats, load balancing).



🔐 Why This Beats Ngrok​


  • No ephemeral URLs (stable DNS).
  • No router port forwarding.
  • Your node’s IP stays hidden behind Cloudflare’s network.
  • Free unless you bolt on extra features.
 
👍 — let’s walk through setting up Cloudflare Tunnel as your permanent replacement for Ngrok. This way, Twilio always hits a stable HTTPS URL → routed through Cloudflare → into your node.




🔧 Step-by-Step Setup (Cloudflare Tunnel for Twilio + Node)​


1. Prerequisites​


  • You own a domain in Cloudflare (easy to transfer or add).
  • You’ve installed your Twilio number already.
  • Your node runs a web app (Flask/FastAPI/etc.) on some local port (e.g., 5000).



2. Install Cloudflared​


On your node (Pi/Jetson/server):



<span><span>curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb -o cloudflared.deb<br>sudo dpkg -i cloudflared.deb<br></span></span>

Check version:




<span><span>cloudflared --version<br></span></span>



3. Authenticate with Cloudflare​




<span><span>cloudflared tunnel login<br></span></span>

  • This opens a browser, asks you to log in, and lets you pick your domain.
  • Cloudflare then drops a cert file in ~/.cloudflared/cert.pem.



4. Create a Named Tunnel​




<span><span>cloudflared tunnel create my-node-tunnel<br></span></span>

This gives you a tunnel UUID and writes it into ~/.cloudflared.




5. Configure Tunnel Routing​


Create a config file: ~/.cloudflared/config.yml




<span><span><span>tunnel:</span></span><span> </span><span><span>my-node-tunnel</span></span><span><br></span><span><span>credentials-file:</span></span><span> </span><span><span>/home/pi/.cloudflared/&lt;UUID&gt;.json</span></span><span><br><br></span><span><span>ingress:</span></span><span><br> </span><span><span>-</span></span><span> </span><span><span>hostname:</span></span><span> </span><span><span>node.yourdomain.com</span></span><span><br> </span><span><span>service:</span></span><span> </span><span><span>http://localhost:5000</span></span><span><br> </span><span><span>-</span></span><span> </span><span><span>service:</span></span><span> </span><span><span>http_status:404</span></span><span><br></span></span>

  • node.yourdomain.com will be the permanent public URL.
  • Requests to that DNS will flow → Cloudflare → your local app on port 5000.



6. Add DNS Record Automatically​




<span><span>cloudflared tunnel route dns my-node-tunnel node.yourdomain.com<br></span></span>

Cloudflare adds a CNAME → your tunnel UUID.




7. Run the Tunnel​


Dev run:




<span><span>cloudflared tunnel run my-node-tunnel<br></span></span>

For auto-start at boot:




<span><span>sudo cloudflared service install<br></span></span>



8. Point Twilio to It​


In Twilio Console → Phone Numbers → Messaging → Webhook URL:




<span><span><span>https:</span></span><span>/</span><span><span>/node.yourdomain.com/api</span></span><span><span>/twilio/inbound</span></span><span><br></span></span>

Now Twilio has a permanent endpoint for inbound SMS.




💰 Cost​


  • Tunnel itself = $0/month.
  • Cloudflare DNS (Free plan) also $0.
  • Only costs if you add extras (Argo Smart Routing, Zero Trust seats, load balancing).



🔐 Why This Beats Ngrok​


  • No ephemeral URLs (stable DNS).
  • No router port forwarding.
  • Your node’s IP stays hidden behind Cloudflare’s network.
  • Free unless you bolt on extra features.
 
Awesome. Here’s a complete, fully-commented Python project preconfigured for Cloudflare Tunnel at https://node.yourdomain.com/api/twilio/inbound. Paste it as-is; no manual edits required beyond your .env values and the DNS/Cloudflared steps.


Code files (full list)​


  1. requirements.txt
  2. .env.example
  3. app.py
  4. ai_router.py
  5. config.py
  6. wsgi.py
  7. run_dev.sh
  8. cloudflared/config.yml
  9. README.md



1) requirements.txt​


twilio==9.3.3
Flask==3.0.3
python-dotenv==1.0.1
gunicorn==22.0.0
werkzeug==3.0.3




2) .env.example​


==== COPY to .env and set real secrets ====​


Twilio webhook verification + outbound sending​


TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_FROM_NUMBER=+1XXXXXXXXXX


Your public host served by Cloudflare Tunnel (NO trailing slash)​


PUBLIC_HOST=https://node.yourdomain.com


Flask host/port​


HOST=0.0.0.0
PORT=5000
FLASK_ENV=production
LOG_LEVEL=INFO




3) app.py​


Flask app exposing a Twilio webhook at /api/twilio/inbound.​


- Verifies Twilio signature using TWILIO_AUTH_TOKEN​


- Uses PUBLIC_HOST so signature validation matches Cloudflare URL​


- Routes message to ai_router.handle_incoming_text()​


- Optional outbound /api/twilio/send for server-initiated SMS​


import os
import logging
from urllib.parse import urljoin
from flask import Flask, request, Response, jsonify
from twilio.twiml.messaging_response import MessagingResponse
from twilio.request_validator import RequestValidator
from twilio.rest import Client
from config import Settings
from ai_router import handle_incoming_text


app = Flask(name)
settings = Settings()


Logging​


logging.basicConfig(level=getattr(logging, settings.LOG_LEVEL, logging.INFO))
log = logging.getLogger("twilio-node")


Twilio REST client (for optional outbound sending)​


twilio_client = None
if settings.TWILIO_ACCOUNT_SID and settings.TWILIO_AUTH_TOKEN:
twilio_client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)


def _expected_public_url(req_path: str) -> str:
"""
Build the absolute public URL that Twilio POSTed to, using PUBLIC_HOST.
This ensures signature validation works behind Cloudflare Tunnel.
"""
base = settings.PUBLIC_HOST.rstrip("/")
return urljoin(base + "/", req_path.lstrip("/"))


def _verify_twilio_signature(req: request) -> bool:
"""
Validate X-Twilio-Signature. If AUTH TOKEN missing, allow (dev mode).
Signature must be computed against the public URL that Twilio targeted.
"""
if not settings.TWILIO_AUTH_TOKEN:
log.warning("TWILIO_AUTH_TOKEN not set; skipping signature validation (DEV MODE).")
return True



<span><span>validator = RequestValidator(settings.TWILIO_AUTH_TOKEN)<br>twilio_sig = req.headers.get(</span><span><span>"X-Twilio-Signature"</span></span><span>, </span><span><span>""</span></span><span>)<br><br></span><span><span># IMPORTANT: Use the public URL Twilio called (Cloudflare hostname),</span></span><span><br></span><span><span># not req.url (which may be the local origin).</span></span><span><br>expected_url = _expected_public_url(req.path)<br>post_vars = req.form.to_dict()<br><br>valid = validator.validate(expected_url, post_vars, twilio_sig)<br>if not valid:<br> log.error(</span><span><span>"Invalid Twilio signature. expected_url=%s form=%s"</span></span><span>, expected_url, post_vars)<br>return valid<br></span></span>

@app.get("/healthz")
def health():
return jsonify(ok=True, service="twilio-node", mode="python", host=settings.PUBLIC_HOST)


@app.post("/api/twilio/inbound")
def inbound_sms():
"""
Primary webhook for inbound SMS.
Twilio POSTs: From, To, Body, MessageSid, etc.
We validate signature, route to AI, return TwiML reply.
"""
if not _verify_twilio_signature(request):
return Response("Invalid signature", status=403)




<span><span>from_number = request.form.get(</span><span><span>"From"</span></span><span>, </span><span><span>""</span></span><span>)<br>to_number = request.form.get(</span><span><span>"To"</span></span><span>, </span><span><span>""</span></span><span>)<br>body = request.form.get(</span><span><span>"Body"</span></span><span>, </span><span><span>""</span></span><span>)<br><br>log.info(</span><span><span>"Inbound SMS: from=%s to=%s body=%r"</span></span><span>, from_number, to_number, body)<br><br></span><span><span># Route to your AI (Jetson/LLMs/NRules/etc.)</span></span><span><br>ai_reply = handle_incoming_text(from_number, body)<br><br></span><span><span># Short TwiML reply back to sender (customize freely)</span></span><span><br>resp = MessagingResponse()<br>resp.message(ai_reply or </span><span><span>"✅ Node online. Your message is in the AI queue."</span></span><span>)<br>return Response(str(resp), mimetype=</span><span><span>"application/xml"</span></span><span>)<br></span></span>

@app.post("/api/twilio/send")
def send_sms():
"""
Optional server-initiated SMS endpoint.
JSON: { "to":"+1...", "message":"..." }
"""
if not (twilio_client and settings.TWILIO_FROM_NUMBER):
return jsonify(error="Twilio sending not configured."), 400




<span><span><span>data</span></span><span> = request.get_json(force=True, silent=True) or {}<br>to = </span><span><span>data</span></span><span>.</span><span><span>get</span></span><span>(</span><span><span>"to"</span></span><span>, </span><span><span>""</span></span><span>)<br>message = </span><span><span>data</span></span><span>.</span><span><span>get</span></span><span>(</span><span><span>"message"</span></span><span>, </span><span><span>""</span></span><span>)<br><br></span><span><span>if</span></span><span> not to or not message:<br> </span><span><span>return</span></span><span> jsonify(error=</span><span><span>"Provide 'to' and 'message'"</span></span><span>), </span><span><span>400</span></span><span><br><br>msg = twilio_client.messages.create(<br> to=to,<br> from_=settings.TWILIO_FROM_NUMBER,<br> body=message<br>)<br>log.info(</span><span><span>"Outbound SMS sent sid=%s to=%s"</span></span><span>, msg.sid, to)<br></span><span><span>return</span></span><span> jsonify(ok=True, sid=msg.sid)<br></span></span>

if name == "main":
# Dev runner (for quick local tests). For prod use: ./run_dev.sh (gunicorn).
app.run(host=settings.HOST, port=settings.PORT)




4) ai_router.py​


Minimal “AI Router” stub — replace with your real pipeline calls.​


You can:​


- Publish to RabbitMQ/Redis for Jetson workers​


- Call a local HTTP inference API on Orin​


- Write to SQL for audit/analytics​


For now, we just echo with a simple policy.​


import datetime


def handle_incoming_text(from_number: str, body: str) -> str:
"""
Decide what to do with the inbound text. This is your 'butler':
- log/queue the message
- classify intent
- trigger tools/agents
- return a short user-facing response
"""
# Example: super-tiny classifier
lower = (body or "").strip().lower()
if "help" in lower:
return "👋 You reached the Node AI. Commands: HELP, STATUS, PING."
if "status" in lower:
now = datetime.datetime.utcnow().isoformat()
return f"🖥️ Node OK @ {now}Z"
if "ping" in lower:
return "🏓 pong"




<span><span><span># Default stub reply</span></span><span><br></span><span><span>return</span></span><span> </span><span><span>"🤖 Got it. Processing via AI pipeline."</span></span><span><br></span></span>



5) config.py​


Centralized settings (env-driven). Keeps secrets out of code.​


import os
from dotenv import load_dotenv


load_dotenv()


class Settings:
TWILIO_ACCOUNT_SID = os.getenv("TWILIO_ACCOUNT_SID", "")
TWILIO_AUTH_TOKEN = os.getenv("TWILIO_AUTH_TOKEN", "")
TWILIO_FROM_NUMBER = os.getenv("TWILIO_FROM_NUMBER", "")




<span><span><span>PUBLIC_HOST</span></span><span> = os.getenv(</span><span><span>"PUBLIC_HOST"</span></span><span>, </span><span><span>"https://node.yourdomain.com"</span></span><span>)<br><br></span><span><span>HOST</span></span><span> = os.getenv(</span><span><span>"HOST"</span></span><span>, </span><span><span>"0.0.0.0"</span></span><span>)<br></span><span><span>PORT</span></span><span> = int(os.getenv(</span><span><span>"PORT"</span></span><span>, </span><span><span>"5000"</span></span><span>))<br></span><span><span>LOG_LEVEL</span></span><span> = os.getenv(</span><span><span>"LOG_LEVEL"</span></span><span>, </span><span><span>"INFO"</span></span><span>)<br></span></span>



6) wsgi.py​


Gunicorn entrypoint:​


from app import app


Expose 'app' as module variable for gunicorn​


all = ["app"]




7) run_dev.sh​


#!/usr/bin/env bash
set -euo pipefail


Load .env into current shell​


if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi


Default to 2 workers; tune on Jetson/Pi​


WORKERS=${WORKERS:-2}
BIND="${HOST:-0.0.0.0}:${PORT:-5000}"


echo "Starting gunicorn on ${BIND} (workers=${WORKERS})"
exec gunicorn -w "${WORKERS}" -b "${BIND}" wsgi:app




8) cloudflared/config.yml​


Place this at: ~/.cloudflared/config.yml​


Replace <UUID>.json with your tunnel credential file name created by:​


cloudflared tunnel create my-node-tunnel​


And make sure DNS points to 'node.yourdomain.com' via:​


cloudflared tunnel route dns my-node-tunnel node.yourdomain.com​


tunnel: my-node-tunnel
credentials-file: /home/pi/.cloudflared/<UUID>.json


ingress:





9) README.md​


Twilio → Cloudflare Tunnel → Node (Python/Flask)​


Overview​


  • Stable public URL: https://node.yourdomain.com
  • Cloudflare Tunnel keeps your origin IP hidden; no router port forwards.
  • Twilio sends inbound SMS webhooks to /api/twilio/inbound.

Quick Start​


  1. Python deps



    <span><span>python3 -m venv .venv &amp;&amp; </span><span><span>source</span></span><span> .venv/bin/activate<br>pip install -r requirements.txt<br></span><span><span>cp</span></span><span> .env.example .</span><span><span>env</span></span><span> </span><span><span># fill in values</span></span><span><br></span></span>
  2. Cloudflared (on the same box)



    <span><span><span># Install + login</span></span><span><br>curl -L https:</span><span><span>//gi</span></span><span>thub.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb -o cloudflared.deb<br>sudo dpkg -i cloudflared.deb<br>cloudflared --version<br>cloudflared tunnel login<br><br></span><span><span># Create tunnel &amp; DNS</span></span><span><br>cloudflared tunnel create </span><span><span>my</span></span><span>-node-tunnel<br></span><span><span># Note the &lt;UUID&gt;.json placed in ~/.cloudflared/</span></span><span><br></span><span><span># Put config.yml (from repo) into ~/.cloudflared/config.yml and set your hostname + credentials-file path</span></span><span><br>cloudflared tunnel route dns </span><span><span>my</span></span><span>-node-tunnel node.yourdomain.com<br><br></span><span><span># Run tunnel</span></span><span><br>cloudflared tunnel run </span><span><span>my</span></span><span>-node-tunnel<br></span><span><span># (Optional) Install service so it starts at boot)</span></span><span><br>sudo cloudflared service install<br></span></span>
  3. App (gunicorn)



    <span><span><span>chmod</span></span><span> +x run_dev.sh<br>./run_dev.sh<br></span></span>
  4. Twilio Console → Phone Numbers → Your Number → Messaging Webhook



    <span><span><span>https:</span></span><span>/</span><span><span>/node.yourdomain.com/api</span></span><span><span>/twilio/inbound</span></span><span><br></span></span>
    • Keep HTTP POST, content form-encoded (default).

Security Notes​


  • Twilio signature verification is ON (uses PUBLIC_HOST to match the Cloudflare URL).
  • Ensure your .env has the correct PUBLIC_HOST exactly as Twilio sees it (https + hostname).
  • For private staging, consider Cloudflare Access (Zero Trust). (Free for basic use; paid for advanced policies.)

Outbound Sending (optional)​


  • POST JSON to POST /api/twilio/send



    <span><span>curl -X POST https:</span><span><span>//node.yourdomain.com/api/twilio/send \</span></span><span><br> -H </span><span><span>"Content-Type: application/json"</span></span><span> \<br> -d '{</span><span><span>"to"</span></span><span>:</span><span><span>"+1XXXXXXXXXX"</span></span><span>,</span><span><span>"message"</span></span><span>:</span><span><span>"Hello from the node!"</span></span><span>}'<br></span></span>

Jetson/AI Integration​


  • Replace handle_incoming_text() with:
    • HTTP call to local inference server (Jetson Orin)
    • Message publish to RabbitMQ/Redis queue
    • DB log to SQL Server/Postgres for audit/analytics



What you change​





Cost​


  • Cloudflare Tunnel & DNS: $0/month on the free plan (premium features optional).
  • Twilio: usage-based (your existing number + per-SMS rates).



Done.​


Once the tunnel is up and ./run_dev.sh is running, send an SMS to your Twilio number. You’ll see logs and an immediate AI stub reply. If you want, I can also drop a RabbitMQ producer version of ai_router.py that queues messages to your Jetson workers.
 
Back
Top