Back to blog

Sharing a Laravel, Inertia & Reverb App with ngrok

·4 min read
LaravelInertiaReactDevelopment

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 ngrok

Sign 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:

ngrok.yml
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:
      - https

Two 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:

.env
NGROK_VITE_HOST=atob-vite.ngrok.io
NGROK_REVERB_HOST=atob-reverb.ngrok.io

Then a config file to read them:

config/tunnel.php
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:

app/Http/Middleware/DetectTunnel.php
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:

bootstrap/app.php
$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:

resources/js/app.tsx
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):

package.json
"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:

composer.json
"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:tunnel

App, Vite with HMR, Reverb, queue, logs, and ngrok - one terminal.

Gotchas

A few things that caught me out:

  • Use https:// in addr. 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(). Setting config('app.asset_url') alone isn't enough - you need this for asset() 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.