This blog and an unrelated motor-temperature monitoring backend share a VPS. Both are reached, by browsers for the blog and by HTTPS POSTs from ESP32 gateways on a factory LAN for the monitoring project, through a daemon called cloudflared, which initiates a connection outward to Cloudflare's edge and keeps it open for as long as the service wants to be reachable. The thing the VPS does not have is what is worth dwelling on: there are no inbound TCP ports open to the public internet. Not 80, not 443, not 22. A port scan finds nothing listening, because nothing is. SSH for operations runs on the same outbound-initiated principle from a different vendor. The host joins a Tailscale tailnet via WireGuard, but the public IP accepts nothing. This post is about what cloudflared is actually doing, why the outward-only posture is a genuinely different architectural class from a reverse proxy, and what I have learned from the hours in which the difference mattered.
The short version: I stopped running reverse proxies because I stopped being the party responsible for TLS renewal, for port exposure, for rate limiting, for DDoS mitigation, and for bot filtering. The longer version is that I traded those responsibilities for a single load-bearing dependency on a specific vendor, a specific daemon, and a specific protocol, and it is worth understanding what each of those pieces is actually doing before deciding whether the trade is one you want to make. The trade is the right trade for my two projects. It may or may not be the right trade for yours. I am going to try to describe the components with enough specificity that the question is answerable for cases that are not mine.
What the daemon is doing, at the protocol layer
cloudflared is a Go binary. It typically runs as a systemd service under a dedicated unprivileged user; on this host the two daemons happen to run as root, a deliberate operational choice I may cover in a follow-up post about the threat model that argues for further per-service-user isolation I did not bother with. It reads a configuration file (/etc/cloudflared/config.yml) that names a tunnel credential, a set of ingress rules, and a handful of operational knobs. On startup it resolves Cloudflare Tunnel endpoints, establishes four long-lived outbound connections to Cloudflare data centers by default, presents its tunnel credential, and enters a loop. In my deployment those transport connections are QUIC over UDP; more generally cloudflared defaults to protocol: auto and can fall back to HTTP/2 over TCP if UDP is unavailable. The loop waits for multiplexed requests to arrive over those transport connections from the edge, each corresponding to a request a browser somewhere on the internet has made to a hostname routed to this tunnel.
When a request arrives, cloudflared consults the ingress rules to decide which local origin to forward the request to. Ingress rules are a list, evaluated top-to-bottom, matching hostname and optionally path, and producing a target URL; typically http://127.0.0.1:3000 for Grafana, http://127.0.0.1:8080 for a Go ingest service, unix:/run/myapp.sock if you want Unix domain sockets. cloudflared then opens or reuses a TCP (or Unix) connection to that target, proxies the request, and proxies the response back over the same edge transport connection. The browser sees a normal HTTP response served, as far as it can tell, from the hostname it asked for.
Three things about this flow are worth dwelling on, because each of them is doing more work than it looks like.
The tunnel's edge connections are outbound. Nothing on the VPS accepts inbound connections from the internet on behalf of the tunnel. The tunnel requires no inbound firewall rule. On hosts with a strict egress firewall, cloudflared does need outbound to Cloudflare on port 7844 (UDP for QUIC, TCP for HTTP/2 fallback); most hosts allow this by default. The knock-on effect is that the VPS's public interface can be in DROP on every port without the tunnel noticing. This is not security theatre. A port that is not listening cannot be exploited via a bug in its listener, and a port that is not reachable cannot be fingerprinted by attackers looking for version numbers. It is a meaningful reduction in surface area.
It is worth being precise here, because operators sometimes conflate two different properties. "Bound to loopback only" and "unreachable from the public internet" are not the same thing. A process bound to 0.0.0.0 listens on all interfaces, but it is reachable only on interfaces where routing and firewall policy allow new inbound connections; a process bound to 127.0.0.1 is reachable only from the local network namespace. The tunnel's no-inbound-ports property comes from the firewall, not from any one socket's bind address. Local-only consumers, a Prometheus container scraping over the docker bridge, for instance, can rely on the broader bind without compromising the public-reachability story, provided the firewall remains the gate.
The transport connections are long-lived. By default cloudflared keeps four HA connections open and multiplexes streams over them. On the edge-to-cloudflared leg, a new proxied request becomes a new stream on an existing tunnel transport connection, rather than a new tunnel connection. When those connections are QUIC, stream multiplexing avoids the transport-level head-of-line blocking you would have had with the same design over HTTP/1.1 over TLS over TCP. QUIC connection migration can survive some path changes without a full reconnect. NAT rebinding or certain IP changes are examples of this. A host reboot still tears the process down and forces reconnection; QUIC is resilient, not magic.
The edge is where browser-facing TLS terminates. A browser making a request to my motor monitoring service opens an HTTPS connection to Cloudflare's edge, Cloudflare serves an automatically provisioned and renewed edge certificate, and the request then travels over the encrypted tunnel to cloudflared on the VPS before cloudflared forwards it to the local origin service. I have never generated a certificate for these hostnames. I have never renewed one. I have never had to think about the ACME protocol. These operational concerns are the certificate-authority equivalent of my utility bills — someone else's job, visible to me only as a line item I do not have to investigate.
Why QUIC, specifically
I want to spend a paragraph on this because it is the part of the design that I think is underappreciated by people who encounter Cloudflare Tunnel for the first time and assume it is "an SSH tunnel with a dashboard."
An SSH tunnel is its own encrypted transport over TCP and it can multiplex channels, but those channels still share one TCP connection. That means packet loss or congestion on that TCP session creates transport-level head-of-line blocking in a way QUIC avoids. It also does not have QUIC-style connection migration across NAT rebinding or IP changes. SSH tunnels are useful; they are just a different transport with different scaling properties.
QUIC is a multiplexed stream protocol over UDP with built-in TLS 1.3 and connection-identifier-based migration. A QUIC connection can carry many concurrent streams without TCP head-of-line blocking, and the connection ID lets the session survive some path changes independently of the UDP 4-tuple.
The practical consequence is that cloudflared keeps a small fixed set of long-lived edge-to-daemon connections open, and every proxied request that Cloudflare routes to my tunnel becomes a stream on one of them without a fresh tunnel transport handshake. In my setup that meant four outbound UDP sockets total, not four plus one per request. During periods of zero traffic, those connections cost a handful of keepalives per minute and little else. During periods of heavy traffic, they cost bandwidth, and little else.
The reason I emphasize this is that the multiplexing claim is the part of the protocol I most underestimated when I first deployed cloudflared. I load-tested it from a single client at concurrencies from ten to a thousand, watching the VPS-side socket counts on each tier. cloudflared's outbound UDP sockets stayed pinned at four across every tier. This matches Cloudflare's default HA-connection layout: four long-lived connections across two Cloudflare data centers. The count does not budge with load. At a thousand concurrent in-flight requests, ten thousand total served, success rate was 100%, sustained throughput was about 5200 requests per second, and p99 latency was 1.1 seconds. The multiplexing claim is real on the edge↔cloudflared tunnel leg. Browser↔edge behavior depends on the client protocol, but the tunnel-side outbound socket count remains fixed in my measurements.
The multiplexing only lives on those two legs, though. In the default configuration cloudflared speaks HTTP/1.1 to origins; HTTP/2 to the origin is opt-in via http2Origin and requires HTTPS on the origin. In my default HTTP/1.1 setup, N concurrent edge requests became roughly N concurrent TCP connections from cloudflared to whatever served the local origin. I confirmed this by watching origin-side connection counts climb 1:1 with concurrency in a separate measurement. The origin sees the connection count. What cloudflared actually delivers at the origin is TLS-handshake-free, not connection-count-free. My Grafana service never does TLS, never rotates certs, never runs certbot — that is the operational win, large and real — but the multiplexing benefit lives on the legs facing the public internet, and the origin's connection-handling capacity is the load ceiling.
Knowing which leg owns which property is the difference between "QUIC is magic" thinking and a working operational model. A fair number of production reverse-proxy deployments could be replaced with a QUIC-based tunnel and the result would be strictly better on every operational axis except vendor lock-in — but the "better" is specifically TLS-termination-at-edge plus multiplexed-delivery-to-the-daemon, not infinite origin capacity. The origin is no harder to overwhelm than it ever was.
The "no origin" property
There is a subtle architectural consequence of the no-inbound-ports posture that I want to name explicitly, because I have seen people build on cloudflared without internalizing it and then be surprised.
The thing reachable from the internet is not my VPS. It is not my Go binary. It is not my Postgres database. It is not even my cloudflared daemon. The thing reachable from the internet is a Cloudflare hostname, which Cloudflare routes through its edge to a tunnel identified by a credential file, which happens to be held by my cloudflared daemon on my VPS. If I rebuild the VPS from scratch tomorrow, restore the credential file, and run cloudflared again, the same hostnames will route to the same tunnel on a different machine with a different IP address and nothing externally visible will have changed. The hostname is bound to the tunnel, not to the host.
This has two immediate consequences. First, migrating the service between machines is trivial — the "migration" is the three operations of spinning up a new host, restoring the credential and the origin service, and shutting down the old host. The DNS does not change. The certificate does not change. The edge does not notice. Nobody has to update a /etc/hosts anywhere. This is a feature I have exploited when moving services from one machine to another and across operating systems.
Second, the relationship between DNS and origin is inverted compared to the traditional model. Normally, a hostname A-records to a public IP, and traffic arrives at that IP, and whoever owns the listener on that IP serves the response. With cloudflared, the public hostname usually CNAMEs to <UUID>.cfargotunnel.com, traffic arrives at Cloudflare's edge, and the origin's public IP is not part of the browser's routing decision. From the outside, dig tells you how to reach Cloudflare, not how to reach the box behind the tunnel.
I bring this up because it changes the security model. The tunneled hostname does not route to the VPS IP directly, so leaking the IP does not let an attacker bypass the hostname and reach the tunneled service over 80 or 443 the way it could in a traditional reverse-proxy setup. If all inbound ports are blocked, the leak is mostly informational for that service; any other exposed service on the same host remains its own problem. The decoupling is real and it is load-bearing.
Access, layered on top
Cloudflare Access is a policy layer that sits in front of a tunnel-routed hostname. Policies can be keyed on email allowlists, IdP group membership, IP ranges, country codes, or combinations. The policy is evaluated at the edge, before the request becomes a stream on the tunnel. The architectural consequence is the part that matters: when the hostname or path is correctly covered by Access, failed requests are stopped before the origin sees them. The Go process behind cloudflared does not see a request that fails the check, and a developer who forgets to apply auth middleware to a new route does not accidentally expose it, because the route's reachability is a property of the Access policy at the edge, not a property of the handler at the origin. For defense in depth, Cloudflare recommends validating the Access JWT at cloudflared or at the origin, especially if the origin could ever be reached through another path.
This is a different shape than middleware. Middleware enforces policy inside the process that also serves the route; a bug in the middleware composition (forgotten registration, ordering mistake, conditional that returns early) opens the route. An edge policy enforces before the process is reached at all; the equivalent failure mode would be misconfiguring the policy itself, which is one decision in one place rather than a property of every route handler. The two failure surfaces are not the same size.
The tradeoff is that the policy lives in Cloudflare's dashboard rather than in source control. For a small number of policies on a single hostname, the dashboard is auditable at a glance and per-event. For a fleet of policies across many hostnames, Cloudflare's Terraform provider is serviceable. The policies I run today fit in the former category.
Running two tunnels on one host
The VPS hosts two projects. Each has its own cloudflared systemd unit, its own config file in /etc/cloudflared/, and its own tunnel credential identified by a distinct UUID. Nothing about either project's cloudflared deployment shares state with the other's: no shared file, no shared process, no shared credential, no shared configuration. A daemon that crashes, leaks memory, or gets OOM-killed takes down its own project and nothing else; a credential that leaks compromises one tunnel and one set of hostnames; a version bump can be staged on one service before the other. The cost is the surface repetition of two nearly-identical systemd units. The benefit is that at 3 AM the incident has already told me which service is involved.
What happens during a Cloudflare incident
Cloudflare has had multi-hour incidents in its history. I watched one such incident in real time from the wrong side of the window.
From the origin's perspective, a Cloudflare incident looks like: cloudflared's logs briefly show reconnection attempts, then show a stable connection again, then traffic stops arriving for a while, and then traffic arrives again. The Go process on the VPS is untroubled throughout. Postgres is untroubled. Prometheus is untroubled. The sensors on the factory LAN continue to post to the gateway, the gateway buffers a few minutes of readings across the incident, and the moment Cloudflare's edge resumes delivering streams, the buffered POSTs flush through. The dashboard shows a gap in the temperature trace — roughly the duration of the incident — and then resumes. The blog was unreachable for the duration and was reachable after. I could not moderate comments during the incident.
The point of relating this is to be honest about the coupling. If Cloudflare is down, my services are unreachable for the duration of the outage. This is not hypothetical; it has happened. The question is whether the coupling is worth the operational dividend of not running reverse proxies, certificates, or firewall rules. For me, with two hobby-scale services, it is. For a production system with tighter uptime requirements, it might not be, and the decision point is specifically "how many nines of availability do you actually need, and how does a multi-hour outage every few years compose with your error budget." I have no error budget to compose against, and my readers accept that the blog is occasionally unavailable. The motor-monitoring dashboard's unavailability during a Cloudflare outage does not stop the sensors from taking readings; it only stops me from looking at them. This is, for this project, acceptable. I mention it not to dismiss the coupling but to note the specific form of it.
What I think about cloudflared, after operating two of them
cloudflared is a small piece of infrastructure that replaces a lot of other, larger pieces of infrastructure. It is not an all-purpose substitute for every reverse proxy in the world; it is a specific substitute for the "expose a small service on the internet, with TLS, without running ports" case. That case happens to be the one I find myself in most often, because it is the case that most hobby projects and most small production services are actually in if they are honest with themselves.
The operator-ego property of the trade is worth naming. Running nginx with Let's Encrypt is an activity I am familiar with, have done many times, and can do competently. Running cloudflared is an activity in which I mostly do not participate; the daemon runs, occasionally reconnects, and otherwise does not require my attention. The feeling of the trade is that I have less to do and have therefore produced less visible effort, which to the part of me that confuses busyness with competence reads as a loss. The part of me that has been paged for an expired certificate at 4 AM disagrees, and is correct to disagree. I have come to think of the default assumption — that a self-hosted reverse proxy is the baseline sophistication and anything else is a deviation — as a cultural hangover from a period in which tunnels did not exist at this quality. They exist at this quality now. The deviation, operationally speaking, is the other direction.
If you are considering cloudflared for a service, my honest pitch is: try it for the service you care least about first. You will spend a morning setting it up, and then you will spend a month not thinking about it, and at the end of the month you will have a calibrated opinion about whether the missing piece — the Cloudflare dependency, the dashboard-based policy configuration, the not-running-certbot-anymore — is a piece you wanted to be missing. For my two services the answer is yes. For your services it may be otherwise. But the answer is discoverable in a month of operating one, which is a low price for a useful calibration.
And that is the honest summary: cloudflared is a daemon that trades vendor lock-in for operational simplicity, and the simplicity is not fake. The protocol underneath it is good engineering. The operational affordances are well-designed. The failure mode is specific and bounded. I would make this trade again. I have, in fact, made it twice already.
Comments (0)
No comments yet. Be the first to comment.
Leave a Comment