<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Manassé Ngudia</title>
    <description>The latest articles on DEV Community by Manassé Ngudia (@manasseng).</description>
    <link>https://dev.to/manasseng</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1621264%2Fc4ccfb27-5487-4eef-bc3c-fed8bbf993a1.jpg</url>
      <title>DEV Community: Manassé Ngudia</title>
      <link>https://dev.to/manasseng</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/manasseng"/>
    <language>en</language>
    <item>
      <title>Testing a Laravel Herd API on a Real Phone with Tailscale</title>
      <dc:creator>Manassé Ngudia</dc:creator>
      <pubDate>Mon, 28 Sep 2026 20:14:34 +0000</pubDate>
      <link>https://dev.to/manasseng/testing-a-laravel-herd-api-on-a-real-phone-with-tailscale-491d</link>
      <guid>https://dev.to/manasseng/testing-a-laravel-herd-api-on-a-real-phone-with-tailscale-491d</guid>
      <description>&lt;p&gt;&lt;em&gt;How to get a physical device talking to your local Laravel site — and to the Ionic/Capacitor app that consumes it — without a public tunnel, without a LAN IP, and without TLS warnings.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;You have a Laravel API running beautifully under Herd at &lt;code&gt;https://myapp.test&lt;/code&gt;. You have a mobile app that talks to it. You plug in a phone, launch the app, and every request fails.&lt;/p&gt;

&lt;p&gt;This is the moment a lot of Laravel developers first discover that their local development environment is, from the phone's point of view, completely invisible.&lt;/p&gt;

&lt;p&gt;This article walks through fixing that with Tailscale. Along the way we'll hit one failure that is genuinely hard to diagnose — the site keeps working perfectly in your Mac's browser while returning &lt;code&gt;404&lt;/code&gt; to every request from the phone — and understanding &lt;em&gt;why&lt;/em&gt; is most of the value here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope:&lt;/strong&gt; this covers a &lt;strong&gt;physical device&lt;/strong&gt; reaching a Herd site over Tailscale. Emulators and simulators have different networking models and aren't covered. Where I'm reasoning rather than reporting something I tested, I say so explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why your phone can't see &lt;code&gt;myapp.test&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;It's tempting to think of this as one problem. It's actually three, and they fail independently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. DNS.&lt;/strong&gt; The &lt;code&gt;.test&lt;/code&gt; TLD isn't real. Herd runs a dnsmasq resolver on your Mac that resolves &lt;code&gt;*.test&lt;/code&gt; to &lt;code&gt;127.0.0.1&lt;/code&gt;, and configures macOS to use it. Your phone has never heard of that resolver. To your phone, &lt;code&gt;myapp.test&lt;/code&gt; is not a slow host or a refused connection — it's a name that does not resolve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Routing.&lt;/strong&gt; Even with the name resolved, &lt;code&gt;127.0.0.1&lt;/code&gt; means "this device". On the phone it means &lt;em&gt;the phone&lt;/em&gt;. And Herd's nginx binds to the loopback interface only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsof &lt;span class="nt"&gt;-nP&lt;/span&gt; &lt;span class="nt"&gt;-iTCP&lt;/span&gt;:443 &lt;span class="nt"&gt;-sTCP&lt;/span&gt;:LISTEN
&lt;span class="c"&gt;# nginx ... TCP 127.0.0.1:443 (LISTEN)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;code&gt;127.0.0.1:443&lt;/code&gt;, not &lt;code&gt;0.0.0.0:443&lt;/code&gt;. Nothing outside the Mac can open that socket, whatever address it asks for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. TLS trust.&lt;/strong&gt; Herd serves &lt;code&gt;.test&lt;/code&gt; sites over HTTPS using a certificate from its own local CA, which it installs into your Mac's keychain during setup. Your phone doesn't have that CA. Even once DNS and routing work, you get certificate errors — and on iOS, installing a custom CA profile on a device is a genuine nuisance.&lt;/p&gt;

