Your local web server needs to be reachable from the public internet - a Next.js app you want to show a client, a webhook receiver on a machine behind CGNAT, a Home Assistant box, a Raspberry Pi. The two default answers are ngrok and Cloudflare Tunnel, and both work fine until you want your own domain, your own name servers, or a traffic cap you set yourself instead of one a vendor picked. Keeping this topic alive is a recurring Hacker News crowd habit - recently resurfacing the “self-hosted HTTP tunnels with SSH and nginx” approach (the community keeps coming back to teaching that trick, so it’s a real pattern worth having in your toolbox).[12]
The idea in one line: instead of paying ngrok to relay traffic and hand you a *.ngrok.io URL, you point your local machine at a cheap VPS you already control, and the VPS terminates public traffic and forwards it back down the tunnel. No third party sees your bytes, no vendor decides your monthly request budget, and the hostname is yours.
This is a comparison plus working configs, not a benchmark post. I don’t publish throughput numbers I haven’t measured, and I won’t invent any here - treat every throughput claim below as “what the tool’s own docs say” and size the box against your real traffic.
When self-hosting the tunnel actually wins
Managed tunnels are a genuinely good deal for bursts: spin up, share, tear down. The case for taking the relay onto your own VPS gets strong when any of these is true:
- You want your own domain. ngrok only lets you use a custom domain on Pay-as-you-go and up, not on Free.[1]
- The free tier’s limits are the problem. ngrok’s Free plan is $0 with a $5 one-time usage credit, and comes with up to 3 online endpoints, 1 GB of data transfer, 20k HTTP/S requests a month, an interstitial (warning) page on HTTP/S endpoints, and a single team member.[1] That’s plenty for demos; it starts to hurt for anything with steady traffic or a browser-reachable endpoint the vendor banners.
- You want control over privacy and data residency. With SSH-reverse-tunnel and self-hosted-relay approaches, plaintext travels only between your local box and your VPS - no third-party relay. With ngrok or Cloudflare in the middle, production traffic crosses their infrastructure.
- The tunnel is a permanent service, not a demo. A persistent link from a homeserver or a single-tenant app is just another thing your stack runs; running it on your own box keeps the blast radius and the billing under you.
The counter-case: managed tunnels have excellent uptime, and ngrok’s Pay-as-you-go plan (billed monthly with $20 of included usage, then pay-per-usage) is cheap to start.[1] If you only occasionally need a URL, stand it up with ngrok or a cloudflared Quick Tunnel and don’t buy a VPS for this. Self-hosting the tunnel is a “you already have a VPS doing other work” play, not a reason to rent one.
The three ways to relay through your own VPS
There are three families, and they differ in how much new software you run and who owns the public entry point.
(a) SSH reverse tunnel + nginx reverse proxy
This uses only OpenSSH, which is already on both machines. The local box opens a remote port-forward to the VPS; the VPS’s nginx terminates public HTTPS and proxies to that loopback port.
One fact shapes the whole setup: OpenSSH binds remote port-forwards to loopback only by default. With the default GatewayPorts no, a -R forward is reachable only from the VPS itself, not the internet.[3] That’s actually what you want here: nginx (public :443) proxies to 127.0.0.1, so you never expose a raw tunnel port. If you ever want the forwarded port on all interfaces instead, set GatewayPorts clientspecified in sshd_config (or yes) and bind an address in the -R argument.[3]
On the VPS, /etc/ssh/sshd_config:
# keep it on loopback, nginx sits in front
GatewayPorts no
AllowTcpForwarding yes
From the local machine, keep the tunnel alive with autossh - a wrapper that starts your ssh, watches it, and restarts it when it dies or stops passing traffic.[2] It can self-monitor on a helper port (checked roughly every 10 minutes) or, cleaner, you disable that with -M 0 and let OpenSSH’s own keepalives detect a dead connection, which is the path the autossh man page recommends for recent OpenSSH.[2]
# local machine, every reboot / via systemd
autossh -M 0 -N \
-o "ServerAliveInterval 60" -o "ServerAliveCountMax 3" \
-o "ExitOnForwardFailure yes" \
-R 8081:localhost:3000 deploy@vps.example.com
-R 8081:localhost:3000 forwards the VPS’s loopback 8081 to your local :3000. Because ExitOnForwardFailure yes makes ssh exit if the remote port can’t bind, autossh will restart it until it can - the pair is why this survives reboots of either end without you touching it.
Then the VPS’s nginx serves your domain and proxies into the tunnel. TLS is handled here with Certbot’s nginx plugin, which is an installer: it edits your server block to serve HTTPS once the certificate is issued (worth re-checking the resulting block, because the same plugin won’t bolt on extra directives like HTTP/2 or security headers for you).[4]
# /etc/nginx/sites-available/proxy.example.com
server {
listen 80;
server_name proxy.example.com;
return 301 https://proxy.example.com$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name proxy.example.com;
ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d proxy.example.com
When to pick this: you want the fewest moving parts - no tunneling daemon to keep updated on either side. The cost is that SSH is tunnel-only here: no application-level virtual hosts (a second service needs a second -R port and an nginx location or server), and all traffic rides one SSH connection, so giant file transfers share the pipe with interactive sessions. It’s the “it’s just ssh and nginx” option, and it’s the one the Hacker News crowd keeps teaching.[12]
(b) A self-hosted tunnel agent (frp, rathole, bore, chisel)
These are proper reverse-proxy daemons with a server half (on the VPS) and a client half (on your local box). You still front the server’s public side with nginx + Certbot for TLS; the tool handles the multiplexing and NAT traversal between the two halves.
frp is the feature-complete workhorse - “a fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet,” Apache-2.0 licensed, currently at v0.72.0 (2026-11-04).[5] It does TCP and UDP, virtual-hosted HTTP/HTTPS subdomains, and a web dashboard. Config (TOML):
# frps.toml (VPS)
bindPort = 7000
# frpc.toml (local machine)
serverAddr = "vps.example.com"
serverPort = 7000
[[tcp]]
name = "web"
localIP = "127.0.0.1"
localPort = 3000
remotePort = 8081
rathole is the leaner Rust sibling - “a lightweight and high-performance reverse proxy for NAT traversal, written in Rust, an alternative to frp and ngrok,” Apache-2.0.[6] Its own benchmark page claims similar latency to frp with more connections and less memory, though it’s careful to note that benchmark was measured on loopback and reflects CPU-bound cases, not your actual network.[6] If frp feels heavy and you just need the tunnel, rathole is the usual pivot.
bore is the minimal one - “a modern, simple TCP tunnel in Rust” that does exactly one thing: forward TCP to a remote server, no app-level routing, no TLS of its own, MIT-licensed, roughly 400 lines.[7] The client talks to the server’s control port (implicitly 7835) to request a remote port, and the server listens on that port and hands connections back; a --secret lets the server reject clients that don’t answer an HMAC challenge, though traffic itself is not encrypted by the tool (you’d run it behind nginx/certbot for that).[7] It’s the right choice when “public TCP port on my VPS → local service” is the entire ask.
# VPS: control port 7835, token-gated
bore server --secret "$(openssl rand -hex 16)"
# local machine: forward your :3000 to the VPS
bore local 3000 --to vps.example.com --secret "$BORE_SECRET"
chisel is the single-binary TCP/UDP-over-HTTP(S) tunnel, MIT-licensed, “a fast TCP/UDP tunnel over HTTP” with a current 1.x line.[8] One binary runs either server or client, auth is --auth user:pass, and reverse forwards look like:
# VPS: chisel server on a control port (front it with nginx/certbot for TLS if you like)
chisel server --port 9122 --auth tunnel:changeme
# local machine: server listens on its 8081, forwards to your :3000
chisel client vps.example.com:9122 --auth tunnel:changeme R:8081:localhost:3000
For all four, TLS terminates in nginx either way: point a server block at proxy_pass http://127.0.0.1:8081; and run certbot --nginx exactly as in section (a).[4] Pick by need: frp if you want virtual hosts, subdomains, and a dashboard; rathole if you want frp-class features in a smaller binary; chisel if you want one binary doing TCP+UDP over an HTTP(S) link and per-client auth; bore if “forward this port” is the whole spec.
(c) Vendor tunnels you route over your own domain - Cloudflare Tunnel and Tailscale Funnel
These are “self-hosted” in the sense that the client daemon runs on your box, but the relay and - for Cloudflare - the nameservers still belong to a vendor. They belong in the comparison because they’re the zero-HTTP-setup path to a stable URL.
Cloudflare Tunnel (cloudflared) - a daemon on your machine opens outbound-only connections to Cloudflare’s global network, so you need no public IP and no open inbound ports.[10] A Quick Tunnel is the zero-friction demo: cloudflared tunnel --url http://localhost:8080 prints a temporary trycloudflare.com URL, needs no account or domain, but has no uptime guarantee, caps at about 200 in-flight requests (beyond that you get HTTP 429), doesn’t support Server-Sent Events, and hands you a new hostname every time it starts.[9] For a stable hostname you create a named tunnel, and that requires the domain’s nameservers pointed at Cloudflare (full setup) - you hand Cloudflare your DNS to get Cloudflare-managed TLS on your own name.[10][9]
Tailscale Funnel - tailscale funnel 3000 shares a local port with the wider internet over HTTPS, as long as the machine is on your tailnet (i.e. you’re already a Tailscale user).[11] Tailscale automatically provisions a TLS certificate for your tailnet’s DNS name (something like your-computer.tailnet-name.ts.net), termination is done by the node’s own Tailscale daemon, and the encrypted tunnel runs through Tailscale’s relay servers - which, per Tailscale, cannot decrypt the traffic.[11] It’s the easiest “let the whole internet in but still encrypted” option here, at the cost of a Tailscale account and routing every byte through Tailscale infrastructure.
HTTPS: who issues the certificate
The single biggest reason to drop a raw tunnel port and put nginx in front is TLS, and each architecture handles it differently:
- SSH
/agent relays: you’re on your own in the best way -certbot --nginxon the VPS for your domain, then nginx terminates TLS and proxies to the loopback tunnel port.[4] Full control of the cert and the hostname; the work is the couple of lines of nginx above. - Quick Tunnel: Cloudflare issues the certificate for the
trycloudflare.comhostname automatically; you do nothing.[9] - Named Cloudflare Tunnel: Cloudflare manages TLS for your hostname, but only because the zone lives on Cloudflare’s nameservers.[10]
- Tailscale Funnel: Tailscale provisions the cert for the
*.ts.netname; you do nothing beyondtailscale funnel 3000.[11]
So the “self-hosted” options buy you the domain and the cert, and ask for one certbot run in return. certbot --nginx modifies your server config as part of installing - that’s expected, just read the diff it makes.[4]
Side-by-side
| Option | New software on your box | Public entry point | TLS | Best for |
|---|---|---|---|---|
| SSH + autossh + nginx | none (OpenSSH + nginx) | VPS nginx | certbot –nginx | Fewest moving parts; one main service |
| frp | server + client daemons | VPS nginx | certbot –nginx | Virtual hosts, TCP+UDP, dashboard |
| rathole | server + client daemons | VPS nginx | certbot –nginx | frp-class features, smaller binary |
| bore | server + client daemons | VPS (raw TCP port) | you add it | “Forward this port” only |
| chisel | one binary (both roles) | VPS nginx | certbot –nginx or in-path | Single binary, TCP+UDP over HTTP(S) |
| Cloudflare Quick Tunnel | cloudflared | Cloudflare edge | automatic (CF) | Throwaway demo URL, zero setup |
| Cloudflare named Tunnel | cloudflared | Cloudflare edge | automatic (CF) | Stable URL, DNS already on CF |
| Tailscale Funnel | Tailscale | Tailscale relay | automatic (ts.net) | Already on Tailscale, E2E-encrypted sharing |
On performance, I’ll stay honest: nobody here publishes a claim I’d repeat as a measured fact. The rathole project publishes its own loopback benchmark claiming more connections and less memory at similar latency to frp - that is the project’s measurement, made on localhost, and the project itself warns not to extrapolate it to your network.[6] What I can say with confidence: with nginx doing TLS termination and the relay on your own VPS, throughput is bounded by your VPS’s bandwidth and your two connections’ ceilings - a flat, predictable cost for unlimited endpoints, versus ngrok’s metered free tier ($5 credit, 3 endpoints, 1 GB, 20k requests) once your traffic outgrows a demo.[1]
The real trade-off column is centralization and the single point of failure. Self-hosted means your VPS is now load-bearing - if it’s down, so is the tunnel. Managed means the vendor’s edge is load-bearing, which is why uptime is better but traffic crosses their network. Tailscale Funnel sits in between: it needs Tailscale’s coordination and relay, but that relay can’t decrypt what passes through.[11]
Decision guide
- Occasional demo, don’t want to spend money: cloudflared Quick Tunnel.[9] Zero setup, ephemeral hostname, get a new URL each time.
- You already run Tailscale and want the whole internet in, encrypted, easily:
tailscale funnel 3000.[11] - You want your own domain
https://app.example.comand already pay for a VPS: SSH + autossh + nginx for one service, or chisel/frp if you’ll add more than one.[3][8][5] - You need virtual hosts, subdomains, and a dashboard on your own box: frp.[5]
- Plain “expose this TCP port to the internet” with a token: bore.[7]
Whatever you pick, run the relay behind nginx with a Let’s Encrypt certificate so the world talks TLS to your name and your tunnel port never has to appear in a browser address bar - that’s the whole upgrade from running a bare reverse proxy in the first place.[4]
Sources
- ngrok - Pricing (Free: $5 usage credit, 3 endpoints, 1 GB transfer, 20k requests, interstitial page; Pay-as-you-go). https://ngrok.com/pricing - checked 2026-10-06
- autossh - Ubuntu manpage: “monitor and restart ssh sessions” (monitor port poll ~10 min;
-M 0withServerAliveInterval/ServerAliveCountMax). https://manpages.ubuntu.com/manpages/focal/man1/autossh.1.html - checked 2026-10-06 - OpenSSH -
sshd_config(5): remote forwards bind loopback by default;GatewayPorts. https://man.openbsd.org/sshd_config - checked 2026-10-06 - Certbot - Using Certbot (authenticators vs installers; the nginx plugin modifies server config). https://eff-certbot.readthedocs.io/en/stable/using.html - checked 2026-10-06
- frp - GitHub repo (Apache-2.0; releases: v0.72.0, 2026-11-04). https://github.com/fatedier/frp; https://github.com/fatedier/frp/releases/latest - checked 2026-11-07
- rathole - GitHub repo + benchmark (Rust, Apache-2.0; project’s own loopback benchmark: similar latency to frp, more connections/bandwidth, less memory). https://github.com/rapiz1/rathole - checked 2026-10-06
- bore - GitHub README (TCP tunnel in Rust, MIT; control port 7835; secret auth; random remote port). https://github.com/ekzhang/bore - checked 2026-10-06
- chisel - GitHub README (fast TCP/UDP tunnel over HTTP, MIT;
--auth; reverse forwards). https://github.com/jpillora/chisel - checked 2026-10-06 - Cloudflare - Quick Tunnels (trycloudflare.com; no account/domain; no uptime guarantee; 200 in-flight cap → 429; no SSE; new hostname per run). https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/ - checked 2026-10-06
- Cloudflare - Cloudflare Tunnel (outbound-only connections to Cloudflare’s network; named tunnels need the domain on Cloudflare’s nameservers). https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/ - checked 2026-10-06
- Tailscale - Funnel docs (
tailscale funnel 3000; auto TLS for tailnet DNS name; relay servers can’t decrypt; termination by node’s daemon). https://tailscale.com/kb/1223/tailscale-funnel; https://tailscale.com/kb/1311/tailscale-funnel - checked 2026-10-06 - Hacker News - “Self-hosted HTTP tunnels with SSH and Nginx” (community trend signal, not a factual source). https://news.ycombinator.com/item?id=49958569 - checked 2026-10-06