Who Will Use It Decides Whether You Need Cloudflare Tunnel or an SSH Tunnel
A question that comes up again and again in web development: the service runs on my own machine or inside a private network — how do people or services outside reach it?
Maybe a client needs to see a work-in-progress page, a Stripe webhook has to hit your laptop, or you want to test a PWA on your phone. Maybe a small tool on your home NAS should be public. Or the other way round: you want to reach the production database from your own computer.
Plenty of tools solve these problems — Cloudflare Tunnel, SSH tunnels, Tailscale, ngrok, frp… Their features overlap, but each shines in different situations. This post first explains how the two most common ones (Cloudflare Tunnel and SSH tunnels) work, then walks through use cases one by one: which tool to pick and how to set it up.
The domain in every example is example.com; replace it with your own domain hosted on Cloudflare.
1. What Cloudflare Tunnel is
Cloudflare Tunnel is a reverse-tunnel service from Cloudflare. You run a small program called cloudflared on your machine; it dials out to Cloudflare’s edge and keeps a few persistent connections open. When someone visits your domain, the request reaches Cloudflare first and is then sent back to your machine over those connections.
flowchart LR
U["User's browser"] -- "HTTPS<br>app.example.com" --> E["Cloudflare edge<br>(CDN, WAF, DDoS protection)"]
C["cloudflared<br>(your machine)"] == "Outbound tunnel" ==> E
E -. "Requests come back<br>through the tunnel" .-> C
C --> S["Local service<br>localhost:8080"]
The connection goes from inside to outside, so the firewall needs no open ports.
This design brings several benefits:
- No public IP and no port forwarding needed. It works behind NAT, corporate networks and carrier-grade NAT (CGNAT).
- Your real IP stays hidden; the outside world only sees Cloudflare’s IPs.
- Cloudflare handles HTTPS certificates, so you don’t run Let’s Encrypt yourself.
- Traffic passes through Cloudflare’s CDN, WAF and DDoS protection, and you can add Cloudflare Access for login checks.
- It’s free.
The price: your domain’s DNS must be on Cloudflare, and TLS is terminated at Cloudflare, which means Cloudflare can see the plaintext traffic.
2. The three modes of SSH tunnels
SSH tunnels are OpenSSH’s built-in port forwarding. They work on any machine with SSH and depend on no provider. There are three modes:
| Mode | Command | Purpose |
|---|---|---|
Local forwarding -L |
ssh -L 5432:localhost:5432 user@server |
Bring a remote service (e.g. a database) to your machine |
Remote forwarding -R |
ssh -R 8080:localhost:3000 user@vps |
Publish a local service on a remote host |
Dynamic forwarding -D |
ssh -D 1080 user@server |
Create a SOCKS proxy; traffic exits via the remote host |
flowchart LR
subgraph L["-L local forwarding: pull remote in"]
direction LR
A1["Local program<br>connects to localhost:5432"] --> A2["ssh client"] == "SSH encrypted" ==> A3["Remote sshd"] --> A4["Remote database<br>:5432"]
end
subgraph R["-R remote forwarding: push local out"]
direction LR
B1["Outside user<br>connects to vps:8080"] --> B2["sshd on the VPS"] == "SSH encrypted" ==> B3["Local ssh client"] --> B4["Local service<br>:3000"]
end
The difference between -L and -R is who listens: with -L your machine listens; with -R the remote host does.
The mode that overlaps with Cloudflare Tunnel is -R: both dial out from inside to publish a local service. But -R needs a jump host or VPS with a public IP, sshd must have GatewayPorts yes, a domain and HTTPS mean setting up nginx and certbot on the VPS yourself, and reconnecting after a drop relies on autossh.
3. Side-by-side comparison
| Aspect | SSH tunnel | Cloudflare Tunnel |
|---|---|---|
| Infrastructure | A VPS or jump host with a public IP | None — just a domain on Cloudflare |
| Protocols | Any TCP | Mainly HTTP/HTTPS; TCP requires cloudflared on the client too |
| Direction | Both (-L pulls in, -R pushes out) |
Mainly pushes out |
| Encryption & privacy | End-to-end, no third party | Decrypted at Cloudflare |
| HTTPS certificates | Set up nginx and certbot yourself | Automatic |
| Exposed IP | The VPS’s IP is visible | Only Cloudflare’s IPs |
| DDoS / WAF | None | Built in |
| Access control | SSH keys (only control who opens tunnels) | Cloudflare Access (SSO, email verification) |
| Reliability | autossh reconnects; the VPS is a single point of failure | Auto-reconnects to multiple edge locations |
| Upload limits | Only VPS bandwidth | 100MB per request on the free plan |
| Cost | VPS monthly fee | Free |
| Vendor lock-in | None | Tied to Cloudflare |
In short, SSH tunnels are lower-level and general-purpose, but you supply the server, certificates and protection yourself. Cloudflare Tunnel does one job — putting HTTP services on the internet — and throws in HTTPS, protection and access control, at the cost of routing traffic through Cloudflare.
4. Choosing by use case
Each case below covers the problem, the recommended solution, why, and the key setup.
Case 1: Quickly showing someone a work in progress
localhost:3000is running on your machine; a client or colleague should take a look, then you shut it down.
Recommended: Cloudflare Quick Tunnel
No Cloudflare account and no domain needed — just one command:
# Install (Debian/Ubuntu)
curl -L -o cloudflared.deb \
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
# Share local port 3000
cloudflared tunnel --url http://localhost:3000
The terminal prints a URL like https://xxx-yyy-zzz.trycloudflare.com; send it to the other person. Press Ctrl+C and the URL stops working.
Limits: the URL changes every time, concurrent requests are capped, and Server-Sent Events are not supported. Use it for quick demos only, never in production.
If you’d rather install nothing, SSH-based services work the same way:
ssh -R 80:localhost:3000 localhost.run
Case 2: Webhook development and testing on a real phone
You’re building a LINE bot, Stripe payments or a GitHub App, and the provider needs a fixed HTTPS callback URL. Or you need to test camera, geolocation or PWA features on a phone, which require HTTPS (a secure context).
Recommended: a Cloudflare Named Tunnel with a fixed subdomain such as dev.example.com
A Quick Tunnel URL changes on every restart, so you’d have to update the webhook settings each time. A Named Tunnel’s URL is fixed: set it up once, and from then on you just start cloudflared each day to receive webhooks. Setup is covered in the next case.
If you need to inspect and replay individual requests while debugging, ngrok’s built-in inspector is handier, and the two can be used side by side.
Case 3: Deploying an app listening on a port to a subdomain
A program on the machine listens on port 8080; you want it at
https://app.example.com, running reliably for the long term.
Recommended: Cloudflare Named Tunnel + systemd
This is the textbook use of Cloudflare Tunnel. With the domain already on Cloudflare, the first setup takes about five minutes:
# 1. Authorise (opens a browser; pick example.com)
cloudflared tunnel login
# 2. Create a tunnel (one per machine is enough; it can serve many apps)
cloudflared tunnel create my-server
# 3. DNS: app.example.com → this tunnel (creates the CNAME for you)
cloudflared tunnel route dns my-server app.example.com
# 4. Run it
cloudflared tunnel run --url http://localhost:8080 my-server
Open https://app.example.com and the service is there, with HTTPS already working.
Several services on one machine
One tunnel can route requests to different ports by hostname:
flowchart LR
U1["app.example.com"] --> E["Cloudflare"]
U2["api.example.com"] --> E
U3["admin.example.com"] --> E
E == "One tunnel" ==> C["cloudflared<br>ingress rules"]
C --> S1["localhost:8080<br>Frontend"]
C --> S2["localhost:3000<br>API"]
C --> S3["localhost:9000<br>Admin"]
One tunnel, one cloudflared process, three services routed by hostname.
Put the rules in ~/.cloudflared/config.yml:
tunnel: my-server
credentials-file: /home/me/.cloudflared/<TUNNEL-ID>.json # created in step 2
ingress:
- hostname: app.example.com
service: http://localhost:8080
- hostname: api.example.com
service: http://localhost:3000
- hostname: admin.example.com
service: http://localhost:9000
- service: http_status:404 # the last rule must be a catch-all
# Each new subdomain needs its DNS record once
cloudflared tunnel route dns my-server api.example.com
cloudflared tunnel route dns my-server admin.example.com
# Check the configuration
cloudflared tunnel ingress validate
# Install as a system service that starts on boot
sudo cloudflared --config ~/.cloudflared/config.yml service install
sudo systemctl restart cloudflared
Adding a service later takes three steps: add a rule to config.yml, run route dns once, restart cloudflared.
You can also skip the CLI: create the tunnel in the Cloudflare dashboard under Zero Trust → Networks → Tunnels, run sudo cloudflared service install <TOKEN> on the server, and manage subdomains on the web from then on. With Docker, the cloudflare/cloudflared image drops straight into docker-compose as one more service.
Common problems
- Only one level of subdomain. The free Universal SSL certificate covers
example.comand*.example.com, soapp.example.comworks, butdev.app.example.comgets a certificate error unless you pay for an Advanced Certificate. - DNS record conflicts. If the subdomain already has an A or CNAME record,
route dnsfails. Delete the old record first, or add-fto overwrite it. - The framework rejects the Host header. Many dev servers only accept
localhostby default; add the domain to the allow list:- Vite:
server.allowedHosts: ['app.example.com'] - Django:
ALLOWED_HOSTS = ['app.example.com'] - Rails:
config.hosts << "app.example.com" - webpack dev server:
allowedHosts: 'all'
- Vite:
- The app itself serves HTTPS with a self-signed certificate. Change
servicetohttps://localhost:8443and addoriginRequest: noTLSVerify: true - 502 Bad Gateway. Usually the local service isn’t running or the port is wrong. Run
curl http://localhost:8080on the machine first to confirm it responds. - The app sees Cloudflare’s IP for every visitor. The real IP is in the
CF-Connecting-IPheader.
Case 4: Internal tools and staging, for specific people only
Grafana, Jenkins or an admin panel for colleagues; a staging site for a client to sign off — but not open to everyone.
Recommended: Cloudflare Tunnel + Cloudflare Access
After connecting the service to a subdomain as in Case 3, go to Zero Trust → Access → Applications, create an application for that subdomain and set who’s allowed — for example, emails ending in @example.com, a few specific addresses, or Google or GitHub logins.
sequenceDiagram
participant U as User
participant A as Cloudflare Access
participant T as Tunnel
participant S as Internal tool
U->>A: Open grafana.example.com
A-->>U: Not signed in, redirect to login
U->>A: Sign in with an email code or Google
A->>A: Check against the allow list
A-->>U: Issue a session cookie
U->>A: Request again with the cookie
A->>T: Verified, forward the request
T->>S: localhost:3000
S-->>U: Return the page
Authentication happens at Cloudflare; requests that fail it never reach your machine.
The upside: no code changes, no VPN, and no SSH keys to hand out to non-technical people. The free plan covers 50 users.
If team members need long-lived connections between several private machines, and not just over HTTP (SSH, databases, file sharing), a mesh VPN such as Tailscale is more direct: once each machine runs Tailscale, they behave as if they were on the same LAN.
Case 5: Reaching a remote database from your machine
The production PostgreSQL only listens on
127.0.0.1, and you want to query it with a GUI tool on your laptop.
Recommended: SSH -L local forwarding
This is home turf for SSH tunnels. Cloudflare Tunnel is good at pushing out; this case is about pulling in:
ssh -N -L 15432:localhost:5432 user@db-server
Then point the GUI tool at localhost:15432. -N means no shell, forwarding only.
If you do this often, put it in ~/.ssh/config:
Host prod-db
HostName db-server.example.com
User deploy
LocalForward 15432 localhost:5432
After that, ssh -N prod-db is all it takes.
Related uses: adding -L during a debugging session to look at a remote admin page that only listens on localhost, or ssh -D 1080 to create a SOCKS proxy so the browser goes out through the remote host.
Case 6: No port 22 open, but SSH still needed
You want every port on the server’s firewall closed, SSH included.
Recommended: run SSH through Cloudflare Tunnel and gate it with Access
Add an ingress rule that carries SSH into the tunnel:
ingress:
- hostname: ssh.example.com
service: ssh://localhost:22
# ...other rules
- service: http_status:404
The client also needs cloudflared; add this to ~/.ssh/config:
Host ssh.example.com
ProxyCommand cloudflared access ssh --hostname %h
Now ssh [email protected] connects through Cloudflare. Configure Access for ssh.example.com and a browser SSO check runs before the connection opens. The server exposes no ports at all, and who may log in is managed centrally in Access.
This also shows the two tools aren’t an either-or: guard the front door with Cloudflare, then use SSH -L to the database once you’re inside. They stack.
Case 7: When Cloudflare Tunnel is the wrong fit
Some requirements don’t suit routing traffic through Cloudflare:
| Requirement | Recommended | Why |
|---|---|---|
| Traffic must not be decrypted by a third party (finance, healthcare, contractual limits) | SSH tunnels, or self-hosted frp or WireGuard | End-to-end encryption; nobody in the middle can read it |
| Large uploads (over 100MB) or video streaming | SSH -R + VPS, or host directly on a VPS |
Avoids the size cap and Cloudflare’s terms of service |
| Non-HTTP protocols for ordinary users (game servers, MQTT, custom TCP) | Self-hosted frp, or open the port on a VPS | Cloudflare Tunnel’s TCP mode needs cloudflared on every client |
| Extremely latency-sensitive | Host directly on a VPS, or a direct Tailscale connection | One less relay hop |
| No vendor lock-in at all | SSH tunnels, frp | Standard open-source tools that work anywhere |
5. Summary
Tools by use case
| Category | Use case | Recommended |
|---|---|---|
| Publishing web services | Quick demo | Cloudflare Quick Tunnel |
| Webhook development, testing on a phone | Cloudflare Named Tunnel (fixed subdomain) | |
| Small sites and side projects in production | Named Tunnel + systemd | |
| Home server, NAS, no fixed IP | Cloudflare Tunnel | |
| Several services on one machine | Tunnel + config.yml ingress |
|
| Specific people only | Internal tools, admin panels | Tunnel + Access |
| Staging for client sign-off | Tunnel + Access (specific emails) | |
| Long-lived links between team machines | Tailscale | |
| Engineer access | Remote database or Redis | SSH -L |
| Ad-hoc debugging | SSH -L / -R |
|
| SOCKS proxy | SSH -D |
|
| Server with port 22 closed | Tunnel + Access in front of SSH | |
| Special requirements | No third-party decryption | SSH tunnels, frp, WireGuard |
| Large files, video streaming | Host directly on a VPS | |
| Publishing non-HTTP protocols | frp, open port on a VPS | |
| Inspecting and replaying requests | ngrok |
Decision flow
flowchart TD
Q1{"Will people use it<br>in a browser?"}
Q1 -- Yes --> Q2{"Is routing traffic<br>through Cloudflare OK?"}
Q1 -- "No, engineers or programs" --> Q5{"What do you need?"}
Q2 -- No --> X1["SSH -R + VPS<br>or self-hosted frp"]
Q2 -- Yes --> Q3{"Only temporary?"}
Q3 -- Yes --> T1["Quick Tunnel"]
Q3 -- "No, long-term or fixed URL" --> T2["Named Tunnel<br>app.example.com"]
T2 --> Q4{"Restrict who can see it?"}
Q4 -- Yes --> T3["Add Cloudflare Access"]
Q4 -- No --> T4["Publish openly"]
Q5 -- "Pull a remote service in" --> S1["SSH -L"]
Q5 -- "Link machines long-term" --> S2["Tailscale"]
Q5 -- "Publish a non-HTTP protocol" --> S3["frp or open a VPS port"]
Ask who will use it first, then whether traffic may go through Cloudflare; two or three questions settle most cases.
Closing thoughts
If your domain is already on Cloudflare, a Cloudflare Named Tunnel covers most everyday web development and small deployments: one tunnel serving several subdomains, Access added where needed, and not a single port open on the server. The remaining pull-in needs, such as reaching a remote database, go to SSH -L.
Only when traffic must not pass through a third party, when large files are involved, or when a non-HTTP protocol has to be public do you need a VPS, frp or Tailscale. The rule of thumb is simple: for services people reach in a browser, choose Cloudflare Tunnel; for engineers or programs using arbitrary protocols, or traffic that must not pass through a third party, choose SSH tunnels or a self-hosted option.