Cloudflare Tunnel connects a server to Cloudflare’s network with an outbound connection made by a small daemon, cloudflared. Visitors reach your app through Cloudflare, and your server needs no open inbound ports: no 80, no 443 and no public IP exposure. It’s popular for self-hosted apps, internal tools behind SSO, and servers behind NAT or CGNAT.
How it works
Visitor ──HTTPS──▶ Cloudflare edge ◀──outbound tunnel (QUIC/HTTP2)── cloudflared on your server ──▶ http://localhost:3000
cloudflaredkeeps several connections open to nearby Cloudflare data centres.- Requests for your hostname are sent down the tunnel and forwarded to a local service.
- Your firewall can block all inbound traffic except SSH (or even SSH, if you use the tunnel for that too).
Requirement: the domain’s DNS must be on Cloudflare.
Install cloudflared
Debian/Ubuntu, from Cloudflare’s repository:
sudo mkdir -p --mode=0755 /usr/share/keyrings
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 any main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install cloudflared
(Check Cloudflare’s docs for the current instructions, and for RHEL or Docker installs.)
Option A: create the tunnel in the dashboard (simplest)
- In Cloudflare Zero Trust → Networks → Tunnels → Create a tunnel (type: cloudflared).
- Copy the install command it shows. It contains a token:
sudo cloudflared service install eyJhIjoi…
This installs a systemd service that connects with that token.
- Under Public Hostnames, add a route, for example
app.example.com→http://localhost:3000. Cloudflare creates the DNS record automatically.
Option B: locally managed tunnel (config file)
cloudflared tunnel login # authorise for your zone
cloudflared tunnel create web1 # creates credentials JSON
cloudflared tunnel route dns web1 app.example.com
~/.cloudflared/config.yml (or /etc/cloudflared/config.yml for the service):
tunnel: web1
credentials-file: /etc/cloudflared/<tunnel-id>.json
ingress:
- hostname: app.example.com
service: http://localhost:3000
- hostname: grafana.example.com
service: http://localhost:3001
- hostname: ssh.example.com
service: ssh://localhost:22
- service: http_status:404
sudo cloudflared service install
sudo systemctl status cloudflared
The last ingress rule (a catch-all) is required.
Lock it down
Close the inbound ports. Once traffic flows through the tunnel, remove the public HTTP/HTTPS allow rules:
sudo ufw delete allow 80/tcp
sudo ufw delete allow 443/tcp
Bind the apps to localhost only. See UFW.
Add Cloudflare Access for private tools. In Zero Trust → Access → Applications, require login (Google, GitHub, email one-time PIN, your IdP) and MFA before anyone reaches grafana.example.com. That turns the tunnel into an identity-aware proxy. See zero trust networking.
SSH through the tunnel: with ssh:// ingress plus Access, connect using cloudflared access ssh as a ProxyCommand. Then SSH needs no open port either.
Trade-offs
Pros:
- no open inbound ports, and the origin IP isn’t exposed;
- works behind NAT or CGNAT, with no port forwarding needed;
- Cloudflare’s WAF, caching, DDoS absorption and Access in front of your apps;
- a free tier for basic use.
Cons:
- all traffic passes through Cloudflare, which terminates TLS and can see the content;
- dependence on one provider’s availability and policies;
- the free tier has limits (for example on upload size per request), and large media streaming may violate terms;
- some protocols need extra client-side setup (SSH, RDP, raw TCP).
For a public website on a VPS with a static IP, a normal Nginx setup with Cloudflare proxy, and the origin locked to Cloudflare IPs, is also fine. Tunnels shine for internal tools, home labs and servers without public ports. See Cloudflare WAF rules.
Alternatives
- Tailscale Funnel / Serve — expose services through Tailscale. See Tailscale.
- frp — a self-hosted tunnel with your own VPS as the public entry point. See frp reverse proxy.
- ngrok and similar — great for development and temporary demos. See ngrok alternatives.
Troubleshooting
sudo journalctl -u cloudflared -f
cloudflared tunnel info web1
cloudflared tunnel list
- 502 / “Unable to reach the origin” — the service URL is wrong, or the app isn’t listening (
ss -tlnp). - The hostname doesn’t resolve — the DNS route is missing (
cloudflared tunnel route dns …). - Redirect loops — the app forces HTTPS, but sees HTTP from cloudflared. Set the app to trust
X-Forwarded-Proto, or point the ingress athttps://localhostwithnoTLSVerify: true.
Quick reference
| Task | Command |
|---|---|
| Install as service (token) | cloudflared service install TOKEN |
| Create tunnel | cloudflared tunnel create NAME |
| Route hostname | cloudflared tunnel route dns NAME host |
| List | cloudflared tunnel list |
| Logs | journalctl -u cloudflared |
Related: Cloudflare with a VPS and port forwarding explained.