DEV Community

Cover image for Testing a Laravel Herd API on a Real Phone with Tailscale
Manassé Ngudia
Manassé Ngudia

Posted on AI-assisted

Testing a Laravel Herd API on a Real Phone with Tailscale

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.


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

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.

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 404 to every request from the phone — and understanding why is most of the value here.

Scope: this covers a physical device 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.


Why your phone can't see myapp.test

It's tempting to think of this as one problem. It's actually three, and they fail independently.

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

2. Routing. Even with the name resolved, 127.0.0.1 means "this device". On the phone it means the phone. And Herd's nginx binds to the loopback interface only:

lsof -nP -iTCP:443 -sTCP:LISTEN
# nginx ... TCP 127.0.0.1:443 (LISTEN)
Enter fullscreen mode Exit fullscreen mode

That's 127.0.0.1:443, not 0.0.0.0:443. Nothing outside the Mac can open that socket, whatever address it asks for.

3. TLS trust. Herd serves .test 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.

Any solution has to answer all three.


Why the usual answers disappoint

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

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

Use a public tunnel (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 — your in-development app, with its debug output and half-finished auth, is now on the public internet, reachable by anyone who guesses or is handed the URL.

Tailscale 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 publicly trusted certificate, so phones accept it with no profile to install.

LAN IP Public tunnel Tailscale
Works off your network ✗ ✓ ✓
Stable address ✗ Usually ✗ ✓
Trusted TLS ✗ ✓ ✓
Stays private ✓ ✗ ✓

What we're building

Two separate paths, and keeping them separate in your head is the single most useful idea in this article:

  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)
Enter fullscreen mode Exit fullscreen mode

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

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


Prerequisites

  • Herd serving your site locally: https://myapp.test loads in your Mac's browser.
  • Tailscale installed on both the Mac and the phone, logged into the same tailnet.
  • HTTPS enabled for your tailnet (Tailscale admin console → DNS → Enable HTTPS). This is what makes the trusted certificate possible.

Confirm both devices are visible:

tailscale status
Enter fullscreen mode Exit fullscreen mode
100.64.0.10   my-mac              you@   macOS    -
100.64.0.11   my-tablet           you@   android  -
Enter fullscreen mode Exit fullscreen mode

If the phone isn't listed, stop here and fix that first — everything below depends on it.


Step 1 — Expose Herd on your tailnet

One command:

tailscale serve --bg "https+insecure://myapp.test"
Enter fullscreen mode Exit fullscreen mode
Available within your tailnet:

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

Serve started and running in the background.
Enter fullscreen mode Exit fullscreen mode

Two details worth understanding rather than copying.

Why https+insecure. Tailscale connects to Herd over loopback, and Herd presents its own local-CA certificate. Tailscale has no reason to trust that CA, so without +insecure the hop fails. This is not 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 ts.net name, and it's fully validated. That's the trick that makes this pleasant — a publicly trusted cert on a privately reachable host.

--bg persists. The proxy survives reboots until you explicitly turn it off. Check it any time with tailscale serve status.

The first request triggers certificate issuance, which can take long enough to look like a hang. Warm it up:

tailscale cert my-mac.tailnet-name.ts.net
Enter fullscreen mode Exit fullscreen mode

Now test it:

curl -s -o /dev/null -w "%{http_code}\n" https://my-mac.tailnet-name.ts.net/api/user
Enter fullscreen mode Exit fullscreen mode

If that returns 401 — congratulations, you're done, skip to Step 3. If it returns 404, read on, because this is the interesting part.


Step 2 — The 404 that isn't a routing problem

Here's the symptom that costs people an afternoon:

# Through Tailscale — 404
curl -s -o /dev/null -w "%{http_code}\n" https://my-mac.tailnet-name.ts.net/api/user
404

# The exact same route, locally — 401
curl -s -o /dev/null -w "%{http_code}\n" -k https://myapp.test/api/user
401
Enter fullscreen mode Exit fullscreen mode

Same nginx. Same PHP-FPM. Same Laravel. Same route. One works, one doesn't.

What's actually happening

tailscale serve is a reverse proxy, and like most reverse proxies it forwards the original Host header. Even though you told it to proxy to myapp.test, the request arriving at nginx carries:

Host: my-mac.tailnet-name.ts.net
Enter fullscreen mode Exit fullscreen mode

