{"id":744,"date":"2026-09-29T05:04:36","date_gmt":"2026-09-29T05:04:36","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/cloudflare-tunnel-how-i-stopped-opening-ports-for-clients\/"},"modified":"2026-09-29T05:04:36","modified_gmt":"2026-09-29T05:04:36","slug":"cloudflare-tunnel-how-i-stopped-opening-ports-for-clients","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/cloudflare-tunnel-how-i-stopped-opening-ports-for-clients\/","title":{"rendered":"How I Stopped Opening Ports With Cloudflare Tunnel"},"content":{"rendered":"<p>I almost opened port 3000 on this very server to show a client her staging dashboard. Old habit: spin up the app, open the port, send a link, forget about it until a scanner finds it. I caught myself, and instead spent twenty minutes wiring up a Cloudflare Tunnel, which is now how every internal tool on this box reaches the outside world. Zero open inbound ports on the firewall except SSH.<\/p>\n<h2 id=\"what-a-tunnel-actually-replaces\">What a tunnel actually replaces<\/h2>\n<p>Before tunnels, exposing something self-hosted meant one of three options: open a port and hope your reverse proxy config is airtight, run a VPN and make every viewer install a client, or pay for a static IP and deal with port forwarding on top of it. The <a href=\"https:\/\/github.com\/cloudflare\/cloudflared\" rel=\"nofollow noopener\" target=\"_blank\">cloudflared daemon<\/a> runs as an outbound-only agent on your server, holds a persistent connection to Cloudflare&rsquo;s edge, and routes traffic to your app through that connection instead of through anything listening on a public port. Nothing needs to accept inbound connections except cloudflared itself, and it initiates the connection, so there&rsquo;s nothing for a port scanner to find.<\/p>\n<h2 id=\"setting-it-up-for-a-real-service\">Setting it up for a real service<\/h2>\n<p>Here&rsquo;s the config I actually run for the staging dashboard, a Grafana instance living on the same Docker network:<\/p>\n<pre><code class=\"language-yaml\"># config.yml\ntunnel: 8f2a1c9e-staging-dashboard\ncredentials-file: \/etc\/cloudflared\/8f2a1c9e.json\n\ningress:\n  - hostname: staging.clientdomain.com\n    service: http:\/\/grafana:3000\n  - hostname: pipeline.abrarqasim.com\n    service: http:\/\/localhost:8000\n  - service: http_status:404\n<\/code><\/pre>\n<p>That last catch-all rule matters more than it looks. Without it, any hostname that hits the tunnel but isn&rsquo;t explicitly listed falls through to cloudflared&rsquo;s default behavior, which in older versions meant an unhelpful blank response. An explicit 404 at the bottom means anything you haven&rsquo;t wired up fails obviously instead of quietly.<\/p>\n<p>Running it as a Docker service instead of a bare systemd unit keeps it on the same network as the containers it&rsquo;s routing to, which avoids a whole class of &ldquo;why can&rsquo;t cloudflared reach my app&rdquo; debugging:<\/p>\n<pre><code class=\"language-yaml\">services:\n  cloudflared:\n    image: cloudflare\/cloudflared:latest\n    command: tunnel --config \/etc\/cloudflared\/config.yml run\n    volumes:\n      - .\/cloudflared:\/etc\/cloudflared\n    networks:\n      - app_network\n    restart: unless-stopped\n<\/code><\/pre>\n<p>The DNS side is one command once the tunnel exists: <code>cloudflared tunnel route dns 8f2a1c9e-staging-dashboard staging.clientdomain.com<\/code> creates a CNAME pointing at your tunnel&rsquo;s unique subdomain on Cloudflare&rsquo;s edge, and it propagates almost immediately since you&rsquo;re not waiting on your own registrar. I&rsquo;ve set up a dozen or so of these now and the DNS step has never once been the part that went wrong; it&rsquo;s always the ingress config or a container sitting on the wrong Docker network.<\/p>\n<h2 id=\"gating-it-behind-a-login-instead-of-a-shared-link\">Gating it behind a login instead of a shared link<\/h2>\n<p>A public hostname is still a public hostname, so for anything more sensitive than a demo dashboard I put <a href=\"https:\/\/developers.cloudflare.com\/cloudflare-one\/policies\/access\/\" rel=\"nofollow noopener\" target=\"_blank\">Cloudflare Access<\/a> in front of the same tunnel. It&rsquo;s a policy layer, not a separate piece of infrastructure: you tell it which email addresses or domains are allowed, and a visitor has to click through a one-time code sent to their inbox before the ingress rule above ever forwards the request. For a client dashboard, I scope the policy to her exact email address and mine, which means the tunnel hostname can leak into a Slack message or a bookmark without becoming a real exposure. It took me longer to write this paragraph than it did to set the policy up in the dashboard.<\/p>\n<h2 id=\"cloudflare-tunnel-vs-tailscale-for-the-specific-case-of-show-a-client-something\">Cloudflare Tunnel vs Tailscale, for the specific case of &ldquo;show a client something&rdquo;<\/h2>\n<p>I use both, for different jobs. Tailscale is the right tool when the audience is me and maybe one other engineer, because it puts everyone on a private mesh network and nothing is reachable from the open internet at all, full stop. That&rsquo;s the wrong shape for a client demo: she doesn&rsquo;t want to install a VPN client to look at her own dashboard. A Cloudflare Tunnel terminates at a normal HTTPS hostname anyone can open in a browser, with Access rules in front of it when I want that email gate. For anything meant for a non-technical person to click a link and see, tunnel wins. For anything meant only for me and my own machines, Tailscale wins, and I&rsquo;d rather run both than force one tool to do the other&rsquo;s job badly.<\/p>\n<h2 id=\"the-failure-mode-worth-planning-for\">The failure mode worth planning for<\/h2>\n<p>Cloudflared is a single outbound process, and if it crashes or the box reboots without it restarting, everything behind that tunnel silently goes dark from the outside, with no local symptom at all, because the app itself is still running fine on its port. I lost about forty minutes the first time this happened to me, checking Grafana&rsquo;s own logs and the app container&rsquo;s health before it occurred to me to check whether the tunnel process was even still alive. <code>restart: unless-stopped<\/code> in the compose file above is not optional, and I added a simple curl-based healthcheck that hits each tunneled hostname from an external monitor every five minutes specifically because a dead tunnel produces no error anywhere on the server itself, only silence on the client&rsquo;s end.<\/p>\n<h2 id=\"what-to-set-up-this-week\">What to set up this week<\/h2>\n<p>If you&rsquo;re still opening ports for anything you self-host, the current cloudflared release installs in about ten minutes and the config above is close to a complete starting point for a single service. Start with whatever internal tool you&rsquo;re most tempted to expose &ldquo;just for a minute,&rdquo; wire the ingress rule, add an Access policy if more than one person needs to see it, and check your firewall rules afterward to confirm you can actually close that port instead of just adding an unused alternative next to it. I run this exact setup on the box behind <a href=\"https:\/\/abrarqasim.com\/blog\/hetzner-vs-digitalocean-what-this-blog-actually-runs-on\" rel=\"noopener\">what this blog actually runs on<\/a>, and the rest of the self-hosting and client infrastructure work I do is at <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">abrarqasim.com\/work<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I used to open a port every time I needed to show a client a staging dashboard. Here&#8217;s the Cloudflare Tunnel setup that replaced that habit for good.<\/p>\n","protected":false},"author":2,"featured_media":743,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I used to open a port every time I needed to show a client a staging dashboard. Here's the Cloudflare Tunnel setup that replaced that habit for good.","rank_math_focus_keyword":"cloudflare tunnel","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302],"tags":[832,80,833,154,8],"class_list":["post-744","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","tag-cloudflare-tunnel","tag-devops","tag-networking","tag-security","tag-self-hosting"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/744","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=744"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/744\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/743"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=744"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=744"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=744"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}