localhost:5173 / vite-tunnel

Exposing localhost:5173 through ngrok or a Cloudflare tunnel

A tunnel agent runs on your own machine and connects to 127.0.0.1:5173, so the default bind is already enough — what stops the first request is server.allowedHosts, which rejects the generated tunnel hostname.

error
Blocked request. This host ("a1b2c3.ngrok-free.app") is not allowed.
To allow this host, add "a1b2c3.ngrok-free.app" to `server.allowedHosts` in vite.config.js.

A tunnel does not need --host

This is the part that is copied wrongly most often. Exposing the dev server to a phone on your LAN needs server.host, because the connection arrives from another machine. A tunnel does not: the agent runs next to the dev server and opens an outbound connection to the tunnel provider, then forwards each request to the local address you gave it. From the socket's point of view that request comes from 127.0.0.1, which the default bind already accepts. If a snippet tells you to combine a tunnel with host: true, it is widening your bind for no reason.

bash
$ npm run dev                     # default bind, localhost only
  Local:   http://localhost:5173/

$ cloudflared tunnel --url http://localhost:5173
  https://tiny-forest-9f2a.trycloudflare.com

$ ngrok http 5173
  Forwarding  https://a1b2c3.ngrok-free.app -> http://localhost:5173

What does stop it: the host check

The browser opens the tunnel URL, so the Host header carries the tunnel hostname, not localhost. Vite compares that header against server.allowedHosts, which is an empty list by default. Only localhost, hostnames under .localhost, and all IP addresses pass without configuration — a generated tunnel hostname is none of those, so the very first request is answered with a plain sentence instead of your app.

browser
Blocked request. This host ("a1b2c3.ngrok-free.app") is not allowed.

The hostname is different the next time you start

That is what makes this different from the container or custom-domain case, where you can write the name into the config once. TryCloudflare generates a random subdomain on trycloudflare.com each time cloudflared connects, and a free ngrok endpoint is assigned a random hostname the same way. A literal entry in vite.config.js is correct for exactly one session, which is why the error comes back on the next start and looks like the fix did not work.

  • Pinned hostname (a reserved ngrok domain, a Cloudflare Tunnel on your own domain): list it once, like any other custom host.
  • Generated hostname (trycloudflare.com, a free ngrok endpoint): do not put it in the config at all — use one of the two routes below.

Route 1: pass the hostname in for that run

Vite reads an environment variable for exactly this situation. __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS adds hosts on top of whatever the config contains, comma-separated for more than one. Nothing is committed, and the value is gone when the shell is.

bash
$ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=a1b2c3.ngrok-free.app npm run dev

# start the tunnel first, read the hostname it prints, then start Vite

Route 2: let the tunnel send a Host the server already allows

The other way round is to leave the Vite config untouched and have the tunnel rewrite the Host header to localhost before the request reaches 5173. Both providers support it, and because localhost is allowed by default, the check then passes with an empty allowedHosts. ngrok documents this as an add-headers Traffic Policy action — setting Host is a special case that replaces the header instead of appending a second value — and notes that the agent's old --host-header flag is deprecated in favour of Traffic Policy.

traffic-policy.yml — ngrok http 5173 --traffic-policy-file traffic-policy.yml
on_http_request:
  - actions:
      - type: add-headers
        config:
          headers:
            host: localhost   # allowed by default, no Vite config needed

The same thing on cloudflared

For a named tunnel the equivalent is httpHostHeader in the origin settings of the ingress rule, which sets the HTTP Host header on requests sent to the local service. It empties by default, meaning the tunnel hostname is passed through unchanged.

~/.cloudflared/config.yml
ingress:
  - hostname: dev.example.com
    service: http://localhost:5173
    originRequest:
      httpHostHeader: localhost
  - service: http_status:404          # catch-all rule is mandatory

Why not just allow .ngrok-free.app and be done

A leading dot allows the domain and every subdomain under it, so .ngrok-free.app would survive every restart. Vite's own rule for what belongs on that list rules it out: a host is safe to add when you control which IP addresses it resolves to. A shared tunnel domain belongs to the provider, not to you. The same reasoning is why the docs say never to add a top-level domain — and why allowedHosts: true carries a danger notice: any website can then reach your dev server through DNS rebinding and read what it serves.

vite.config.js
server: { allowedHosts: true }              # any hostname, including one an attacker points at you
server: { allowedHosts: ['.ngrok-free.app'] } # a domain you do not control
server: { allowedHosts: ['dev.example.com'] } # your domain, your DNS record

The tunnel is HTTPS, so why does the check still run

Vite skips the host check when the dev server itself is using HTTPS. Behind a tunnel it is not. TLS is terminated at the provider's edge, and what arrives on 5173 is a plain HTTP request — the padlock in the browser says nothing about the last hop. This also means the trick of putting TLS in front of the server to make the message disappear does not apply here, which is just as well: it bypasses the check rather than satisfying it.

The page loads over the tunnel but never reloads

The page and the HMR socket are two separate connections, and the second one goes to the tunnel hostname over wss. If the reverse proxy in front of Vite does not forward WebSocket upgrades, the HMR client gives up on that route and tries to reach the WebSocket server directly instead, which from a remote browser goes nowhere. Vite prints the fallback in the console, and the message is expected rather than a fault.

browser console
Direct websocket connection fallback. Check out
https://vite.dev/config/server-options.html#server-ws
to remove the previous connection error.

Fixing the socket, with the current option

Vite documents three ways out, in this order: configure the reverse proxy to proxy WebSocket too; set server.strictPort and point server.ws.clientPort at the same value as server.port; or give server.ws.port a different value from server.port. Note which option you are setting — the WebSocket keys under server.hmr (protocol, host, port, path, clientPort, timeout, server) are deprecated in favour of server.ws. They are still synced automatically, so an older config keeps working, but a new one should not be written that way.

vite.config.js
export default defineConfig({
  server: {
    strictPort: true,
    ws: { clientPort: 5173 },   # same value as server.port
  },
})

Close it when you are finished

A tunnel turns a dev server that serves your source files, your source maps and whatever your config exposes into a public URL, with no authentication in front of it. Quick tunnels are documented as a testing and development tool for that reason. Stop the agent when the demo is over rather than leaving it running in a second terminal tab.

# faq

Questions

Do I need --host for ngrok or cloudflared?

No. The tunnel agent runs on the same machine and connects to 127.0.0.1:5173, which the default bind already accepts. --host only matters when the connection arrives from another machine.

Why does the tunnel hostname stop working after a restart?

Quick tunnels and free ngrok endpoints get a new random hostname every time the agent connects. A hostname written into vite.config.js is only correct for that one session.

Can I allow the whole tunnel domain with a leading dot?

It works, but it puts a domain you do not control on the allowlist. Vite's guidance is to add only hosts whose IP resolution you control. Pass the generated hostname in per run instead, or rewrite the Host header at the tunnel.

The tunnel URL is https — why is the host check still running?

Because the dev server is not the thing serving HTTPS. TLS ends at the tunnel edge and the request reaches 5173 over plain HTTP, so the check applies as normal.

The page loads through the tunnel but hot reload is dead. Why?

The HMR WebSocket is a second connection to the same hostname. If it is not forwarded, Vite falls back to a direct connection and prints "Direct websocket connection fallback". Proxy the WebSocket, or set server.ws.clientPort with strictPort.

Is server.hmr.clientPort still the right setting?

No. The WebSocket options under server.hmr are deprecated in favour of server.ws. Existing configs are synced automatically, but new ones should use server.ws.

# next

Related