You can prove this in one command, no Tailscale involved — just lie about the Host:

curl -s -o /dev/null -w "%{http_code}\n" -k -H "Host: myapp.test" https://127.0.0.1/api/user
# 401  ← correct

curl -s -o /dev/null -w "%{http_code}\n" -k -H "Host: my-mac.tailnet-name.ts.net" https://127.0.0.1/api/user
# 404  ← reproduced, with Tailscale entirely out of the picture
Enter fullscreen mode Exit fullscreen mode

That's the whole bug. The Host header doesn't match any site Herd knows about.

Why the obvious fix doesn't work

The instinct is to add the hostname to nginx's server_name. This will not work, and understanding why saves you from chasing it.

Look at a Herd vhost:

server {
    listen 127.0.0.1:443 ssl;
    server_name myapp.test www.myapp.test *.myapp.test;
    root /;

    location / {
        rewrite ^ "/Applications/Herd.app/Contents/Resources/valet/server.php" last;
    }
}
Enter fullscreen mode Exit fullscreen mode

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

The fix

Valet has a default site: the project served when no site matches the incoming host. Set it to your project and the fallthrough lands where you want.

Find your config — Herd keeps it separately from a standalone Valet install:

# Herd
~/Library/Application\ Support/Herd/config/valet/config.json

# Standalone Valet
~/.config/valet/config.json
Enter fullscreen mode Exit fullscreen mode

Back it up, then add a default key pointing at the project root (not public/):

{
    "tld": "test",
    "loopback": "127.0.0.1",
    "paths": [ "..." ],
    "default": "/Users/you/Projects/myapp"
}
Enter fullscreen mode Exit fullscreen mode

No restart needed — server.php reads this per request. Re-run the curl:

curl -s -o /dev/null -w "%{http_code}\n" https://my-mac.tailnet-name.ts.net/api/user
# 401
Enter fullscreen mode Exit fullscreen mode

401 is the goal. It means the request reached Laravel and Laravel declined it, which is exactly correct for an unauthenticated call.

Know the side effect. default 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.

A dead end, so you don't repeat it

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.

It didn't. Herd's CLI reported Nginx has been restarted, 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, verify the PID actually changed rather than trusting the success message.

The Valet default approach is one JSON key and needs no restart at all. Prefer it.


Step 3 — Point the mobile app at it

With the API reachable, the app needs two things: the right API URL, and its own bundle delivered to the device.

The API URL

Put the tailnet URL wherever your app reads its API base. In a Vite-based Ionic app that's an env var:

VITE_API_BASE_URL=https://my-mac.tailnet-name.ts.net/api
Enter fullscreen mode Exit fullscreen mode

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

Live reload, and where the dev-server URL really lives

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.

Your committed capacitor.config.ts has no server key and shouldn't. At run time, the CLI writes one into the generated native config:

android/app/src/main/assets/capacitor.config.json
ios/App/App/capacitor.config.json
Enter fullscreen mode Exit fullscreen mode

Both are gitignored. It writes:

{ "server": { "url": "http://192.168.1.42:8101" } }
Enter fullscreen mode Exit fullscreen mode

and strips it again on clean shutdown.

The trap: that cleanup only runs on a clean 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.

This is also why you should never ship an APK built from a live-reload run: it has a dev-server URL baked in.

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

  • --external binds the dev server to all interfaces (including the Tailscale one).
  • --public-host=<host> sets the address written into the native config, used verbatim.

So rather than hardcoding an address, derive it:

{
  "scripts": {
    "dev:android:tailscale": "ionic capacitor run android -l --external --public-host=$(tailscale ip -4 | head -1)"
  }
}
Enter fullscreen mode Exit fullscreen mode

tailscale ip -4 returns this machine's tailnet address. No hardcoded IP, works on any machine with Tailscale, no edit when anything changes.

npm run dev:android:tailscale
Enter fullscreen mode Exit fullscreen mode

Verify the dev server is genuinely reachable over the tailnet:

curl -s -o /dev/null -w "%{http_code}\n" http://100.x.y.z:8101/
# 200
Enter fullscreen mode Exit fullscreen mode

Step 4 — Verify each hop separately

When something fails, resist testing "the app". Test the chain one link at a time — the status code tells you exactly which link broke.

# 1. Herd itself, bypassing everything
curl -s -o /dev/null -w "%{http_code}\n" -k https://myapp.test/api/user

