{"id":907,"date":"2026-03-16T00:49:39","date_gmt":"2026-03-15T16:49:39","guid":{"rendered":"https:\/\/vinta.ws\/code\/?p=907"},"modified":"2026-03-16T15:16:14","modified_gmt":"2026-03-16T07:16:14","slug":"expose-a-local-service-with-cloudflare-tunnel","status":"publish","type":"post","link":"https:\/\/vinta.ws\/code\/expose-a-local-service-with-cloudflare-tunnel.html","title":{"rendered":"Expose a Local Service with Cloudflare Tunnel"},"content":{"rendered":"<p>Expose a service running on your local machine to a remote server without opening any ports. For instance, <strong>let your OpenClaw agent (the remote server) access qBittorrent Web UI on your Mac (the local machine), to download a movie for you.<\/strong><\/p>\n<p>The local machine makes an outbound-only connection to Cloudflare. The remote server hits your subdomain on Cloudflare's edge. Traffic flows:<\/p>\n<pre><code>OpenClaw on your remote server -&gt; https:\/\/your-tunnel-name.example.com -&gt; Cloudflare edge servers -&gt; Cloudflare Tunnel -&gt; qBittorrent Web UI on your local machine<\/code><\/pre>\n<p>You can probably do the same thing with Tailscale, but unfortunately, Tailscale app doesn't work well with Mullvad VPN on macOS (and I don't want to use Tailscale's Mullvad VPN add-on).<\/p>\n<p>ref:<br \/>\n<a href=\"https:\/\/developers.cloudflare.com\/cloudflare-one\/networks\/connectors\/cloudflare-tunnel\/\">https:\/\/developers.cloudflare.com\/cloudflare-one\/networks\/connectors\/cloudflare-tunnel\/<\/a><br \/>\n<a href=\"https:\/\/tailscale.com\/docs\/features\/exit-nodes\/mullvad-exit-nodes\">https:\/\/tailscale.com\/docs\/features\/exit-nodes\/mullvad-exit-nodes<\/a><\/p>\n<h2>Setup<\/h2>\n<h3>1. Create Cloudflare Tunnel<\/h3>\n<p>Do this from any device where you're logged into Cloudflare. <strong>No login needed on the local machine or the remote server.<\/strong><\/p>\n<ol>\n<li>Go to <a href=\"https:\/\/one.dash.cloudflare.com\/\">Cloudflare Zero Trust dashboard<\/a><\/li>\n<li>Networks -&gt; Connectors -&gt; Create a tunnel -&gt; Cloudflared\n<ul>\n<li>Name your tunnel: <code>your-tunnel-name<\/code><\/li>\n<\/ul>\n<\/li>\n<li>Copy the <strong>tunnel token<\/strong><\/li>\n<li>Configure the tunnel you just created -&gt; Published application routes -&gt; Add a published application route\n<ul>\n<li>Subdomain: <code>your-tunnel-name<\/code><\/li>\n<li>Domain: select your domain from the dropdown (e.g., <code>example.com<\/code>)<\/li>\n<li>Path: <code>[leave empty]<\/code><\/li>\n<li>Service:\n<ul>\n<li>Type: <code>HTTP<\/code><\/li>\n<li>URL: <code>localhost:8080<\/code><\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<\/li>\n<li>After you create the <strong>published application route<\/strong>, Cloudflare will automatically create the DNS record for your subdomain<\/li>\n<\/ol>\n<p>ref:<br \/>\n<a href=\"https:\/\/developers.cloudflare.com\/cloudflare-one\/networks\/connectors\/cloudflare-tunnel\/get-started\/tunnel-useful-terms\/\">https:\/\/developers.cloudflare.com\/cloudflare-one\/networks\/connectors\/cloudflare-tunnel\/get-started\/tunnel-useful-terms\/<\/a><br \/>\n<a href=\"https:\/\/developers.cloudflare.com\/cloudflare-one\/networks\/routes\/add-routes\/\">https:\/\/developers.cloudflare.com\/cloudflare-one\/networks\/routes\/add-routes\/<\/a><\/p>\n<h3>2. Access Controls for Cloudflare Tunnel<\/h3>\n<p>Still in the Cloudflare Zero Trust dashboard.<\/p>\n<ol>\n<li>Access controls -&gt; Service credentials -&gt; Service Tokens -&gt; Create Service Token\n<ul>\n<li>Token name: <code>your-token-name<\/code><\/li>\n<li>Service Token Duration: <code>Non-expiring<\/code><\/li>\n<li>Save the <code>CF-Access-Client-Id<\/code> and <code>CF-Access-Client-Secret<\/code> (<strong>shown only once<\/strong>)<\/li>\n<\/ul>\n<\/li>\n<li>Access controls -&gt; Policies -&gt; Add a policy\n<ul>\n<li>Policy name: <code>your-policy-name<\/code><\/li>\n<li>Action: <strong><code>Service Auth<\/code><\/strong><\/li>\n<li>Session duration: <code>24 hours<\/code><\/li>\n<li>Configure rules -&gt; Include:\n<ul>\n<li>Selector: <strong><code>Service Token<\/code><\/strong><\/li>\n<li>Value: select the service token you just created (e.g., <code>your-token-name<\/code>)<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<\/li>\n<li>Access controls -&gt; Applications -&gt; Add an application -&gt; <strong>Self-hosted<\/strong>\n<ul>\n<li>Application name: <code>your-tunnel-name<\/code><\/li>\n<li>Session Duration: <code>24 hours<\/code><\/li>\n<li>Add public hostname:\n<ul>\n<li>Input method: <code>Default<\/code><\/li>\n<li>Subdomain: <code>your-tunnel-name<\/code> (must match the subdomain in step 1.4)<\/li>\n<li>Domain: select your domain from the dropdown (e.g., <code>example.com<\/code>)<\/li>\n<li>Path: <code>[leave empty]<\/code><\/li>\n<\/ul>\n<\/li>\n<li><strong>Select existing policies<\/strong> (this text is a clickable button, not a label!)\n<ul>\n<li>Check the policy you created in step 2.2<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<\/li>\n<\/ol>\n<p>ref:<br \/>\n<a href=\"https:\/\/developers.cloudflare.com\/cloudflare-one\/access-controls\/service-credentials\/service-tokens\/\">https:\/\/developers.cloudflare.com\/cloudflare-one\/access-controls\/service-credentials\/service-tokens\/<\/a><br \/>\n<a href=\"https:\/\/developers.cloudflare.com\/cloudflare-one\/access-controls\/policies\/\">https:\/\/developers.cloudflare.com\/cloudflare-one\/access-controls\/policies\/<\/a><br \/>\n<a href=\"https:\/\/developers.cloudflare.com\/cloudflare-one\/access-controls\/applications\/http-apps\/\">https:\/\/developers.cloudflare.com\/cloudflare-one\/access-controls\/applications\/http-apps\/<\/a><\/p>\n<h3>3. Run cloudflared on Local Machine (macOS)<\/h3>\n<p>Make <code>cloudflared<\/code> run on boot, connecting outbound to Cloudflare. No browser auth ever needed.<\/p>\n<pre class=\"line-numbers\"><code class=\"language-bash\">brew install cloudflared\n\n# install as a LaunchAgent using the tunnel token from step 1\nsudo cloudflared service install YOUR_TUNNEL_TOKEN<\/code><\/pre>\n<p>ref:<br \/>\n<a href=\"https:\/\/developers.cloudflare.com\/cloudflare-one\/networks\/connectors\/cloudflare-tunnel\/downloads\/\">https:\/\/developers.cloudflare.com\/cloudflare-one\/networks\/connectors\/cloudflare-tunnel\/downloads\/<\/a><\/p>\n<p>To verify it's running:<\/p>\n<pre class=\"line-numbers\"><code class=\"language-bash\">sudo launchctl list | grep cloudflared<\/code><\/pre>\n<h3>4. Access the Local Service on Remote Server<\/h3>\n<p>Test that the tunnel and access policy work. We're accessing qBittorrent Web UI here:<\/p>\n<pre class=\"line-numbers\"><code class=\"language-bash\">curl \\\n  -H \"CF-Access-Client-Id: $YOUR_CF_ACCESS_CLIENT_ID\" \\\n  -H \"CF-Access-Client-Secret: $YOUR_CF_ACCESS_CLIENT_SECRET\" \\\n  -d \"username=YOUR_USERNAME&amp;password=YOUR_PASSWORD\" \\\n  https:\/\/your-tunnel-name.example.com\/api\/v2\/auth\/login<\/code><\/pre>\n<p>The <code>CF-Access-XXX<\/code> headers must be included on <strong>every<\/strong> request. Without them, Cloudflare returns a 302 redirect to a login page.<\/p>\n<p>ref:<br \/>\n<a href=\"https:\/\/github.com\/qbittorrent\/qBittorrent\/wiki\/#webui\">https:\/\/github.com\/qbittorrent\/qBittorrent\/wiki\/#webui<\/a><\/p>\n<h2>Why Cloudflare Tunnel Over Tailscale<\/h2>\n<ul>\n<li>No login on endpoints: The tunnel token is scoped to one tunnel, can't access your Cloudflare account<\/li>\n<li>No VPN conflicts: <code>cloudflared<\/code> is just outbound HTTPS, Mullvad VPN doesn't care<\/li>\n<li>Free: Cloudflare Zero Trust free tier covers this<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Expose a service running on your local machine to a remote server without opening any ports. For instance, let your OpenClaw agent (the remote server) access qBittorrent Web UI on your Mac (the local machine).<\/p>\n","protected":false},"author":1,"featured_media":908,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[38],"tags":[101,151,134],"class_list":["post-907","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-about-devops","tag-cli-tool","tag-cloudflare","tag-networking"],"_links":{"self":[{"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/posts\/907","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/comments?post=907"}],"version-history":[{"count":0,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/posts\/907\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/media\/908"}],"wp:attachment":[{"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/media?parent=907"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/categories?post=907"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/tags?post=907"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}