# The complete guide to Cloudflare Tunnel

> 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.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-23 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/cloudflare-tunnel/

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](https://flaviocopes.com/cloudflare-quick-tunnels/), 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.com` instead of `kingston-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 and `ssh.` to port 22. A quick tunnel proxies one origin.
- **A service, not a terminal.** `cloudflared` installs 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](https://dash.cloudflare.com/sign-up). The free one is enough.
- A domain whose nameservers point at Cloudflare. If you bought it elsewhere, [add it to Cloudflare](https://developers.cloudflare.com/fundamentals/manage-domains/add-site/) 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. `cloudflared` connects out on port `7844`, 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:

```bash
brew install cloudflared
```

On Omarchy or any Arch:

```bash
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:

```bash
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](https://developers.cloudflare.com/tunnel/downloads/) 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:

1. **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.
2. **The connector.** A running `cloudflared` process 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.
3. **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).
4. **The DNS record.** A proxied CNAME from `photos.flaviocopes.com` to `<UUID>.cfargotunnel.com`. That's how a request for your hostname ends up at your tunnel.

```mermaid
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:

```js
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](https://dash.cloudflare.com/), 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:

```bash
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](https://developers.cloudflare.com/tunnel/troubleshooting/https-origins/) 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:

```bash
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`:

```bash
sudo launchctl stop com.cloudflare.cloudflared
sudo launchctl start com.cloudflare.cloudflared
```

A warning about macOS. There is a [GitHub issue](https://github.com/cloudflare/cloudflared/issues/327) 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:

```bash
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

```bash
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

```bash
cloudflared tunnel create macmini-home
```

```text
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.

```bash
cloudflared tunnel list
```

```text
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`:

```yaml
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:

```bash
cloudflared tunnel ingress validate
```

```text
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:

```text
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:

```bash
cloudflared tunnel ingress rule https://photos.flaviocopes.com/2026/summer
```

```text
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

```bash
cloudflared tunnel route dns macmini-home photos.flaviocopes.com
```

```text
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

```bash
cloudflared tunnel run macmini-home
```

Here is what mine printed, without the pre-check table:

```text
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:

```bash
cloudflared tunnel info macmini-home
```

```text
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:

```bash
curl 127.0.0.1:20241/ready
```

```json
{"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:

```bash
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:

```bash
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:

```yaml
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:

```yaml
  - 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](https://developers.cloudflare.com/tunnel/reference/origin-parameters/).

## 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:

```text
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](https://www.cloudflare.com/service-specific-terms-application-services/#content-delivery-network-free-pro-or-business), 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](https://flaviocopes.com/courses/vpn/) on this site, which has a whole module on building a Cloudflare private network, and to the [Cloudflare course](https://flaviocopes.com/courses/cloudflare/), 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](https://developers.cloudflare.com/tunnel/tutorials/grafana/) 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](https://yetidevworks.com/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:

```bash
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](https://flaviocopes.com/scraping-tool/) 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.
