Contents

Cloudflare Tunnel: Convenience and Its Cost

The easiest way to publish a homelab service, and the bill that comes due later

Contents

There is a specific flavour of relief that comes from getting Cloudflare Tunnel working for the first time. You have a service running on a box in a cupboard, behind a router you do not control, on a connection whose public IP changes every few weeks, and fifteen minutes after installing a daemon it is answering on a real hostname with a real certificate. No port forwards. No dynamic DNS. No firewall archaeology. It feels like cheating, and in a sense it is — you have outsourced the hard part of the problem to a company with more edge capacity than most national ISPs.

I run tunnels for a few things and I am glad they exist. I also think most people adopt them without ever sitting down and working out what they have agreed to, which is a shame, because the agreement is interesting and the answer is genuinely different depending on what you are publishing. This is the honest version: how it actually works, what it buys, what it costs, and the specific cases where I would tell you to use something else.

What a tunnel actually is

Advertisement

The mental model people arrive with is usually wrong. They imagine Cloudflare Tunnel as a clever NAT-traversal trick, like the hole-punching a mesh VPN does. It is simpler and more consequential than that.

You install cloudflared on a machine in your network. It dials outbound to Cloudflare’s edge — several long-lived QUIC connections to different data centres — and holds them open. Cloudflare now has a persistent path into your network that your router regards as normal outbound traffic, because that is exactly what it is. When a request arrives at the edge for app.example.com, Cloudflare routes it down one of those held-open connections, cloudflared receives it, and makes a plain HTTP request to whatever local address you configured — http://127.0.0.1:8096, say. The response goes back up the same pipe.

The important consequence: Cloudflare terminates TLS at their edge. The certificate the browser validates is Cloudflare’s, issued for your hostname. Between the edge and cloudflared there is a separate encrypted connection. Between cloudflared and your service there is usually nothing at all, because it is a loopback or LAN hop. So for the moment the request is inside Cloudflare’s infrastructure, it exists in plaintext, in memory, on a machine you do not own.

Every reverse proxy and CDN in existence works this way, and it is the mechanism that makes their WAF, caching and bot filtering possible. You cannot inspect what you cannot decrypt. But it is the thing to internalise before you decide what to put behind one, because it is the whole trade.

Setting one up

The modern flow is dashboard-first, which I find slightly grating but it is genuinely less error-prone than the old config-file dance. You create a tunnel, it gives you a token, you run a container with that token, and you add public hostnames pointing at local addresses. The DNS records get created for you.

If you prefer your infrastructure declared in a file — and I do, because I want it in git — the local-config mode still works and is better documented than people think:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# /etc/cloudflared/config.yml
tunnel: 7f3a1c2e-4b5d-4e8a-9c11-2f6b8d0e4a37
credentials-file: /etc/cloudflared/7f3a1c2e-4b5d-4e8a-9c11-2f6b8d0e4a37.json

ingress:
  # Public, cached, low-risk: fine at the edge.
  - hostname: blog.example.com
    service: http://127.0.0.1:1313

  # Behind Access — no anonymous request ever reaches it.
  - hostname: files.example.com
    service: http://192.168.1.40:8080
    originRequest:
      connectTimeout: 30s
      noHappyEyeballs: true

  # Catch-all. Required, and it must be last.
  - service: http_status:404

Then the tunnel itself, as a compose service, because I want it restarting with everything else:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --config /etc/cloudflared/config.yml run
    volumes:
      - ./cloudflared:/etc/cloudflared:ro
    networks:
      - edge

networks:
  edge:
    external: true

The ingress list is evaluated top to bottom, first match wins, and the bare service: entry at the bottom is mandatory. Forgetting it produces a startup error that reads like a schema complaint rather than “you forgot the catch-all”, which cost me twenty minutes once.

The piece that actually matters is the next one.

Access is the reason to be here

Advertisement

A tunnel by itself publishes a service to the entire internet with a nice certificate. That is a downgrade from a properly configured reverse proxy, since you have added a third party and gained nothing defensively. The thing that makes the whole architecture worth considering is Cloudflare Access, the identity layer that sits in front of the tunnel.

With an Access policy on a hostname, an anonymous request never reaches your origin at all. Cloudflare checks for a valid session token at the edge; if there isn’t one, the visitor gets an identity provider login and your cloudflared process never learns the request existed. Your service — which may be some Node application with a login form written in 2019 — is now behind an authentication check that runs in a different country, on hardware maintained by people whose full-time job is that check.

For anything with a weak or homegrown login, this is a colossal security win and I will not pretend otherwise. It is strictly better than exposing the same app through your own proxy, and it is better than most self-hosted SSO setups purely on the grounds that it cannot fail open when your identity container is down. If you want the self-hosted equivalent, Authelia and Authentik both do this well, and both put the availability of your login on your own uptime rather than someone else’s.

The rule I follow: a tunnel without Access on it should only ever carry something I would happily hand to a stranger. Anything else gets a policy, or it does not get a hostname.

Two details make Access considerably more useful than the dashboard walkthrough suggests. The first is service tokens, which let a script authenticate without a browser. You get a client ID and secret, and you send them as headers:

1
curl -sS https://files.example.com/api/status   -H "CF-Access-Client-Id: 1a2b3c4d5e6f.access"   -H "CF-Access-Client-Secret: $CF_ACCESS_SECRET"