&lt;p&gt;Any solution has to answer all three.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the usual answers disappoint
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use the LAN IP.&lt;/strong&gt; Bind the dev server to &lt;code&gt;0.0.0.0&lt;/code&gt;, point the phone at &lt;code&gt;http://192.168.1.42:8000&lt;/code&gt;. This works, right up until it doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both devices must be on the same network. Guest wifi, corporate wifi with client isolation, and phone-on-cellular all break it.&lt;/li&gt;
&lt;li&gt;DHCP reassigns your Mac's address and every hardcoded URL goes stale.&lt;/li&gt;
&lt;li&gt;It's plain HTTP. On Android you must enable cleartext; on iOS you fight App Transport Security.&lt;/li&gt;
&lt;li&gt;It doesn't give you &lt;code&gt;myapp.test&lt;/code&gt;, so any Host-based routing or absolute-URL generation behaves differently than in your browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use a public tunnel&lt;/strong&gt; (ngrok, Expose, Cloudflare Tunnel). These genuinely solve DNS, routing and TLS in one step, which is why people reach for them. The costs: the URL usually changes on every restart, so you're re-editing config constantly; free tiers add latency and rate limits; and — the part worth pausing on — &lt;strong&gt;your in-development app, with its debug output and half-finished auth, is now on the public internet&lt;/strong&gt;, reachable by anyone who guesses or is handed the URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tailscale&lt;/strong&gt; is a different shape of answer. It's a WireGuard-based mesh VPN: every device you log in becomes a node on a private network with a stable address and a stable DNS name. Nothing is exposed publicly. And the part that matters most here — Tailscale can terminate TLS for you with a &lt;strong&gt;publicly trusted&lt;/strong&gt; certificate, so phones accept it with no profile to install.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;LAN IP&lt;/th&gt;
&lt;th&gt;Public tunnel&lt;/th&gt;
&lt;th&gt;Tailscale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Works off your network&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stable address&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;Usually ✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trusted TLS&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stays private&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;Two separate paths, and keeping them separate in your head is the single most useful idea in this article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Phone / tablet (on your tailnet)
    │
    ├── app bundle  ──►  http://100.x.y.z:8101
    │                    (Vite dev server, plain HTTP, live reload)
    │
    └── API calls   ──►  https://my-mac.tailnet-name.ts.net/api
                              │   tailscale serve  (TLS terminated here)
                              ▼
                         https://myapp.test
                         (Herd nginx → Valet → Laravel)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;API&lt;/strong&gt; hop gets a real hostname and a real certificate. The &lt;strong&gt;dev server&lt;/strong&gt; hop is plain HTTP to a raw address. They have different transport rules, they fail in different ways, and you debug them separately.&lt;/p&gt;

&lt;p&gt;If you only have a web app and no mobile app, you only need the right-hand path — skip the live-reload section entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Herd serving your site locally: &lt;code&gt;https://myapp.test&lt;/code&gt; loads in your Mac's browser.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tailscale.com/download" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; installed on &lt;strong&gt;both&lt;/strong&gt; the Mac and the phone, logged into the &lt;strong&gt;same&lt;/strong&gt; tailnet.&lt;/li&gt;
&lt;li&gt;HTTPS enabled for your tailnet (Tailscale admin console → DNS → &lt;em&gt;Enable HTTPS&lt;/em&gt;). This is what makes the trusted certificate possible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Confirm both devices are visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100.64.0.10   my-mac              you@   macOS    -
100.64.0.11   my-tablet           you@   android  -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the phone isn't listed, stop here and fix that first — everything below depends on it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Expose Herd on your tailnet
&lt;/h2&gt;

&lt;p&gt;One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale serve &lt;span class="nt"&gt;--bg&lt;/span&gt; &lt;span class="s2"&gt;"https+insecure://myapp.test"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Available within your tailnet:

https://my-mac.tailnet-name.ts.net/
|-- proxy https+insecure://myapp.test

