solutionhacker
Case SH-004Cloud

How to Put a Residential Proxy Behind Cloudflare Tunnel for a Worker

works

Updated  · 8 min read

Problem A Cloudflare Worker only has fetch() and cannot route requests through a host:port residential proxy.

Solution

Put a small VPS relay behind Cloudflare Tunnel: Worker → HTTPS relay → randomized residential proxy → target website. The Worker stays serverless and simple, and the VPS does the network plumbing.

Cloudflare Workers cannot open arbitrary TCP proxy connections to host:port residential proxies. The fix is to put a small VPS relay behind Cloudflare Tunnel: Worker → HTTPS relay → randomized remote proxy → target website.

Why this setup exists

I had a list of residential proxy sessions in the classic host:port:username:password format. Each entry pointed at the same upstream proxy gateway but used a different session name, so every request could rotate through a different residential identity.

The problem: a Cloudflare Worker cannot use that kind of proxy directly. In Node or Python you would pass a proxy URL like http://user:pass@zproxy.example.com:22225 to an HTTP client. A Worker only has fetch(). It can make outbound HTTP requests, but it cannot tell fetch() to route through a third-party HTTP proxy on a custom port.

The Worker needs an HTTPS endpoint. The residential proxy needs a TCP-aware client. So the VPS becomes the bridge.

The architecture is simple:

Cloudflare Worker
      ↓ HTTPS fetch()
proxy.yourdomain.com
      ↓ Cloudflare Tunnel
VPS relay on 127.0.0.1:8888
      ↓ Python requests with proxy support
Residential proxy gateway host:port
      ↓
Target website

Prepare the proxy list

Store your remote proxy entries on the VPS. The relay expects one proxy per line:

host:port:username:password
host:port:username-session-rand2:password
host:port:username-session-rand3:password

For example, with a residential provider that supports sticky sessions, the username often contains the zone, country, and session ID:

zproxy.example.com:22225:customer-zone-residential-country-us-session-rand1:YOUR_PASSWORD
zproxy.example.com:22225:customer-zone-residential-country-us-session-rand2:YOUR_PASSWORD
zproxy.example.com:22225:customer-zone-residential-country-us-session-rand3:YOUR_PASSWORD

Security note. Do not publish your real proxy credentials in a repo or blog post. Keep this file private on the VPS, readable only by the relay service user.

In my setup the file lived at:

/root/repos/proxy/proxy_luminati_random.txt

Install the relay dependencies

The relay is intentionally boring at the application layer: Flask receives the Worker request, Python requests performs the upstream fetch using a randomized proxy, then Flask returns the response body and headers. For production, though, do not run it with Flask’s built-in development server. Put the Flask app behind Gunicorn so multiple Worker requests can run in parallel.

apt update
apt install -y python3 python3-pip
pip3 install --break-system-packages flask requests gunicorn

If you prefer a virtual environment, create one and point systemd at that Python binary. On a small single-purpose VPS, the system install is the shortest path.

Create the Python relay

Create /root/repos/proxy/luminati_relay.py:

import os
import random
from flask import Flask, request, Response
import requests as req

app = Flask(__name__)

PROXY_FILE = "/root/repos/proxy/proxy_luminati_random.txt"
AUTH_TOKEN = os.environ.get("LUMINATI_AUTH_TOKEN")

proxies = []
with open(PROXY_FILE) as f:
    for line in f:
        line = line.strip()
        if not line:
            continue
        host, port, user, pwd = line.split(":", 3)
        proxies.append(f"http://{user}:{pwd}@{host}:{port}")

print(f"Loaded {len(proxies)} proxy entries")

@app.route("/health", methods=["GET"])
def health():
    return f"OK - {len(proxies)} proxies loaded"