# 2. Host-header routing, still bypassing Tailscale
curl -s -o /dev/null -w "%{http_code}\n" -k \
  -H "Host: my-mac.tailnet-name.ts.net" https://127.0.0.1/api/user

# 3. The full path through Tailscale
curl -s -o /dev/null -w "%{http_code}\n" https://my-mac.tailnet-name.ts.net/api/user

# 4. The dev server over the tailnet
curl -s -o /dev/null -w "%{http_code}\n" http://100.x.y.z:8101/

# 5. From the Mac, can you actually see the phone?
tailscale ping my-tablet
Enter fullscreen mode Exit fullscreen mode

Reading the results:

Code Meaning
401 / 422 Good. You reached Laravel; it responded. An auth error is a successful round trip.
404 Reached nginx, wrong site. Host-header routing — Step 2.
000 Never got an HTTP response: DNS, connection refused, or TLS handshake failure.
502 / 504 Tailscale reached nginx but the upstream failed. Is Herd running?

Send a real request body to see an actual response rather than a bare code:

curl -s -X POST https://my-mac.tailnet-name.ts.net/api/auth/login \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -d '{"email":"nope@example.com","password":"wrong"}'
# {"error":"Unauthorized"}
Enter fullscreen mode Exit fullscreen mode

A JSON error body from your application is the strongest possible signal: every hop works and you're talking to Laravel.


Platform notes

Android: cleartext

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:

<!-- android/app/src/debug/AndroidManifest.xml -->
<application android:usesCleartextTraffic="true"
    tools:replace="android:usesCleartextTraffic" />
Enter fullscreen mode Exit fullscreen mode
<!-- android/app/src/release/AndroidManifest.xml -->
<application android:usesCleartextTraffic="false"
    tools:replace="android:usesCleartextTraffic" />
Enter fullscreen mode Exit fullscreen mode

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

iOS: App Transport Security — a caveat, not a recipe

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

A common ATS relaxation for local development is:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>
Enter fullscreen mode Exit fullscreen mode

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

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

The API is unaffected — it goes over HTTPS to a ts.net 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.

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:

tailscale serve --bg --https=8443 http://127.0.0.1:8101
Enter fullscreen mode Exit fullscreen mode

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

CORS

Capacitor runs your app in a WebView with a real origin, so browser CORS rules genuinely apply. A permissive config/cors.php is typical:

'paths' => ['api/*'],
'allowed_origins' => ['*'],
'supports_credentials' => false,
Enter fullscreen mode Exit fullscreen mode

Worth being precise about why this is acceptable rather than just convenient: with token auth — a JWT or bearer token in an Authorization header — and supports_credentials => false, 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.

If you use cookie-based session auth instead, this reasoning does not hold — you'd need supports_credentials => true, an explicit origin list (the spec forbids * with credentials), and proper SESSION_DOMAIN handling.

WebSockets are a separate problem

If you use Laravel Reverb for broadcasting, note that getting HTTP working does nothing for it. A config left at REVERB_HOST=127.0.0.1 points the phone at itself. 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.


Troubleshooting

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

Security

The default here is genuinely private. tailscale serve publishes only within your tailnet — devices logged into your account. Nothing is on the public internet, and tailscale serve status says so explicitly:

https://my-mac.tailnet-name.ts.net (tailnet only)
Enter fullscreen mode Exit fullscreen mode

Its sibling, tailscale funnel, is the opposite: it puts the same service on the public internet. 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.

Two rules worth keeping:

  1. Never point Serve or Funnel at a production site.
  2. Tear down what you're not using.

Teardown

# Stop the proxy
tailscale serve --https=443 off

# Confirm
tailscale serve status
Enter fullscreen mode Exit fullscreen mode

Then remove the default key from your Valet config if you don't want the global fallback.


Wrapping up

The mechanics reduce to two commands and one JSON key:

tailscale serve --bg "https+insecure://myapp.test"
npm run dev:android:tailscale
Enter fullscreen mode Exit fullscreen mode

plus "default" in your Valet config so Herd knows which project an unfamiliar hostname belongs to.

The idea worth keeping is the one from the diagram: your API and your dev server are two different hops with two different transport rules. 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.

And if you see a 404 from your phone while the same URL loads perfectly in your browser: it's the Host header. It's almost always the Host header.

Top comments (0)