Serve started and running in the background.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth understanding rather than copying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;https+insecure&lt;/code&gt;.&lt;/strong&gt; Tailscale connects to Herd over loopback, and Herd presents its own local-CA certificate. Tailscale has no reason to trust that CA, so without &lt;code&gt;+insecure&lt;/code&gt; the hop fails. This is &lt;em&gt;not&lt;/em&gt; a security compromise: the "insecure" part is a connection from your Mac to itself. The certificate that your phone sees is a completely different one, issued by Let's Encrypt for your &lt;code&gt;ts.net&lt;/code&gt; name, and it's fully validated. That's the trick that makes this pleasant — &lt;strong&gt;a publicly trusted cert on a privately reachable host.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--bg&lt;/code&gt; persists.&lt;/strong&gt; The proxy survives reboots until you explicitly turn it off. Check it any time with &lt;code&gt;tailscale serve status&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first request triggers certificate issuance, which can take long enough to look like a hang. Warm it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale cert my-mac.tailnet-name.ts.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now test it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://my-mac.tailnet-name.ts.net/api/user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns &lt;code&gt;401&lt;/code&gt; — congratulations, you're done, skip to Step 3. If it returns &lt;strong&gt;&lt;code&gt;404&lt;/code&gt;&lt;/strong&gt;, read on, because this is the interesting part.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — The 404 that isn't a routing problem
&lt;/h2&gt;

&lt;p&gt;Here's the symptom that costs people an afternoon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Through Tailscale — 404&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://my-mac.tailnet-name.ts.net/api/user
404

&lt;span class="c"&gt;# The exact same route, locally — 401&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; https://myapp.test/api/user
401
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same nginx. Same PHP-FPM. Same Laravel. Same route. One works, one doesn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's actually happening
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;tailscale serve&lt;/code&gt; is a reverse proxy, and like most reverse proxies it &lt;strong&gt;forwards the original &lt;code&gt;Host&lt;/code&gt; header&lt;/strong&gt;. Even though you told it to proxy &lt;em&gt;to&lt;/em&gt; &lt;code&gt;myapp.test&lt;/code&gt;, the request arriving at nginx carries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host: my-mac.tailnet-name.ts.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can prove this in one command, no Tailscale involved — just lie about the Host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Host: myapp.test"&lt;/span&gt; https://127.0.0.1/api/user
&lt;span class="c"&gt;# 401  ← correct&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Host: my-mac.tailnet-name.ts.net"&lt;/span&gt; https://127.0.0.1/api/user
&lt;span class="c"&gt;# 404  ← reproduced, with Tailscale entirely out of the picture&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole bug. The Host header doesn't match any site Herd knows about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the obvious fix doesn't work
&lt;/h3&gt;

&lt;p&gt;The instinct is to add the hostname to nginx's &lt;code&gt;server_name&lt;/code&gt;. &lt;strong&gt;This will not work&lt;/strong&gt;, and understanding why saves you from chasing it.&lt;/p&gt;