@app.route("/fetch", methods=["GET"])
def fetch():
    if AUTH_TOKEN:
        token = request.headers.get("X-Auth-Token", "") or request.args.get("token", "")
        if token != AUTH_TOKEN:
            return Response("Unauthorized", status=401)

    target_url = request.args.get("url")
    if not target_url:
        return Response("Missing 'url' parameter", status=400)

    proxy_url = random.choice(proxies)
    proxy_dict = {"http": proxy_url, "https": proxy_url}

    headers = {
        k: v for k, v in request.headers
        if k.lower() not in ("host",)
    }

    timeout = int(request.args.get("timeout", "15"))

    try:
        resp = req.request(
            method=request.method,
            url=target_url,
            headers=headers,
            proxies=proxy_dict,
            timeout=timeout,
            allow_redirects=True,
            stream=True,
        )

        excluded = {"transfer-encoding", "connection", "keep-alive"}
        resp_headers = [
            (k, v) for k, v in resp.raw.headers.items()
            if k.lower() not in excluded
        ]

        return Response(resp.content, status=resp.status_code, headers=resp_headers)

    except req.exceptions.ProxyError:
        return Response("Proxy error", status=502)
    except req.exceptions.Timeout:
        return Response("Upstream timeout", status=504)
    except Exception as e:
        return Response(f"Error: {str(e)}", status=500)

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=8888)

Notice two design choices:

  • The app is still written as normal Flask, but production traffic is served by Gunicorn. The if __name__ == "__main__" block is only for local debugging.
  • The service binds to 127.0.0.1:8888, not the public internet.
  • Every request picks random.choice(proxies), so the Worker gets rotation without knowing anything about proxy credentials.

Run it as a production systemd service

Create /etc/systemd/system/luminati-relay.service. The important update is the ExecStart: Gunicorn runs four worker processes, each with twenty threads, so the relay can keep many slow residential-proxy requests in flight at the same time.

[Unit]
Description=Luminati Residential Proxy Relay
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=root
WorkingDirectory=/root/repos/proxy
Environment="LUMINATI_AUTH_TOKEN=change-this-long-random-token"
ExecStart=/usr/local/bin/gunicorn \
  --workers 4 \
  --worker-class gthread \
  --threads 20 \
  --bind 127.0.0.1:8888 \
  --timeout 90 \
  luminati_relay:app
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Why threads? Residential proxies are mostly network wait time. While one request is waiting on the proxy gateway or target site, another thread can handle another Worker request. The Python overhead is tiny compared with residential proxy latency.

Then enable it:

systemctl daemon-reload
systemctl enable --now luminati-relay
systemctl status luminati-relay

Check the local health endpoint:

curl http://127.0.0.1:8888/health

You should see something like:

OK - 2000 proxies loaded

You can also sanity-check concurrency with a burst of local health checks:

python3 - <<'PY'
import concurrent.futures, requests, time
url = 'http://127.0.0.1:8888/health'
start = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=40) as ex:
    results = list(ex.map(lambda _: requests.get(url, timeout=5).status_code, range(40)))
print(results.count(200), 'ok in', round(time.time() - start, 3), 'seconds')
PY

On the live setup this returned 40 ok in a fraction of a second, confirming Gunicorn was accepting parallel requests instead of serializing them through the Flask dev server.

Put Cloudflare Tunnel in front

The relay is local-only, so it needs a public HTTPS path. Cloudflare Tunnel is perfect for this: no inbound firewall holes, no public port 8888, no cert management.

If you already have a tunnel, add another hostname to its config. If not, create one:

cloudflared tunnel login
cloudflared tunnel create proxy-relay

Create or edit /etc/cloudflared/config.yml:

tunnel: YOUR_TUNNEL_UUID
credentials-file: /root/.cloudflared/YOUR_TUNNEL_UUID.json

ingress:
  - hostname: proxy.yourdomain.com
    service: http://127.0.0.1:8888
  - service: http_status:404

Add the DNS route:

cloudflared tunnel route dns proxy-relay proxy.yourdomain.com

Install or restart the tunnel service:

cloudflared service install
systemctl enable --now cloudflared
systemctl restart cloudflared

One tunnel can serve many hostnames. You do not need a new tunnel for every subdomain. A single tunnel can route demo.yourdomain.com to port 8000 and proxy.yourdomain.com to port 8888 using separate ingress rules.