Without this, every automation you own breaks the moment you turn Access on, and the temptation is to carve a bypass rule for a path prefix — which is how people end up with an unauthenticated /api/ on a service they believe is protected. Use tokens.

The second is that Access injects a signed JWT into every request that reaches your origin, in the Cf-Access-Jwt-Assertion header. If your application can consume a JWT or a trusted header, you get single sign-on for free: the edge has already established who the user is, and it tells your app. The catch is that a header is only trustworthy if nothing else can set it, which means your origin must be genuinely unreachable except through the tunnel. On a homelab where the same service also answers on the LAN, that assumption quietly fails, and header-based auth becomes a hole rather than a feature. Bind the service to loopback if you are going to rely on it.

The costs, itemised

You have added a dependency you cannot fix. When Cloudflare has a bad afternoon — and they do, roughly once a year, visibly — your services are down and there is nothing in your rack you can touch to change that. For a photo gallery this is an annoyance. For the thing that lets you into your network when you are 900km away, it is a design flaw. Keep an out-of-band path that does not route through the same company.

Plaintext at the edge, as discussed. For a blog, irrelevant. For a document archive with your tax returns in it, think about it properly. You can push TLS all the way to the origin with a client certificate, which stops Cloudflare being able to hand your traffic to your origin in the clear — but they still terminate the outer connection, so the exposure window closes only partially. If your threat model includes “a company with a US legal address holds a decrypted copy of this”, a tunnel is the wrong tool and no amount of configuration fixes it.

The terms of service are not written for you. Serving significant non-HTML traffic through the free tier — video, large downloads, backup blobs — is against the rules and has been for years. Enforcement is inconsistent, which is worse than strict enforcement, because you build something, it works for eight months, and then it does not. I would not put a media server through a free tunnel and expect it to be a permanent arrangement.

The free tier is a business decision that can be revisited. Tunnels and Access have been free, for a generous number of users, for years now. That is not a promise. The interesting question is what your exit looks like: if the free seats became five seats tomorrow, how long would it take you to move? For a config-file tunnel fronting a Caddy instance, about an hour — the origin already speaks HTTP and you swap what sits in front of it. For a setup where Access is your authentication, because the apps behind it have no login of their own, the answer is weeks, because you now have to install and populate an identity provider you have never run. That asymmetry is the real lock-in, and it is worth knowing which side of it you are on before you need to care.

It is a very good tracking position. Every request to every service you publish, with source IP and timing, flows through one company’s logs. That is a fact about the architecture. Whether it bothers you is a matter for your own threat model, and I would rather you decided consciously than by default.

Troubleshooting

Error 1033 (tunnel not found / unavailable). The tunnel daemon is not connected. Check docker logs cloudflared for connection registration lines — you want to see four Registered tunnel connection messages with different connIndex values. One or two means your firewall is eating outbound QUIC on UDP/7844. Force fallback with --protocol http2 and see if it comes back; if it does, that is your problem.

Error 502 with a healthy-looking tunnel. The edge reached cloudflared and cloudflared failed to reach your service. Nearly always this is 127.0.0.1 inside a container meaning the container, rather than the host. Point at the container name on a shared network, or use host.docker.internal, or run with network_mode: host and accept the mess.

Redirect loops on an app behind Access. The application is issuing HTTP redirects because it thinks it is being served over plain HTTP. It is — the last hop is unencrypted, and the app has no idea about the TLS three legs upstream. Find the app’s “trust proxy” or “external URL” setting and tell it the truth. Nextcloud, Jellyfin and Gitea each have their own name for this switch and each will loop cheerfully until you find it.

Websockets dropping every 30 seconds. Usually an idle timeout on a proxy in between, or connectTimeout set too low in originRequest. Cloudflare’s edge handles websockets fine; the problem is almost always local.

Everything works, then dies at 3am, then works again. Your ISP is renumbering you and the daemon is reconnecting. This is fine and is one of the genuine benefits — it recovers without you. If it does not recover, cloudflared is old; the reconnection logic has improved a lot over the last two years.

The alternatives, honestly

Advertisement

If your objection is the third party rather than the mechanism, Pangolin does the same architectural trick with a VPS you rent, and you keep the TLS keys. You are now the person who patches the edge, which is real work, and your edge has one point of presence instead of three hundred.

If you only need yourself to reach your services, you do not want a tunnel at all — you want a mesh VPN. Tailscale or plain WireGuard gives you access with no public surface whatsoever, and the security properties are dramatically better because there is no public surface to attack. The tunnel question only becomes interesting when other people need in — family, a friend, a phone that will not hold a VPN profile.

And if you are only fighting CGNAT, an IPv6 address and a firewall rule may solve your entire problem for free, assuming everything that needs to reach you has v6. Which, in 2025, is most things and never the one thing you need.

The verdict

Cloudflare Tunnel is the correct answer for publishing low-sensitivity services to people who are not you, from a network you do not control, with an identity layer you did not write. That is a narrow description and it fits a lot of homelabs. Blog, status page, a share for the family, a Home Assistant instance behind an Access policy: put it on a tunnel, sleep well.

It is the wrong answer for your emergency access path, for anything whose plaintext you would not email, and for bulk media you intend to serve for years. The convenience is real and I use it. The cost is a dependency, a plaintext window and a set of terms that can change without your input — and the reason to write it all down is that the failure mode of a convenience this good is forgetting you ever made the trade.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.