&lt;p&gt;Look at a Herd vhost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;myapp.test&lt;/span&gt; &lt;span class="s"&gt;www.myapp.test&lt;/span&gt; &lt;span class="s"&gt;*.myapp.test&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^&lt;/span&gt; &lt;span class="s"&gt;"/Applications/Herd.app/Contents/Resources/valet/server.php"&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;root /&lt;/code&gt; and that every request is rewritten to a single &lt;code&gt;server.php&lt;/code&gt;. Herd (like Valet, which it's built on) doesn't use nginx to map hostnames to directories. nginx is a thin shell; &lt;strong&gt;&lt;code&gt;server.php&lt;/code&gt; reads the &lt;code&gt;Host&lt;/code&gt; header itself and resolves which project to serve.&lt;/strong&gt; Adding a &lt;code&gt;server_name&lt;/code&gt; alias gets you past nginx and straight into a PHP front controller that looks at &lt;code&gt;Host&lt;/code&gt;, doesn't recognise &lt;code&gt;my-mac.tailnet-name.ts.net&lt;/code&gt;, and falls through.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Valet has a &lt;strong&gt;default site&lt;/strong&gt;: the project served when no site matches the incoming host. Set it to your project and the fallthrough lands where you want.&lt;/p&gt;

&lt;p&gt;Find your config — Herd keeps it separately from a standalone Valet install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Herd&lt;/span&gt;
~/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Support/Herd/config/valet/config.json

&lt;span class="c"&gt;# Standalone Valet&lt;/span&gt;
~/.config/valet/config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back it up, then add a &lt;code&gt;default&lt;/code&gt; key pointing at the &lt;strong&gt;project root&lt;/strong&gt; (not &lt;code&gt;public/&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tld"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"loopback"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"paths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/Users/you/Projects/myapp"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No restart needed — &lt;code&gt;server.php&lt;/code&gt; reads this per request. Re-run the curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://my-mac.tailnet-name.ts.net/api/user
&lt;span class="c"&gt;# 401&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;401&lt;/code&gt; is the goal. It means the request reached Laravel and Laravel declined it, which is exactly correct for an unauthenticated call.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Know the side effect.&lt;/strong&gt; &lt;code&gt;default&lt;/code&gt; is global. Every unmatched hostname on your machine now serves this project instead of 404ing. Sites you reach by name are unaffected, but if you juggle many local projects, remember you've set this — and that a Herd update could silently drop the key, at which point the tailnet 404s return while local still works. That asymmetry is the tell.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  A dead end, so you don't repeat it
&lt;/h3&gt;

&lt;p&gt;My first attempt was a custom nginx server block on a spare port that rewrote the Host and proxied onward — a reasonable design. Herd's config directory does glob-include extra files, so it should have worked.&lt;/p&gt;

&lt;p&gt;It didn't. Herd's CLI reported &lt;code&gt;Nginx has been restarted&lt;/code&gt;, but the master process ID never changed and the listener never appeared. The config was simply never loaded, with no error anywhere. If you ever add custom nginx config to Herd, &lt;strong&gt;verify the PID actually changed&lt;/strong&gt; rather than trusting the success message.&lt;/p&gt;

&lt;p&gt;The Valet &lt;code&gt;default&lt;/code&gt; approach is one JSON key and needs no restart at all. Prefer it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Point the mobile app at it
&lt;/h2&gt;

&lt;p&gt;With the API reachable, the app needs two things: the right API URL, and its own bundle delivered to the device.&lt;/p&gt;

&lt;h3&gt;
  
  
  The API URL
&lt;/h3&gt;

&lt;p&gt;Put the tailnet URL wherever your app reads its API base. In a Vite-based Ionic app that's an env var:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VITE_API_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://my-mac.tailnet-name.ts.net/api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check which env file actually wins.&lt;/strong&gt; Vite's precedence is &lt;code&gt;.env&lt;/code&gt; → &lt;code&gt;.env.[mode]&lt;/code&gt; → &lt;code&gt;.env.local&lt;/code&gt; → &lt;code&gt;.env.[mode].local&lt;/code&gt;, later winning. A forgotten &lt;code&gt;.env.local&lt;/code&gt; overriding the &lt;code&gt;.env&lt;/code&gt; you're carefully editing is a classic way to lose twenty minutes. A machine-specific tailnet URL belongs in &lt;code&gt;.env.local&lt;/code&gt; anyway — it's gitignored, and it shouldn't be in anyone else's checkout.&lt;/p&gt;

&lt;h3&gt;
  
  
  Live reload, and where the dev-server URL really lives
&lt;/h3&gt;

&lt;p&gt;For live reload, Capacitor loads your app from the Vite dev server instead of the bundled files. The Ionic CLI arranges this — and it's worth knowing exactly how, because the mechanism produces a confusing failure mode.&lt;/p&gt;

&lt;p&gt;Your committed &lt;code&gt;capacitor.config.ts&lt;/code&gt; has no &lt;code&gt;server&lt;/code&gt; key and shouldn't. At run time, the CLI writes one into the &lt;strong&gt;generated native&lt;/strong&gt; config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;android/app/src/main/assets/capacitor.config.json
ios/App/App/capacitor.config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are gitignored. It writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://192.168.1.42:8101"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and strips it again on clean shutdown.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The trap:&lt;/strong&gt; that cleanup only runs on a &lt;em&gt;clean&lt;/em&gt; exit. Kill the process — close the terminal, force-quit, lose the SSH session — and the stale URL stays in the native config. The app then keeps trying to load from a dev server that moved or died, and shows a blank screen. If a device build mysteriously loads nothing, check that file first.&lt;/p&gt;

&lt;p&gt;This is also why you should never ship an APK built from a live-reload run: it has a dev-server URL baked in.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;By default the CLI picks the address by enumerating your network interfaces and prompting you — its own help text says to &lt;em&gt;"make sure your device is on the same Wi-Fi network as your computer"&lt;/em&gt;, which is precisely the assumption we're removing. Two flags take control:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--external&lt;/code&gt; binds the dev server to all interfaces (including the Tailscale one).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--public-host=&amp;lt;host&amp;gt;&lt;/code&gt; sets the address written into the native config, used &lt;strong&gt;verbatim&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So rather than hardcoding an address, derive it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dev:android:tailscale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ionic capacitor run android -l --external --public-host=$(tailscale ip -4 | head -1)"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tailscale ip -4&lt;/code&gt; returns this machine's tailnet address. No hardcoded IP, works on any machine with Tailscale, no edit when anything changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev:android:tailscale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify the dev server is genuinely reachable over the tailnet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://100.x.y.z:8101/
&lt;span class="c"&gt;# 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 4 — Verify each hop separately
&lt;/h2&gt;

&lt;p&gt;When something fails, resist testing "the app". Test the chain one link at a time — the status code tells you exactly which link broke.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Herd itself, bypassing everything&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; https://myapp.test/api/user

&lt;span class="c"&gt;# 2. Host-header routing, still bypassing Tailscale&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Host: my-mac.tailnet-name.ts.net"&lt;/span&gt; https://127.0.0.1/api/user

&lt;span class="c"&gt;# 3. The full path through Tailscale&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://my-mac.tailnet-name.ts.net/api/user

&lt;span class="c"&gt;# 4. The dev server over the tailnet&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://100.x.y.z:8101/

&lt;span class="c"&gt;# 5. From the Mac, can you actually see the phone?&lt;/span&gt;
tailscale ping my-tablet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading the results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;401&lt;/code&gt; / &lt;code&gt;422&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Good.&lt;/strong&gt; You reached Laravel; it responded. An auth error is a &lt;em&gt;successful&lt;/em&gt; round trip.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;404&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reached nginx, wrong site. Host-header routing — Step 2.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never got an HTTP response: DNS, connection refused, or TLS handshake failure.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;502&lt;/code&gt; / &lt;code&gt;504&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Tailscale reached nginx but the upstream failed. Is Herd running?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Send a real request body to see an actual response rather than a bare code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://my-mac.tailnet-name.ts.net/api/auth/login &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"nope@example.com","password":"wrong"}'&lt;/span&gt;
&lt;span class="c"&gt;# {"error":"Unauthorized"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A JSON error body from &lt;em&gt;your&lt;/em&gt; application is the strongest possible signal: every hop works and you're talking to Laravel.&lt;/p&gt;




&lt;h2&gt;
  
  
  Platform notes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Android: cleartext
&lt;/h3&gt;

&lt;p&gt;The API hop is HTTPS, so it's fine. The dev-server hop is plain HTTP to a raw address, and modern Android blocks cleartext by default. Capacitor's template enables it globally, but the better pattern is per-build-type overlays — permissive in debug, hardened in release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- android/app/src/debug/AndroidManifest.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;application&lt;/span&gt; &lt;span class="na"&gt;android:usesCleartextTraffic=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
    &lt;span class="na"&gt;tools:replace=&lt;/span&gt;&lt;span class="s"&gt;"android:usesCleartextTraffic"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- android/app/src/release/AndroidManifest.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;application&lt;/span&gt; &lt;span class="na"&gt;android:usesCleartextTraffic=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;
    &lt;span class="na"&gt;tools:replace=&lt;/span&gt;&lt;span class="s"&gt;"android:usesCleartextTraffic"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build-type overlays aren't touched by &lt;code&gt;cap sync&lt;/code&gt;, so the hardened release value survives every regeneration. Production rejects plaintext; development doesn't have to care.&lt;/p&gt;

&lt;h3&gt;
  
  
  iOS: App Transport Security — a caveat, not a recipe
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;I verified the Android path end to end. I did not test iOS.&lt;/strong&gt; What follows is reasoning from Apple's documented rules, and you should confirm it on a device before relying on it.&lt;/p&gt;

&lt;p&gt;A common ATS relaxation for local development is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSAppTransportSecurity&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSAllowsLocalNetworking&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This permits cleartext to private ranges — &lt;code&gt;10/8&lt;/code&gt;, &lt;code&gt;172.16/12&lt;/code&gt;, &lt;code&gt;192.168/16&lt;/code&gt;, link-local and &lt;code&gt;.local&lt;/code&gt;. It's a good setting because it isn't pinned to a specific IP.&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;Tailscale addresses are in the CGNAT range &lt;code&gt;100.64.0.0/10&lt;/code&gt;&lt;/strong&gt;, which is not in that list. So &lt;code&gt;http://100.x.y.z:8101&lt;/code&gt; would be expected to fail ATS on iOS even though it works on Android.&lt;/p&gt;

&lt;p&gt;The API is unaffected — it goes over HTTPS to a &lt;code&gt;ts.net&lt;/code&gt; name with a publicly trusted certificate, which ATS is perfectly happy with. It's only the plain-HTTP dev-server hop that's in question.&lt;/p&gt;

&lt;p&gt;If you hit this, the clean fix is to stop using plain HTTP for that hop too — put the dev server behind Tailscale Serve on a second port so it also gets a real certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale serve &lt;span class="nt"&gt;--bg&lt;/span&gt; &lt;span class="nt"&gt;--https&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8443 http://127.0.0.1:8101
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then point &lt;code&gt;--public-host&lt;/code&gt; at the &lt;code&gt;ts.net&lt;/code&gt; name instead of the raw IP. That removes the cleartext question on both platforms. &lt;strong&gt;I haven't tested this variant either&lt;/strong&gt; — it follows from how Serve works, but verify before trusting it.&lt;/p&gt;

&lt;h3&gt;
  
  
  CORS
&lt;/h3&gt;

&lt;p&gt;Capacitor runs your app in a WebView with a real origin, so browser CORS rules genuinely apply. A permissive &lt;code&gt;config/cors.php&lt;/code&gt; is typical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'paths'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api/*'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="s1"&gt;'allowed_origins'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="s1"&gt;'supports_credentials'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth being precise about why this is acceptable rather than just convenient: with &lt;strong&gt;token auth — a JWT or bearer token in an &lt;code&gt;Authorization&lt;/code&gt; header&lt;/strong&gt; — and &lt;code&gt;supports_credentials =&amp;gt; false&lt;/code&gt;, there is no cookie or session riding along on cross-origin requests. A wildcard origin can't be abused to make authenticated requests on a victim's behalf, because nothing is attached automatically.&lt;/p&gt;

&lt;p&gt;If you use cookie-based session auth instead, this reasoning &lt;strong&gt;does not hold&lt;/strong&gt; — you'd need &lt;code&gt;supports_credentials =&amp;gt; true&lt;/code&gt;, an explicit origin list (the spec forbids &lt;code&gt;*&lt;/code&gt; with credentials), and proper &lt;code&gt;SESSION_DOMAIN&lt;/code&gt; handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebSockets are a separate problem
&lt;/h3&gt;

&lt;p&gt;If you use Laravel Reverb for broadcasting, note that getting HTTP working does nothing for it. A config left at &lt;code&gt;REVERB_HOST=127.0.0.1&lt;/code&gt; points the &lt;em&gt;phone&lt;/em&gt; at &lt;em&gt;itself&lt;/em&gt;. You'd need Reverb bound to a reachable interface and its own Serve mapping. Easy to overlook, because HTTP works fine and only realtime features quietly fail.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;404&lt;/code&gt; over the tailnet, site fine locally&lt;/td&gt;
&lt;td&gt;Host header doesn't match any Herd site&lt;/td&gt;
&lt;td&gt;Set Valet's &lt;code&gt;default&lt;/code&gt; (Step 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;000&lt;/code&gt; / TLS error on first request&lt;/td&gt;
&lt;td&gt;Certificate not issued yet&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tailscale cert my-mac.tailnet-name.ts.net&lt;/code&gt;, retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Device can't resolve the &lt;code&gt;ts.net&lt;/code&gt; name&lt;/td&gt;
&lt;td&gt;MagicDNS off, or device not on the tailnet&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;tailscale status&lt;/code&gt; on both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App loads, all API calls fail&lt;/td&gt;
&lt;td&gt;Wrong env file, or app not rebuilt&lt;/td&gt;
&lt;td&gt;Check env precedence; &lt;code&gt;.env.local&lt;/code&gt; beats &lt;code&gt;.env&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App shows a blank screen&lt;/td&gt;
&lt;td&gt;Stale &lt;code&gt;server.url&lt;/code&gt; from a killed live-reload run&lt;/td&gt;
&lt;td&gt;Check the generated native &lt;code&gt;capacitor.config.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom nginx config ignored&lt;/td&gt;
&lt;td&gt;Herd reports a restart that didn't happen&lt;/td&gt;
&lt;td&gt;Verify the master PID changed; prefer the Valet &lt;code&gt;default&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works on wifi, fails on cellular&lt;/td&gt;
&lt;td&gt;Device dropped off the tailnet&lt;/td&gt;
&lt;td&gt;Check the Tailscale app is connected&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;p&gt;The default here is genuinely private. &lt;code&gt;tailscale serve&lt;/code&gt; publishes &lt;strong&gt;only within your tailnet&lt;/strong&gt; — devices logged into your account. Nothing is on the public internet, and &lt;code&gt;tailscale serve status&lt;/code&gt; says so explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://my-mac.tailnet-name.ts.net (tailnet only)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its sibling, &lt;strong&gt;&lt;code&gt;tailscale funnel&lt;/code&gt;&lt;/strong&gt;, is the opposite: it puts the same service on the &lt;em&gt;public internet&lt;/em&gt;. It's a legitimate tool for webhook testing, but it is not what you want for routine device testing, and it deserves real caution — a dev environment typically has debug mode on, verbose errors, seeded accounts with weak passwords, and no rate limiting.&lt;/p&gt;

&lt;p&gt;Two rules worth keeping:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Never point Serve or Funnel at a production site.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Tear down what you're not using.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Teardown
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stop the proxy&lt;/span&gt;
tailscale serve &lt;span class="nt"&gt;--https&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;443 off

&lt;span class="c"&gt;# Confirm&lt;/span&gt;
tailscale serve status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then remove the &lt;code&gt;default&lt;/code&gt; key from your Valet config if you don't want the global fallback.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The mechanics reduce to two commands and one JSON key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale serve &lt;span class="nt"&gt;--bg&lt;/span&gt; &lt;span class="s2"&gt;"https+insecure://myapp.test"&lt;/span&gt;
npm run dev:android:tailscale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;plus &lt;code&gt;"default"&lt;/code&gt; in your Valet config so Herd knows which project an unfamiliar hostname belongs to.&lt;/p&gt;

&lt;p&gt;The idea worth keeping is the one from the diagram: &lt;strong&gt;your API and your dev server are two different hops with two different transport rules.&lt;/strong&gt; The API gets a real hostname and a real certificate and works anywhere. The dev server is plain HTTP to a raw address and needs platform permission to be reached at all. When something breaks, find out which hop it was before you change anything — the status codes in Step 4 will tell you in about ten seconds.&lt;/p&gt;

&lt;p&gt;And if you see a &lt;code&gt;404&lt;/code&gt; from your phone while the same URL loads perfectly in your browser: it's the Host header. It's almost always the Host header.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>herd</category>
      <category>ionic</category>
      <category>tailscale</category>
    </item>
  </channel>
</rss>