Test the full path

First test locally:

curl "http://127.0.0.1:8888/fetch?url=http://httpbin.org/ip" \
  -H "X-Auth-Token: change-this-long-random-token"

Then test through Cloudflare Tunnel:

curl "https://proxy.yourdomain.com/fetch?url=http://httpbin.org/ip" \
  -H "X-Auth-Token: change-this-long-random-token"

If it works, the response should show the target site seeing the residential proxy IP, not your VPS IP.

Call it from a Cloudflare Worker

Now the Worker only needs to call your HTTPS relay. It never sees the proxy password and never opens a raw proxy connection.

export default {
  async fetch(request, env) {
    const url = new URL(request.url)
    const target = url.searchParams.get("url") || "https://httpbin.org/ip"

    const relayUrl = new URL("https://proxy.yourdomain.com/fetch")
    relayUrl.searchParams.set("url", target)
    relayUrl.searchParams.set("timeout", "20")

    const response = await fetch(relayUrl.toString(), {
      headers: {
        "X-Auth-Token": env.RELAY_AUTH_TOKEN,
        "User-Agent": request.headers.get("User-Agent") || "Mozilla/5.0"
      }
    })

    return new Response(response.body, {
      status: response.status,
      headers: response.headers
    })
  }
}

Set the Worker secret:

wrangler secret put RELAY_AUTH_TOKEN

The Worker now uses standard HTTPS, while the VPS relay handles the proxy mechanics and randomization.

What this buys you

Layer Job Why it matters
Worker Calls one HTTPS endpoint Works within Cloudflare’s runtime limits
Cloudflare Tunnel Publishes the VPS relay securely No inbound ports or TLS setup
VPS relay Chooses a random proxy and fetches target URL Supports host:port:user:pass proxies
Gunicorn Runs threaded Flask workers Allows many slow proxy requests to run concurrently
Residential proxy Exits from a residential IP Target sees proxy IP, not Worker or VPS IP

Pitfalls

  • Do not expose the relay without auth. Anyone could burn your proxy bandwidth. Use an X-Auth-Token header or Cloudflare Access.
  • Keep the relay bound to localhost. The public path should be Cloudflare Tunnel, not 0.0.0.0:8888 on the VPS.
  • Do not use Flask’s dev server for production. It is fine for a smoke test, but a Worker can generate concurrent requests. Use Gunicorn, uWSGI, or another production WSGI server.
  • Set timeouts. Residential proxies can be slow or fail. Always set a timeout and return clean 502/504 errors.
  • Do not log credentials. If you log the selected proxy URL, you log the username and password. Avoid that.
  • Use allowlists for production. A general ?url= fetch endpoint is powerful. Restrict target hosts if this backs a real application.

The final shape

The important trick is accepting that the Worker is not the right place to speak proxy protocol. It should stay serverless and simple. The VPS relay is the adapter: it turns a clean HTTPS request into a proxied outbound request with randomized residential sessions.

Once the tunnel is running, the whole thing feels native from the Worker side:

await fetch("https://proxy.yourdomain.com/fetch?url=https://target.example")

Behind that one URL is the part Cloudflare Workers cannot do directly: authenticated remote proxy routing over a custom port, with rotation.


This pattern is useful whenever a serverless runtime can make HTTPS requests but cannot configure a low-level outbound proxy. Use the VPS for the network plumbing; keep the Worker focused on orchestration.

Questions people ask

Why can't the Worker use the proxy directly?
A Worker can make outbound HTTP requests, but it cannot tell fetch() to route through a third-party HTTP proxy on a custom port.
Do I need a new tunnel for every subdomain?
No. A single tunnel can route demo.yourdomain.com to port 8000 and proxy.yourdomain.com to port 8888 using separate ingress rules.
How do I stop others from using the relay?
Never expose it without auth: require an X-Auth-Token header or Cloudflare Access, and keep the relay bound to localhost so the only public path is Cloudflare Tunnel.