You've deployed an API, dashboard, or webhook on your VPS... but how do you access it from the internet without exposing your ports? The answer is simple: Cloudflare Tunnel. Free, secure, and remarkably effective.
In this comprehensive guide, we'll show you how to expose any local service through an encrypted tunnel without touching your firewall, with automatic SSL and built-in DDoS protection.
🤔 The Problem: Exposing a Service Without Risk
When you launch an application on a VPS (a FastAPI on port 8000, an admin dashboard on port 3000, a webhook on port 5000...), it's only accessible locally.
To make it accessible from the internet, the traditional method involves:
- Opening the port in the firewall (UFW, iptables...)
- Configuring a reverse proxy (Nginx, Caddy...)
- Obtaining an SSL certificate (Let's Encrypt...)
- Managing certificate renewal
- Protecting against DDoS attacks
Each step adds complexity and risk:
| Risk | Description |
|---|---|
| Open ports | Every open port is a potential attack surface |
| Poor Nginx config | Missing headers, misconfigured TLS, server info leaks |
| Expired certificate | Inaccessible service, browser errors |
| DDoS | A standard VPS can't withstand a volumetric attack |
| Port scanning | Bots constantly scan public IPs |
What if you could avoid all this?
🚀 The Solution: Cloudflare Tunnel (formerly Argo Tunnel)
Cloudflare Tunnel creates an outbound connection from your server to Cloudflare's network. No inbound ports are needed.
How It Works
[User] → [Cloudflare Edge] ← encrypted tunnel ← [cloudflared on your VPS] → [localhost:8000]
The flow is reversed compared to a traditional server:
cloudflared(the tunnel client) runs on your VPS- It establishes an outbound connection to Cloudflare
- Cloudflare routes incoming traffic to your tunnel
- Your service stays on
localhost— no open ports
Benefits
| Feature | Traditional (Nginx + Let's Encrypt) | Cloudflare Tunnel |
|---|---|---|
| Ports to open | 80, 443 + service ports | None (0) |
| SSL | Let's Encrypt (manual config) | Automatic |
| DDoS Protection | None (or paid) | Included for free |
| Reverse proxy | Nginx/Caddy to configure | Built-in |
| Difficulty | Intermediate to advanced | Beginner to intermediate |
| Cost | Free | Free |
| Zero Trust | No | Yes (Access policies) |
🛠️ Prerequisites
Before starting, you'll need:
- A VPS with Linux (Ubuntu/Debian recommended) — Hostinger offers excellent VPS starting at €3.99/month with 20% discount
- A domain name pointed to Cloudflare (Cloudflare nameservers)
- A Cloudflare account (free)
- A local service running (API, website, dashboard...)
💡 VPS Tip: For a Cloudflare Tunnel, even a small VPS is sufficient.
cloudflaredconsumes very few resources (~20 MB RAM). Hostinger is ideal for beginners with excellent value for money.
📥 Step 1: Install cloudflared
On Ubuntu/Debian
# Add Cloudflare's official repository
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install -y cloudflared
Verify Installation
cloudflared --version
# cloudflared version 2025.x.x (built ...)
Alternative: Direct Binary
# If the repository causes issues
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
sudo mv cloudflared-linux-amd64 /usr/local/bin/cloudflared
sudo chmod +x /usr/local/bin/cloudflared
🔐 Step 2: Authentication
Log in to your Cloudflare account:
cloudflared tunnel login
This command opens a link in the terminal. Copy it to your browser, select the domain to use, and authorize.
A certificate will be saved in ~/.cloudflared/cert.pem.
# Verify the certificate exists
ls -la ~/.cloudflared/cert.pem
🏗️ Step 3: Create the Tunnel
# Create a named tunnel
cloudflared tunnel create my-tunnel
# Expected output:
# Tunnel credentials written to /root/.cloudflared/<UUID>.json
# Created tunnel my-tunnel with id <UUID>
📝 Important: Note the tunnel UUID. You'll need it for configuration.
List your tunnels:
cloudflared tunnel list
# ID NAME CREATED
# a1b2c3d4-e5f6-7890-abcd-ef1234567890 my-tunnel 2026-02-24T10:00:00Z
⚙️ Step 4: Configure the Tunnel
Create the configuration file:
nano ~/.cloudflared/config.yml
Simple Configuration (Single Service)
tunnel: a1b2c3d4-e5f6-7890-abcd-ef1234567890
credentials-file: /root/.cloudflared/a1b2c3d4-e5f6-7890-abcd-ef1234567890.json
ingress:
- hostname: api.mydomain.com
service: http://localhost:8000
- service: http_status:404
Multi-Service Configuration
tunnel: a1b2c3d4-e5f6-7890-abcd-ef1234567890
credentials-file: /root/.cloudflared/a1b2c3d4-e5f6-7890-abcd-ef1234567890.json
ingress:
# FastAPI API
- hostname: api.mydomain.com
service: http://localhost:8000
originRequest:
connectTimeout: 10s
noTLSVerify: true
# Admin dashboard
- hostname: admin.mydomain.com
service: http://localhost:3000
originRequest:
connectTimeout: 10s
# Webhook receiver
- hostname: webhook.mydomain.com
service: http://localhost:5000
# Catch-all (required, must be last)
- service: http_status:404
⚠️ Important Rule: The last
ingressentry must always be a catch-all withouthostname. This is mandatory.
Validate Configuration
cloudflared tunnel ingress validate
# OK
🌐 Step 5: Configure DNS
For each hostname in your config, create a DNS CNAME record:
# Automatic method (recommended)
cloudflared tunnel route dns my-tunnel api.mydomain.com
cloudflared tunnel route dns my-tunnel admin.mydomain.com
cloudflared tunnel route dns my-tunnel webhook.mydomain.com
This automatically creates CNAME records in your Cloudflare zone:
| Type | Name | Content | Proxy |
|---|---|---|---|
| CNAME | api | a1b2c3d4-...cfargotunnel.com | ✅ Proxied |
| CNAME | admin | a1b2c3d4-...cfargotunnel.com | ✅ Proxied |
| CNAME | webhook | a1b2c3d4-...cfargotunnel.com | ✅ Proxied |
▶️ Step 6: Run the Tunnel
Manual Test
cloudflared tunnel run my-tunnel
You should see:
2026-02-24T10:30:00Z INF Starting tunnel tunnelID=a1b2c3d4-...
2026-02-24T10:30:00Z INF Connection registered connIndex=0 ...
2026-02-24T10:30:00Z INF Connection registered connIndex=1 ...
2026-02-24T10:30:01Z INF Connection registered connIndex=2 ...
2026-02-24T10:30:01Z INF Connection registered connIndex=3 ...
4 connections = the tunnel is operational! Test in your browser: https://api.mydomain.com
As a systemd Service (Production)
# Install the service
sudo cloudflared service install
# Verify
sudo systemctl status cloudflared
# The service uses the config in /etc/cloudflared/config.yml
# Copy your config if needed:
sudo cp ~/.cloudflared/config.yml /etc/cloudflared/config.yml
sudo cp ~/.cloudflared/*.json /etc/cloudflared/
# Restart
sudo systemctl restart cloudflared
sudo systemctl enable cloudflared
Check that the service starts on boot:
sudo systemctl is-enabled cloudflared
# enabled
🐍 Use Case 1: Exposing a FastAPI API
Imagine you're developing an AI API with FastAPI:
# api.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="My AI API")
class Query(BaseModel):
prompt: str
max_tokens: int = 500
@app.get("/health")
def health():
return {"status": "ok", "version": "1.0.0"}
@app.post("/generate")
async def generate(query: Query):
# Your AI logic here
return {
"response": f"Generated response for: {query.prompt}",
"tokens_used": 42
}
Run the API:
uvicorn api:app --host 127.0.0.1 --port 8000
Tunnel configuration:
ingress:
- hostname: api.mydomain.com
service: http://localhost:8000
originRequest:
connectTimeout: 30s # Longer for AI requests
- service: http_status:404
Your API is now accessible at https://api.mydomain.com with automatic SSL!
# Test from anywhere
curl https://api.mydomain.com/health
# {"status":"ok","version":"1.0.0"}
curl -X POST https://api.mydomain.com/generate \
-H "Content-Type: application/json" \
-d '{"prompt": "Hello AI", "max_tokens": 100}'
📊 Use Case 2: Protected Admin Dashboard
For an admin dashboard (Streamlit, Grafana, or custom), you want an extra layer of authentication.
Cloudflare Access (Zero Trust)
Cloudflare offers a free authentication system (up to 50 users):
- Go to Cloudflare Dashboard → Zero Trust → Access → Applications
- Create an application:
- Type: Self-hosted
- Subdomain:
admin.mydomain.com - Policy: Allow emails
[email protected]
Now, when someone accesses admin.mydomain.com, they must first authenticate via Cloudflare Access. Even if your dashboard has no login, it's protected.
# config.yml - the dashboard is on localhost:8501 (Streamlit)
ingress:
- hostname: admin.mydomain.com
service: http://localhost:8501
originRequest:
noTLSVerify: true
- service: http_status:404
Verify the JWT Header in Your App
Cloudflare Access adds a Cf-Access-Jwt-Assertion header to each request. You can verify it server-side:
from fastapi import Request, HTTPException
import jwt
import requests
CLOUDFLARE_TEAM = "your-team"
CERTS_URL = f"https://{CLOUDFLARE_TEAM}.cloudflareaccess.com/cdn-cgi/access/certs"
def verify_cloudflare_token(request: Request):
token = request.headers.get("Cf-Access-Jwt-Assertion")
if not token:
raise HTTPException(status_code=403, detail="No access token")
# Get public keys
certs = requests.get(CERTS_URL).json()
public_keys = certs["public_certs"]
for key_data in public_keys:
try:
decoded = jwt.decode(
token,
key=jwt.algorithms.RSAAlgorithm.from_jwk(key_data["cert"]),
algorithms=["RS256"],
audience=f"https://admin.mydomain.com"
)
return decoded
except jwt.InvalidTokenError:
continue
raise HTTPException(status_code=403, detail="Invalid token")
🔗 Use Case 3: Webhook Receiver
Webhooks (GitHub, Stripe, Telegram...) require a public HTTPS URL. Cloudflare Tunnel is perfect for this:
# webhook.py
from fastapi import FastAPI, Request
import hmac
import hashlib
app = FastAPI()
WEBHOOK_SECRET = "your_secret_here"
@app.post("/github")
async def github_webhook(request: Request):
body = await request.body()
signature = request.headers.get("X-Hub-Signature-256", "")
# Verify signature
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(),
body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid signature")
# Process webhook
return {"status": "ok"}