Sharing a Laravel, Inertia & Reverb App with ngrok
For the past 8 months I've been working on AtoBeach, a travel booking platform. I've wanted to share it with friends and family to test, but tunneling a Laravel app with Inertia, Vite, and Reverb isn't straightforward - you need three tunnels, not one. Localcan required extra fiddling, expose.dev I couldn't justify, and ngrok out the box didn't work.
I ended up getting ngrok working with a middleware-based approach. One command - composer run dev:tunnel - and everything just works.
Getting Started with ngrok
On Mac, installation is just:
brew install ngrokSign up at ngrok.com - I went with the Personal plan at $20/mo, but you can use any .ngrok.io hostname for free as long as nobody else is using it.
Once you've signed up, authenticate your CLI from the dashboard:
ngrok config add-authtoken <your-token>ngrok Config
Create an ngrok.yml in your project root with three tunnels - one for the app, one for Vite, and one for Reverb. For AtoBeach, mine looks like this:
version: 3
tunnels:
app:
hostname: atob-app.ngrok.io
addr: https://atobeach.test:443
host_header: atobeach.test
proto: http
schemes:
- https
vite:
hostname: atob-vite.ngrok.io
addr: https://localhost:5173
proto: http
schemes:
- https
reverb:
hostname: atob-reverb.ngrok.io
addr: https://reverb.herd.test:443
host_header: reverb.herd.test
proto: http
schemes:
- httpsTwo things tripped me up here. The addr must use https:// because Herd serves over TLS - point at port 80 and you'll hit a redirect loop. And host_header is required so Herd knows which site to serve.
Laravel Config
Add your Vite and Reverb tunnel hostnames to .env:
NGROK_VITE_HOST=atob-vite.ngrok.io
NGROK_REVERB_HOST=atob-reverb.ngrok.ioThen a config file to read them:
return [
'vite_host' => env('NGROK_VITE_HOST'),
'reverb_host' => env('NGROK_REVERB_HOST'),
];The Middleware
This is the core of the whole setup. A single middleware detects ngrok traffic and overrides everything per-request:
public function handle(Request $request, Closure $next): Response
{
if (! app()->isLocal() || ! str_contains($request->getHost(), '.ngrok.')) {
return $next($request);
}
$appUrl = "https://{$request->getHost()}";
Config::set([
'app.url' => $appUrl,
'app.asset_url' => $appUrl,
'session.domain' => $request->getHost(),
]);
URL::forceRootUrl($appUrl);
URL::forceScheme('https');
app('url')->useAssetOrigin($appUrl);
$viteHost = config('tunnel.vite_host');
if ($viteHost && file_exists(public_path('hot'))) {
file_put_contents(storage_path('tunnel-hot'), "https://{$viteHost}");
Vite::useHotFile(storage_path('tunnel-hot'));
}
$reverbHost = config('tunnel.reverb_host');
if ($reverbHost) {
Inertia::share('tunnel', ['reverbHost' => $reverbHost]);
}
return $next($request);
}Prepend it to the web middleware in bootstrap/app.php so it runs before Inertia:
$middleware->web(prepend: [
DetectTunnel::class,
]);ngrok sets X-Forwarded-Host to the tunnel hostname. With trusted proxies configured, $request->getHost() returns that, and we override the app URL, asset URLs, and session domain. For Vite, we write a separate hot file pointing at the tunnel. For Reverb, we share the host as an Inertia prop.
Frontend: Reverb
On the frontend, read the tunnel prop before configuring Echo:
const initialPage = JSON.parse(document.getElementById('app')!.dataset.page!);
const tunnelReverbHost = initialPage.props?.tunnel?.reverbHost;
configureEcho({
broadcaster: import.meta.env.VITE_BROADCAST_CONNECTION,
...(tunnelReverbHost && {
wsHost: tunnelReverbHost,
wsPort: 443,
wssPort: 443,
forceTLS: true,
}),
});Through ngrok, Echo connects to the Reverb tunnel. Locally, it's undefined and Echo uses its defaults. No environment checks needed.
One Command
Add a tunnel script to package.json (the double --config is needed because ngrok v3 replaces the default config, and you need the global one for your authtoken):
"tunnel": "ngrok start --all --config \"$HOME/Library/Application Support/ngrok/ngrok.yml\" --config ngrok.yml"Then in composer.json, a dev:tunnel script that runs everything together - including passing VITE_HMR_HOST so hot reloading works through the tunnel:
"dev:tunnel": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently \"php artisan queue:listen\" \"php artisan pail\" \"VITE_HMR_HOST=$(grep NGROK_VITE_HOST .env | cut -d= -f2) bun run dev\" \"bun run tunnel\" --names=queue,logs,vite,ngrok --kill-others"
]composer run dev:tunnelApp, Vite with HMR, Reverb, queue, logs, and ngrok - one terminal.
Gotchas
A few things that caught me out:
- Use
https://inaddr. Herd serves over TLS. Plain HTTP causes a redirect loop. - Set
host_header. Without it, Herd doesn't know which site you want and you'll get "Site not found". - Vite is HTTPS too. Herd provides TLS for the Vite dev server, so the tunnel needs
addr: https://localhost:5173. - Call
useAssetOrigin(). Settingconfig('app.asset_url')alone isn't enough - you need this forasset()URLs to actually change.
The nice thing about this approach is it's entirely per-request. Visit atobeach.test locally and nothing changes. Visit through ngrok and the middleware handles everything. No cleanup when you stop, no startup order to worry about.