The complete guide to Cloudflare Tunnel
By Flavio Copes
Run a site or app from a home machine on your own domain with no open ports: named tunnels, the dashboard and CLI paths, ingress rules, running as a service, Access login, SSH, limits.
You have an app running on a machine you own. A Mac mini in the living room, a Raspberry Pi, a NUC in a closet, an old laptop. You want it reachable at photos.yourdomain.com, over HTTPS, from anywhere, for as long as the machine is on. And you don’t want to open a single port on your router.
That is what Cloudflare Tunnel does. You install a small daemon called cloudflared on the machine. It opens an outbound connection to Cloudflare and keeps it open. You tell Cloudflare “requests for photos.yourdomain.com go down that connection to localhost:8000”. Cloudflare terminates HTTPS, applies its usual caching and protection, and forwards each request through the tunnel to your machine. Your router never sees an inbound connection.
It’s free on every Cloudflare plan. What you need is a Cloudflare account and a domain whose DNS is on Cloudflare.
We’ll get a hostname working twice, once from the dashboard and once from the CLI, then route more services through the same tunnel, put a login in front, and SSH through it. I ran the CLI path end to end on my Mac on 22 September 2026, with cloudflared 2026.9.1, on a tunnel created and deleted for this post, and checked the dashboard steps and everything else against Cloudflare’s docs on the same day.
The difference with quick tunnels
The same cloudflared binary also does quick tunnels: run cloudflared tunnel --url http://localhost:8000 with no account and you get a random trycloudflare.com URL that lives until you press Ctrl-C. I wrote a whole guide about those, and if you only need to show a dev server to someone this afternoon, that’s the one to read.
A named tunnel, which is what Cloudflare calls Cloudflare Tunnel, is the version you keep:
- Your hostname.
photos.flaviocopes.cominstead ofkingston-inside-best-graphic.trycloudflare.com, and it stays the same across restarts and reboots. - Several services. One tunnel can route
photos.to port 8000,git.to port 3000 andssh.to port 22. A quick tunnel proxies one origin. - A service, not a terminal.
cloudflaredinstalls itself as a systemd unit or a launchd job and reconnects on its own. - Login in front. Cloudflare Access can require a Google, GitHub or email login before a request even reaches your machine. Quick tunnels have no such option.
- The real limits. No 200 in-flight request cap, and Server-Sent Events stream (I checked). Quick tunnels have both problems.
- Support. Quick tunnels come with “no uptime guarantee, we test new features on these first”. Named tunnels are what Cloudflare supports for real traffic.
What it costs you is an account, a domain on Cloudflare DNS, and a few minutes of setup the first time.
What you need
- A Cloudflare account. The free one is enough.
- A domain whose nameservers point at Cloudflare. If you bought it elsewhere, add it to Cloudflare and change the nameservers at your registrar. Cloudflare needs to answer DNS for the domain, because the tunnel works through a DNS record it manages. There is a partial setup where you keep your own DNS and add CNAMEs by hand, but the full setup is simpler and I’ll assume it.
- A machine that stays on, with outbound internet access.
cloudflaredconnects out on port7844, both UDP and TCP, so a very strict firewall may need that opened outbound. - Something to serve. I’ll use a Node.js app on port 8000, but it can be anything that speaks HTTP, or SSH, or raw TCP.
In the examples the hostname is photos.flaviocopes.com. Put your own domain there.
Install cloudflared on the machine that will run the tunnel. On macOS:
brew install cloudflared
On Omarchy or any Arch:
omarchy pkg add cloudflared
On Debian and Ubuntu, Cloudflare has an apt repository, which is what you want on a server so the package updates with everything else:
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-get update && sudo apt-get install cloudflared
The downloads page has RPMs, Windows builds and a Docker image. Cloudflare supports releases for one year, and from 2027 it stops building for 32-bit Windows and Intel Macs.
How it works
Four things exist once you’re set up:
- The tunnel. A persistent object in your Cloudflare account, identified by a UUID. It’s the logical link between Cloudflare and your machine, and it exists whether or not anything is connected to it.
- The connector. A running
cloudflaredprocess that holds the tunnel open. When it starts it makes four outbound connections to two different Cloudflare data centers, so one data center or one connection failing doesn’t take you offline. You can run more connectors for the same tunnel, on other machines. Cloudflare calls those replicas. - The routes. Rules that say which public hostname goes to which local service. Cloudflare’s term is ingress rules. They live either in Cloudflare (a remotely managed tunnel) or in a YAML file on your machine (a locally managed tunnel).
- The DNS record. A proxied CNAME from
photos.flaviocopes.comto<UUID>.cfargotunnel.com. That’s how a request for your hostname ends up at your tunnel.
flowchart TB
V["Visitor"] -->|"HTTPS to photos.flaviocopes.com"| E["Cloudflare edge"]
E -->|"CNAME to UUID.cfargotunnel.com"| T["Your tunnel"]
T -->|"one of four connections"| C["cloudflared on your machine"]
C -->|"ingress rule: http://localhost:8000"| A["Your app"]
C -.->|"outbound, opened first"| E
A request comes in at the Cloudflare location nearest the visitor, travels across Cloudflare’s network to a location your connector is attached to, goes down the tunnel, and cloudflared makes a plain HTTP request to localhost:8000. The response comes back the same way.
Cloudflare terminates the visitor’s TLS. Your app can speak plain HTTP on localhost and the visitor still gets HTTPS with a valid certificate, issued and renewed by Cloudflare. It also means Cloudflare sees the traffic, as with any site behind its proxy.
And <UUID>.cfargotunnel.com only works for DNS records in the same Cloudflare account. If someone learns your tunnel UUID they cannot point their own domain at it.
Two ways to manage a tunnel
The docs mix both, so pick one on purpose before you start.
A remotely managed tunnel keeps its configuration in Cloudflare. You create it in the dashboard (or with the API), you get a token, and the machine runs cloudflared with that token. Adding a hostname is a form in the dashboard. Nothing on the machine changes. If you have three machines running the same tunnel, one form updates all three.
A locally managed tunnel keeps its configuration in ~/.cloudflared/config.yml on the machine. You authenticate cloudflared with your account once, create the tunnel from the terminal, write the YAML, and run it. The routes are a file you can put in Git, template with Ansible, or generate from a script.
Cloudflare pushes you toward remotely managed, and for a home server I agree: fewer files to lose, and the dashboard shows you health, connectors and live logs. Locally managed is for when the config file is the point, when you want it versioned or when you’re automating a fleet. Both run the same binary and the same protocol. You can also convert a local tunnel to remote management from the dashboard later.
I’ll show both. If you’re unsure, do the dashboard path.
Path A: the dashboard
Start your app first, so there’s something to reach. Any web server works; here is the Node.js one from my quick tunnels post, which prints the request it receives as JSON:
import { createServer } from 'node:http'
const server = createServer((req, res) => {
console.log(`${req.method} ${req.url}`)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ path: req.url, headers: req.headers }, null, 2))
})
server.listen(8000, () => {
console.log('Listening on http://localhost:8000')
})
Run it with node server.js and leave it running.
Create the tunnel
In the Cloudflare dashboard, go to Networking, then Tunnels, and select Create Tunnel. Give it a name. Pick something that describes the machine, like macmini-home, because a tunnel usually outlives the app you made it for.
The next screen, Setup Environment, asks for the operating system and architecture of the machine and shows an install command under Install and Run. On macOS and Linux it looks like this:
sudo cloudflared service install eyJhIjoiNmZmNDJhZTIt...
That long eyJ... string is the tunnel token. Treat it like a password: anyone who has it can run a connector that joins your tunnel and receives your traffic. Don’t paste it into a chat, a Gist or a Dockerfile you’ll push somewhere.
Run the command on the machine. It installs cloudflared as a system service, stores the token where the service can read it, and starts it. Within a few seconds the dashboard says the tunnel is connected. Select Continue.
On the Tunnels page your tunnel now shows a status. Healthy means four connections are up. Degraded means it’s serving traffic but at least one connection has failed, which is usually a firewall or a flaky network. Down means cloudflared was connected and isn’t anymore. Inactive means the connector never connected at all, so the install command didn’t work.
The status doesn’t tell you whether cloudflared can reach your app. A tunnel can show Healthy while every request to your hostname gets a 502.
Add a route
Select the tunnel, open the Routes tab, select Add route, then Published application.
Under Hostname, type the subdomain, photos, and pick your domain from the dropdown. Under Service URL, enter where the app lives from the point of view of the machine running cloudflared: http://localhost:8000.
Select Add route. Cloudflare creates the DNS record for you, a proxied CNAME to <UUID>.cfargotunnel.com. Open https://photos.flaviocopes.com and your node server.js terminal logs the request.
That’s the whole setup. Everything below is what you do once it works.
The request your app receives looks like the one in the quick tunnels guide: host is your hostname, cf-connecting-ip is the visitor’s address, cf-ipcountry their country, cf-ray the request ID. When I tested it for this post I found two differences: there is no cf-worker header, because no Worker sits in the path, and the response carries no x-robots-tag: none. Quick tunnels are marked as not indexable and your own hostname isn’t, so Google can find it if you let it.
If your app already serves HTTPS itself, or redirects HTTP to HTTPS, don’t use an http:// Service URL. You’ll get ERR_TOO_MANY_REDIRECTS, because every request reaches your app over HTTP and gets the same redirect back. Use https://localhost:443 as the Service URL, and if the app’s certificate is for photos.flaviocopes.com rather than localhost, expand Additional application settings and set Origin Server Name to that hostname, keeping TLS verification on. Cloudflare has a decision tree for this case.
What the install command did
It helps to know what sudo cloudflared service install <token> did, because that’s what you’ll debug later.
On Linux it writes a systemd unit that runs cloudflared tunnel run with the token, enables it at boot, and starts it. Check it with the usual commands:
systemctl status cloudflared
journalctl -u cloudflared -f
On macOS it writes a launchd job. With sudo it’s a launch daemon in /Library/LaunchDaemons/com.cloudflare.cloudflared.plist that runs at boot; without sudo it’s a launch agent that runs when you log in. Logs go to /Library/Logs/com.cloudflare.cloudflared.out.log and .err.log. Start and stop it with launchctl:
sudo launchctl stop com.cloudflare.cloudflared
sudo launchctl start com.cloudflare.cloudflared
A warning about macOS. There is a GitHub issue from 2021, closed by Cloudflare in 2022, where people were still reporting in 2026 that the launchd job doesn’t start the tunnel. The original bug is with locally managed tunnels: the plist runs cloudflared without the tunnel run arguments, and the error log fills with Use cloudflared tunnel run to start tunnel. The token-based install we just did is Cloudflare’s recommended path and the fix it pointed people to, but a couple of the 2026 comments come from people who pasted the dashboard command and still found nothing running after a reboot. On a Mac, reboot once and check launchctl list | grep cloudflare and the error log before you rely on it. The issue thread has the plist edit if you need it.
If you’d rather see the connector in a terminal before trusting it to a service, you can run it by hand with the same token:
cloudflared tunnel run --token eyJhIjoiNmZmNDJhZTIt...
The output is the one you may know from quick tunnels, with two differences: the settings line shows token:***** instead of a URL, and there are four Registered tunnel connection lines instead of one. We’ll read that log in the CLI section, where I ran it.
Path B: the CLI
Same result, different tools, and you end up with a config file you own.
Authenticate
cloudflared tunnel login
This opens a browser. Log in to Cloudflare and pick the domain you’ll use. cloudflared then saves an account certificate at ~/.cloudflared/cert.pem. That file lets cloudflared create tunnels and DNS records in your account, so it’s as sensitive as the dashboard login. You need it on the machine where you create the tunnel, not on every machine that runs one.
Create the tunnel
cloudflared tunnel create macmini-home
Tunnel credentials written to /Users/flavio/.cloudflared/58540a18-2a19-4329-822f-2c3e5f795270.json. Keep this file secret. To revoke these credentials, delete the tunnel.
Created tunnel macmini-home with id 58540a18-2a19-4329-822f-2c3e5f795270
Cloudflare registered the tunnel, printed its UUID, and wrote a credentials file. That JSON has four fields: AccountTag, TunnelID, TunnelSecret and Endpoint. It’s what a connector needs to run this tunnel, the local equivalent of the token, so take the “keep this file secret” at face value. Note the UUID and the path.
cloudflared tunnel list
ID NAME CREATED CONNECTIONS
58540a18-2a19-4329-822f-2c3e5f795270 macmini-home 2026-09-22T16:30:19Z
Every tunnel in the account, with its connections. Ours has none yet.
Write the config file
Create ~/.cloudflared/config.yml:
tunnel: 58540a18-2a19-4329-822f-2c3e5f795270
credentials-file: /Users/flavio/.cloudflared/58540a18-2a19-4329-822f-2c3e5f795270.json
ingress:
- hostname: photos.flaviocopes.com
service: http://localhost:8000
- service: http_status:404
Two rules. The first sends requests for photos.flaviocopes.com to port 8000. The last one is the catch-all, and it’s required: any request that reaches your tunnel with a hostname you didn’t list gets a 404 from cloudflared itself, without touching any of your services.
Check the file before running anything:
cloudflared tunnel ingress validate
Validating rules from /Users/flavio/.cloudflared/config.yml
OK
If you leave the catch-all out, this is what you get instead, and cloudflared tunnel run fails with the same message:
Validation failed: The last ingress rule must match all URLs (i.e. it should not have a hostname or path filter)
You can also ask which rule a URL would hit:
cloudflared tunnel ingress rule https://photos.flaviocopes.com/2026/summer
Using rules from /Users/flavio/.cloudflared/config.yml
Matched rule #0
hostname: photos.flaviocopes.com
service: http://localhost:8000
A hostname you didn’t list matches rule #1, the http_status:404. When you have ten rules with paths and wildcards, this command saves you a lot of guessing.
Create the DNS record
cloudflared tunnel route dns macmini-home photos.flaviocopes.com
INF Added CNAME photos.flaviocopes.com which will route to this tunnel tunnelID=58540a18-2a19-4329-822f-2c3e5f795270
That’s the proxied CNAME to <UUID>.cfargotunnel.com in your zone. Run it twice and the second time it says the hostname is already configured to route to your tunnel. If a record for that name already exists and points somewhere else, cloudflared refuses with An A, AAAA, or CNAME record with that host already exists, and you delete the old one in the dashboard first.
The record and the tunnel are independent objects. You can create the record before the tunnel runs, stopping the tunnel doesn’t delete the record, and neither does deleting the tunnel. cloudflared has no command to remove a DNS record, so cleanup happens in the dashboard.
Run it
cloudflared tunnel run macmini-home
Here is what mine printed, without the pre-check table:
INF Starting tunnel tunnelID=58540a18-2a19-4329-822f-2c3e5f795270
INF Version 2026.9.1 (Checksum 3844772c3b27356a40bd81784af932f61320fb142764d154bb737a746399e6bc)
INF GOOS: darwin, GOVersion: go1.27.1, GoArch: arm64
INF Settings: map[config:/Users/flavio/.cloudflared/config.yml cred-file:/Users/flavio/.cloudflared/58540a18-2a19-4329-822f-2c3e5f795270.json credentials-file:/Users/flavio/.cloudflared/58540a18-2a19-4329-822f-2c3e5f795270.json]
INF cloudflared will not automatically update if installed by a package manager.
INF Generated Connector ID: fe9470bb-693f-41a1-9220-1aea935b69a8
INF Initial protocol quic
INF Starting metrics server on 127.0.0.1:20241/metrics
INF Registered tunnel connection connIndex=0 connection=3df66500-da99-4822-a269-08bf63cf371f event=0 ip=198.41.192.7 location=mxp06 protocol=quic
INF Registered tunnel connection connIndex=1 connection=5fe75fb7-f6e4-45bc-ac4a-79badd307374 event=0 ip=198.41.200.73 location=fco01 protocol=quic
INF Registered tunnel connection connIndex=2 connection=9e09ece7-54f5-4802-8164-f02a2112e0e8 event=0 ip=198.41.200.233 location=fco01 protocol=quic
INF Registered tunnel connection connIndex=3 connection=e38f5baa-acfc-4184-b6ca-040e05a60fb5 event=0 ip=198.41.192.167 location=mxp03 protocol=quic
Four connections, connIndex 0 to 3, to two Milan data centers and two in Rome. That’s the redundancy the docs promise, and you can see it in the account too:
cloudflared tunnel info macmini-home
NAME: macmini-home
ID: 58540a18-2a19-4329-822f-2c3e5f795270
CREATED: 2026-09-22 16:30:19.45543 +0000 UTC
CONNECTOR ID CREATED ARCHITECTURE VERSION ORIGIN IP EDGE
fe9470bb-693f-41a1-9220-1aea935b69a8 2026-09-22T16:30:53Z darwin_arm64 2026.9.1 203.0.113.42 2xfco01, 1xmxp03, 1xmxp06
One connector, four edge connections. cloudflared tunnel list now shows the same 2xfco01, 1xmxp03, 1xmxp06 in its connections column.
curl https://photos.flaviocopes.com/hello answered with the JSON from my server on the first try, about twenty seconds after route dns. And the local readiness endpoint reports all four connections:
curl 127.0.0.1:20241/ready
{"status":200,"readyConnections":4,"connectorId":"fe9470bb-693f-41a1-9220-1aea935b69a8"}
That endpoint is a good health check to wire into your own monitoring.
When I stopped it with Ctrl-C, the log ended with no more connections active and exiting, cloudflared tunnel info said does not have any active connection, and a visitor to photos.flaviocopes.com got Cloudflare’s error 1033 page with a 530 status, the same page a stopped quick tunnel shows. The DNS record still points at the tunnel; there’s just nothing behind it.
To make it a service, install it the same way as before but without a token. On Linux, mind that sudo changes $HOME, so cloudflared may not find the config you wrote as your user:
sudo cloudflared --config /home/flavio/.cloudflared/config.yml service install
systemctl start cloudflared
On macOS, sudo cloudflared service install expects the config in /etc/cloudflared instead of ~/.cloudflared, and it’s the case the GitHub issue above is about. If you’re on a Mac, my advice is to create the tunnel with the CLI if you like the workflow, then run it with the token. cloudflared tunnel token macmini-home prints a 180-character eyJ... token for a CLI-created tunnel, and cloudflared tunnel run --token <that token> connects the same four connections with no config file at all; I tried it and the settings line in the log shows token:*****. sudo cloudflared service install <that token> then gives you the service path Cloudflare recommends.
Delete it
When you’re done with a tunnel:
cloudflared tunnel delete macmini-home
It prints nothing on success, and cloudflared tunnel list no longer shows it. A tunnel with active connections refuses to be deleted; stop the connector first, or pass -f. Deleting also revokes the credentials file. It does not touch the CNAME, so photos.flaviocopes.com keeps answering with the 1033 page until you remove the record under DNS, then Records in the dashboard.
Several services through one tunnel
A quick tunnel can’t do this part. Say the same machine also runs a Git server on port 3000 and you want SSH into it too: one tunnel handles all of it with three rules.
In the dashboard, add two more routes: git.flaviocopes.com to http://localhost:3000, and ssh.flaviocopes.com with service type SSH to localhost:22. Cloudflare creates a DNS record for each.
In a config file:
tunnel: 58540a18-2a19-4329-822f-2c3e5f795270
credentials-file: /Users/flavio/.cloudflared/58540a18-2a19-4329-822f-2c3e5f795270.json
ingress:
- hostname: photos.flaviocopes.com
service: http://localhost:8000
- hostname: git.flaviocopes.com
service: http://localhost:3000
- hostname: ssh.flaviocopes.com
service: ssh://localhost:22
- service: http_status:404
Then cloudflared tunnel route dns macmini-home git.flaviocopes.com and the same for ssh., and restart the connector.
Rules are evaluated top to bottom and the first match wins. A rule can match a hostname, a path, or both. The hostname accepts a leading wildcard, *.flaviocopes.com, and the path is a regular expression in Go syntax, so path: \.(jpg|png|css|js)$ routes static assets to a different service than the rest of the site. A rule with no hostname matches every hostname, which is why the catch-all goes last.
Be careful with paths, because matching one does not strip it. A rule with path: /api sends https://photos.flaviocopes.com/api/albums to your service as /api/albums, not /albums. If a service expects to live at the root, put a reverse proxy like Caddy in front of it, or rewrite the URL with a Cloudflare rule at the edge.
The service can also be a Unix socket (unix:/home/flavio/app.sock), raw TCP (tcp://localhost:5432), RDP, SMB, hello_world for the built-in test page, or http_status:<code> to answer with a fixed status. Non-HTTP services need cloudflared on the client side too, which we’ll see with SSH.
Origin settings you’ll actually touch
Between cloudflared and your service there are a few knobs. Most people need one or two of them. In a config file they go under originRequest, either at the top level for all rules or inside a rule for that service alone. In the dashboard, since August 2026, they’re under Additional application settings when you edit a route.
Host header
Some servers only answer requests whose Host header they recognize. Vite and Astro dev servers do this, Ollama does it, and they’ll return a 403 for photos.flaviocopes.com. httpHostHeader rewrites the header before the request reaches the service:
- hostname: dev.flaviocopes.com
service: http://localhost:4321
originRequest:
httpHostHeader: localhost:4321
HTTPS origins
If the service speaks HTTPS with a certificate for its public name, set originServerName to that name and keep verification on. noTLSVerify: true exists for self-signed certificates on a box you control, and Cloudflare’s own docs call it a last resort. The connection between Cloudflare and cloudflared is encrypted regardless of this; these settings are only about the last hop on your machine.
Timeouts
connectTimeout (default 30 seconds) is how long cloudflared waits for the service to accept the TCP connection. Lower it for a service that’s either up or not, so visitors get a fast 502 instead of a slow one.
Streaming
In the quick tunnels guide I measured that every GET response is held until it completes, which breaks Server-Sent Events. Through the named tunnel I ran the same two test routes: an SSE endpoint writing one event per second, and a plain text/plain endpoint writing one line per second. Both arrived line by line, one second apart, exactly as they do on localhost. So SSE works here, and so did ordinary chunked responses.
Cloudflare’s troubleshooting page says responses are buffered unless they carry Content-Type: text/event-stream. My plain-text test streamed anyway, so if a streaming endpoint of yours looks frozen, set that header before you dig further, but don’t be surprised if it was already fine. WebSockets stream too.
The full list, including keep-alive tuning, HTTP/2 to origin and a CA pool for private certificates, is in the origin parameters reference.
Put a login in front of it
Once photos.flaviocopes.com resolves, it’s public. Anyone can open it. For a photo gallery you share with family, that’s probably not what you want, and adding user accounts to every little app you self-host gets old.
Cloudflare Access puts a login page in front of the hostname, at Cloudflare’s edge, before any request reaches your tunnel. It’s part of Cloudflare Zero Trust (now called Cloudflare One), and the free plan covers 50 users. The limit counts people who log in, so a relative who visits a hundred times still takes one seat.
Cloudflare recommends creating the Access application before the route, because until it exists the app is open to anyone who finds the hostname. If you followed the steps above in order, do it now.
In the dashboard go to Zero Trust, then Access controls, then Applications. Select Create new application, then Self-hosted and private, then Add public hostname, and pick photos on your domain.
Then a policy: who gets in. All applications deny by default, so you add an Allow policy with a rule such as “Emails ending in @flaviocopes.com” or a list of specific addresses. A new Zero Trust organization comes with Cloudflare itself as the login method, restricted to members of your Cloudflare account, which is fine when the only user is you. For family and friends, add the One-time PIN identity provider under Integrations, then Identity providers: they type their email, get a code, and they’re in, no account anywhere. Google, GitHub, Microsoft and any OIDC or SAML provider are options too.
Pick a session duration and save. The next visit to photos.flaviocopes.com shows Cloudflare’s login page first.
Also turn on Protect with Access in the tunnel’s route settings, so cloudflared itself validates the Access token on every request and drops anything that somehow arrives without one. And for scripts and machines that need to reach the app, Access has service tokens: a client ID and secret sent as headers, no browser involved, and they don’t consume one of the 50 seats.
In the quick tunnels guide the best I could offer was HTTP Basic auth inside the app. With Access the app doesn’t change at all.
SSH through the tunnel
You added ssh.flaviocopes.com with an ssh://localhost:22 service. Now ssh flavio@ssh.flaviocopes.com will not work as is, because Cloudflare’s edge speaks HTTP, and SSH isn’t HTTP. cloudflared wraps it in a WebSocket on the server side, and you need cloudflared on the client side to unwrap it.
The trick is an SSH ProxyCommand. In ~/.ssh/config on your laptop:
Host ssh.flaviocopes.com
ProxyCommand cloudflared access ssh --hostname %h
Now ssh flavio@ssh.flaviocopes.com works from anywhere. If the hostname is behind an Access application, cloudflared access ssh opens a browser for you to log in the first time and caches the token. Your SSH keys and sshd configuration don’t change, and port 22 stays closed on the router. cloudflared access ssh-config --hostname ssh.flaviocopes.com prints the config block for you.
Cloudflare also offers a browser-rendered terminal: turn it on in the Access application’s settings and ssh.flaviocopes.com opens a terminal in the browser tab, no SSH client needed. Handy from a phone or someone else’s machine.
Raw TCP for arbitrary clients fits less well. For a game server, a database that another server connects to, or anything UDP, the WebSocket wrapping means every client needs cloudflared access tcp or the Cloudflare One (WARP) client.
Streaming video or large media through a public hostname is against Cloudflare’s terms for Free, Pro and Business plans, which want a paid product for that. People who put Jellyfin behind a tunnel are counting on Cloudflare not enforcing that rule, and I wouldn’t.
Private networks
Everything so far publishes a hostname to the whole internet, with Access as the gate. There’s a second mode where nothing is public.
You add a CIDR route to the tunnel, say 192.168.1.0/24, and install the Cloudflare One client (the renamed WARP app) on your devices. Now your phone and laptop reach 192.168.1.20:8000 from anywhere as if they were at home, through the tunnel, and nobody else can. You can add Gateway policies about who reaches which IP, and a private DNS so nas.home resolves.
This is Cloudflare’s VPN replacement, and it’s where the docs about “Zero Trust” send you. It’s a bigger topic than one section, so I’ll point you to the free VPN course on this site, which has a whole module on building a Cloudflare private network, and to the Cloudflare course, which covers both modes of the tunnel.
My rule of thumb is hostname routes for things other people need to reach, and private routes for things only my own devices need to reach.
Keep it running
Replicas
Run cloudflared on a second machine with the same token, or the same credentials file, and you have two connectors on one tunnel, eight connections. If one machine dies, traffic goes to the other. Up to 25 replicas per tunnel. Requests go to the geographically closest replica, so this is failover, not load balancing; for real traffic steering Cloudflare wants you to create separate tunnels and put them behind a load balancer.
Updates and tokens
With a package manager, cloudflared updates with the system. A binary you downloaded yourself updates itself when it runs as a service, checking once a day, and restarts, which drops the connections for a moment. For zero downtime, start a replica on the new version first, then stop the old one.
In the dashboard, a tunnel has Rotate token. After rotating, no new connector can join with the old token; running connectors stay up until you restart them, so you re-run service install with the new token on each machine. If a token leaks, rotate, then force-disconnect the existing connectors with the API’s DELETE .../cfd_tunnel/<id>/connections.
Logs, alerts and metrics
The tunnel page in the dashboard has a Live logs tab since August 2026 that streams from every connector at once, filterable by level and event type. From a terminal, cloudflared tail <tunnel> does the same. On the machine itself, journalctl -u cloudflared or the launchd log files.
Cloudflare can email you or hit a webhook when a tunnel changes health. It’s under Notifications in the account, type “Tunnel Health Alert”.
The /metrics endpoint on 127.0.0.1:20241 is Prometheus format, with request counts, errors and connection state. Cloudflare has a Grafana tutorial for it. And cloudflared tunnel diag bundles logs, config and metrics into a zip for a support ticket.
A terminal dashboard: ytunnel
If you end up with several tunnels on one machine, ytunnel is a terminal UI that manages them for you. It’s open source (MIT), written in Rust, and runs on macOS and Linux.
You give it a Cloudflare API token with Zone, DNS and Cloudflare Tunnel edit permissions, and one command does everything from Path B:
ytunnel add photos localhost:8000 --start
That creates the tunnel through the API, adds the CNAME, writes the credentials and the config file, and installs a launchd agent on macOS or a systemd user unit on Linux that runs cloudflared tunnel --config ... run. Run ytunnel with no arguments and you get a dashboard with every tunnel, its logs, live request counts and status codes read from the /metrics endpoint, and the edge locations it’s connected to. It also sends a desktop notification when a tunnel goes down.
ytunnel run myapp localhost:3000 gives you something like a quick tunnel on your own domain that cleans up when you press Ctrl-C, and ytunnel doctor finds the orphaned CNAMEs that cloudflared tunnel delete leaves behind. Tunnels you created in the dashboard show up too, read-only.
It’s built on locally managed tunnels, so you give up the dashboard’s route editing for those. I haven’t run it for this post, so try it on a test hostname first. Install it with brew install yetidevworks/ytunnel/ytunnel or cargo install ytunnel.
Limits and rules
- Free. Cloudflare Tunnel is included on every plan, and Access is free for 50 users. What costs money is beyond that, or the paid Zero Trust features.
- Request size. Traffic through a public hostname is Cloudflare proxy traffic, and the Free and Pro plans cap request bodies at 100 MB. Uploading a 2 GB video to your self-hosted Immich through a tunnel fails with a 413. Business is 200 MB. Cloudflare’s own advice for moving large files is a private network route, which doesn’t go through the HTTP proxy.
- Terms. No video or large-file serving through the proxy on Free, Pro or Business, as above.
- Account limits. 1,000 tunnels and 25 active replicas per tunnel. You won’t hit them at home.
- Latency. Every request goes from the visitor to Cloudflare, through the tunnel, to you. From my desk to a Cloudflare location in Milan it’s a few milliseconds, but a visitor on another continent pays for two legs across the world instead of one.
- What Cloudflare sees. TLS ends at Cloudflare, which I don’t mind for a photo gallery and would mind for anything sensitive.
- Port 7844 outbound must be open, UDP for QUIC or TCP for the HTTP/2 fallback. Corporate networks sometimes block it.
When something breaks
A short list, in the order I’d check.
502 Bad Gateway. The tunnel is fine; cloudflared can’t reach your service. The service isn’t running, it’s on another port, or the Service URL says http and the service speaks https (or the reverse). Check curl http://localhost:8000 on the machine itself, then read the tunnel logs, which say exactly what cloudflared tried to connect to.
Error 1033 or 1016 page. Cloudflare has the DNS record but no healthy connector behind it. cloudflared isn’t running, it’s connected to a different tunnel than the one the record points to, or you deleted the tunnel and left the record. cloudflared tunnel info <name> shows what’s attached.
ERR_TOO_MANY_REDIRECTS. Your app redirects HTTP to HTTPS and the Service URL is http://. Switch it to https:// and set the origin server name. Don’t “fix” it by changing the zone’s SSL mode to Flexible.
403 from the app. The app checks the Host header. Set httpHostHeader.
Healthy in the dashboard but Degraded now and then. One of the four connections keeps failing. Almost always a firewall or a consumer router that dislikes long-lived UDP. Try --protocol http2 to force TCP.
cloudflared service is already installed. Uninstall first: sudo cloudflared service uninstall, then install again with the new token.
Quick tunnels stopped working on this machine. A config.yml in ~/.cloudflared disables them. Rename the file while you test, then put it back.
Everything looks right and nothing arrives. Wait a minute for DNS, then check that the record is proxied (orange cloud). A grey-cloud CNAME to cfargotunnel.com does nothing.
How I would use it
This site runs on Cloudflare Pages, so the site itself doesn’t need a tunnel. Where I’d reach for one:
A stable dev hostname. In the quick tunnels guide I said the URL changing on every restart is what eventually makes you want a named tunnel. dev.flaviocopes.com pointing at my Astro dev server, with httpHostHeader set so Vite accepts it and an Access policy that allows only my email, gives me a permanent way to check a draft post on my phone or send a preview to someone. And a stable origin is exactly what an embed widget with an origin allowlist, like LiveKit’s, needs. A quick tunnel can’t give me that.
The Plausible server. My analytics run on a DigitalOcean droplet with ports 80 and 443 open to the world, because the tracking script has to be reachable by every visitor. That part stays public. But SSH into that droplet doesn’t need port 22 open on the internet; an ssh. route behind Access, with the port closed in the DigitalOcean firewall, means nobody is scanning it. That’s a change I’d make on any server I keep.
A machine at home for jobs. My scraping tutorial ends with “run it from a Mac mini at home on a residential IP”. A tunnel is how the rest of my tools would call it: a hostname, an Access service token for the caller, no port forwarding on the router I don’t control well.
Where it doesn’t fit for me: serving media to other people from home, because of the terms; syncing big files through a public hostname, because of the 100 MB request cap; anything where the extra hop through Cloudflare matters, like a game server; and anything I’d want end-to-end encrypted from my device to the machine, where Tailscale is the tool. For a web app I run myself and want to reach from anywhere, I’d go with a named tunnel and Access in front.
Want me to talk about your product? You can sponsor this site.
Related posts about cloudflare: