{"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-10-22T05:53:57","modified_gmt":"2026-10-21T21:53:57","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":"Make a Local Service Accessible to Cloud Agents 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 Codex Cloud 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>Your remote server -&gt; https:\/\/your-tunnel-name.example.com -&gt; Cloudflare edge servers -&gt; Cloudflare Tunnel (cloudflared) -&gt; qBittorrent Web UI on your local machine<\/code><\/pre>\n<p>You can probably do the same thing with Tailscale, but unfortunately, the 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 target=\"_blank\" 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 target=\"_blank\" 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 target=\"_blank\" href=\"https:\/\/dash.cloudflare.com\/\">Cloudflare dashboard<\/a> -&gt; Networking -&gt; Tunnels<\/li>\n<li>Create Tunnel\n<ul>\n<li>Tunnel name: <code>your-tunnel-name<\/code><\/li>\n<li>Click <code>Create Tunnel<\/code><\/li>\n<\/ul>\n<\/li>\n<li>Open the tunnel -&gt; Overview -&gt; Add a replica -&gt; macOS\n<ul>\n<li>Copy the <strong>tunnel token<\/strong> from the installation command (the value after <code>service install<\/code>)<\/li>\n<\/ul>\n<\/li>\n<li>Open the tunnel -&gt; Routes -&gt; Add route -&gt; Published application\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 URL: <code>http:\/\/localhost:8080<\/code><\/li>\n<\/ul>\n<\/li>\n<li>Click <code>Add route<\/code>\n<ul>\n<li>After you create the <strong>published application route<\/strong>, Cloudflare will automatically create the DNS record for your subdomain<\/li>\n<\/ul>\n<\/li>\n<\/ol>\n<p>ref:<br \/>\n<a target=\"_blank\" 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 target=\"_blank\" 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>Protect the local service with an access policy and let your cloud agent authenticate with a service token instead of a browser login.<\/p>\n<ol>\n<li>Go to <a target=\"_blank\" href=\"https:\/\/dash.cloudflare.com\/\">Cloudflare dashboard<\/a> -&gt; Zero Trust -&gt; Access controls -&gt; Service credentials<\/li>\n<li>Service Tokens -&gt; Create Service Token\n<ul>\n<li>Service token name: <code>your-token-name<\/code><\/li>\n<li>Service token duration: <code>Non-expiring<\/code><\/li>\n<li>Click <code>Generate token<\/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>Policies -&gt; Add a policy\n<ul>\n<li>Policy details:\n<ul>\n<li>Policy Name: <code>your-policy-name<\/code><\/li>\n<li>Action: <strong><code>Service Auth<\/code><\/strong><\/li>\n<li>Policy session duration: <code>24 hours<\/code><\/li>\n<\/ul>\n<\/li>\n<li>Policy 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<li>Click <code>Save policy<\/code><\/li>\n<\/ul>\n<\/li>\n<li>Applications -&gt; Create new application -&gt; Self-hosted and private -&gt; Continue with Self-hosted and private\n<ul>\n<li>Destinations -&gt; Public hostnames:\n<ul>\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>Access policies -&gt; Add current policies:\n<ul>\n<li>Select the policy you created in step 2.3<\/li>\n<\/ul>\n<\/li>\n<li>Details:\n<ul>\n<li>Name: <code>your-tunnel-name<\/code><\/li>\n<li>Session Duration: <code>24 hours<\/code><\/li>\n<\/ul>\n<\/li>\n<li>Click <code>Create<\/code><\/li>\n<\/ul>\n<\/li>\n<\/ol>\n<p>ref:<br \/>\n<a target=\"_blank\" 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 target=\"_blank\" href=\"https:\/\/developers.cloudflare.com\/cloudflare-one\/access-controls\/policies\/\">https:\/\/developers.cloudflare.com\/cloudflare-one\/access-controls\/policies\/<\/a><br \/>\n<a target=\"_blank\" 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 needed.<\/p>\n<pre class=\"line-numbers\" data-label=\"bash\"><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 target=\"_blank\" 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\" data-label=\"bash\"><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\" data-label=\"bash\"><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 target=\"_blank\" href=\"https:\/\/github.com\/qbittorrent\/qBittorrent\/wiki\/#webui\">https:\/\/github.com\/qbittorrent\/qBittorrent\/wiki\/#webui<\/a><\/p>\n<h3>5. Disable Browser Integrity Check on Cloudflare (Optional)<\/h3>\n<p>If Cloudflare blocks requests from your remote server and returns <strong>HTTP 403 with Cloudflare error 1010<\/strong>, you need to disable Browser Integrity Check:<\/p>\n<ol>\n<li>Go to <a target=\"_blank\" href=\"https:\/\/dash.cloudflare.com\/\">Cloudflare dashboard<\/a> -&gt; <code>example.com<\/code> -&gt; Rules -&gt; Overview<\/li>\n<li>Configuration Rules -&gt; Create rule\n<ul>\n<li>Rule name: <code>Disable Browser Integrity Check for your-tunnel-name<\/code><\/li>\n<li>If incoming requests match: <code>Custom filter expression<\/code><\/li>\n<li>When incoming requests match:\n<ul>\n<li>Field: <code>Hostname<\/code><\/li>\n<li>Operator: <code>equals<\/code><\/li>\n<li>Value: <code>your-tunnel-name.example.com<\/code> (must match the hostname in step 1.4)<\/li>\n<\/ul>\n<\/li>\n<li>Then the settings are -&gt; Browser Integrity Check:\n<ul>\n<li>Click <code>+ Add<\/code><\/li>\n<li>Leave the toggle off<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<\/li>\n<li>Click <code>Deploy<\/code>, then retry the request from your remote server<\/li>\n<\/ol>\n<p>This disables Browser Integrity Check only for that hostname instead of the entire domain.<\/p>\n<p>ref:<br \/>\n<a target=\"_blank\" href=\"https:\/\/developers.cloudflare.com\/waf\/tools\/browser-integrity-check\/\">https:\/\/developers.cloudflare.com\/waf\/tools\/browser-integrity-check\/<\/a><br \/>\n<a target=\"_blank\" href=\"https:\/\/developers.cloudflare.com\/rules\/configuration-rules\/\">https:\/\/developers.cloudflare.com\/rules\/configuration-rules\/<\/a><\/p>\n<h2>Why Cloudflare Tunnel Over Tailscale?<\/h2>\n<ul>\n<li>A good fit if your infrastructure is already on Cloudflare<\/li>\n<li>Works with Mullvad VPN if you use split tunneling to exclude <code>cloudflared<\/code><\/li>\n<li>Covered by Cloudflare Zero Trust's free tier<\/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}]}}