📑 Table des matières

Cloudflare Tunnel : exposer ses services sans ouvrir de ports

Self-Hosting 🟡 Intermédiaire ⏱️ 13 min de lecture 📅 2026-02-24

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:

  1. Opening the port in the firewall (UFW, iptables...)
  2. Configuring a reverse proxy (Nginx, Caddy...)
  3. Obtaining an SSL certificate (Let's Encrypt...)
  4. Managing certificate renewal
  5. 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:

  1. cloudflared (the tunnel client) runs on your VPS
  2. It establishes an outbound connection to Cloudflare
  3. Cloudflare routes incoming traffic to your tunnel
  4. Your service stays on localhostno 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. cloudflared consumes 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 ingress entry must always be a catch-all without hostname. 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):

  1. Go to Cloudflare DashboardZero TrustAccessApplications
  2. Create an application:
  3. Type: Self-hosted
  4. Subdomain: admin.mydomain.com
  5. 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"}