Setting Up PHP Hot Module Reloading with Cloudflare Tunnels and Vite

Categories: Development, Tools

If you haven’t already, check out how to set up a Cloudflared Tunnel before you continue.

I’ve been running Cloudflared tunnels for about a year now. One thing that always bothered me was getting Hot Module Reloading (HMR) to work seamlessly.

Before we get started, make sure that your package.json file has type: module or that your Vite file is using .mjs

The Problem

Before, I had to use project.test, which disrupted my workflow.

For Laravel projects, I had to update my .env file, and for WordPress, I needed to modify wp-config.php by updating WP_HOME and WP_SITEURL, only to revert those changes later.

For this example, we’ll focus on a Laravel set up.

By the end we should have Hot Module Reloading (HMR) working for both standard assets and @viteReactRefresh.

The Laravel Blade directive @vite() checks for the existence of a /public/hot file to check if we’re in development mode. If the file exists, Vite loads the client script using the default path specified in the hot file.

By default, this is localhost or [::1], which obviously isn’t externally accessible or secure, so HMR won’t work.

Looking at the contents of hot, you should see something like: http://[::1]:5173

<script type="module" src="http://[::1]:5173/@vite/client"></script>

This will cause mixed content warnings. If you’ve forced HTTPS through your AppServiceProvider, then Vite is going to try to load over local HTTPS.

Again, this won’t work.

Now, the /public/hot file contains: https://[::1]:5173

<script type="module" src="https://[::1]:5173/@vite/client"></script>

Updating Cloudflared

To get this to work we’re going to need to make a simple adjustment to the Cloudflared Ingress rules.

When setting up Cloudflared, we used a wildcard domain. But now, we’re going to pass Vite through to a specific service. We need to add a new Ingress rule at the top.

Original Cloudflared Config:

tunnel: <Tunnel-UUID>
credentials-file: /path/to/.cloudflared/Tunnel-UUID.json

ingress:
  - hostname: "*.domain.com"
    service: http://127.0.0.1:80
  - hostname: "*.example.com"
    service: http://127.0.0.1:80
  - service: http_status:404

Updated Cloudflared Config:

tunnel: <Tunnel-UUID>
credentials-file: /path/to/.cloudflared/Tunnel-UUID.json

ingress:
  - hostname: "vite.domain.com"
    service: http://127.0.0.1:5173
  - hostname: "*.domain.com"
    service: http://127.0.0.1:80
  - hostname: "*.example.com"
    service: http://127.0.0.1:80
  - service: http_status:404

We’ve added an ingress rule for Vite. Now, every request will be routed from our publicly accessible URL of vite.domain.com to our internal Vite server of http://127.0.0.1:5173/

Restart Cloudflared

Restart the Cloudflared tunnel for the changes to take effect:

sudo launchctl start com.cloudflare.cloudflared
sudo launchctl stop com.cloudflare.cloudflared

Done! 👏

Next, update vite.config.js

We need to make a couple of adjustments to vite.config.js.

  1. We need a way of passing in our public Vite URL To do this we’ll use the .env file.
  2. Adjust Vite’s server configuration:
    • Enable CORS
    • Force HTTP internally
  3. Adjust the HMR configuration:
    • Use WebSocket Secure
    • Use our public URL
    • Use port 443

Environment Changes

First, update the import statement in vite.config.js to include loadEnv:

// Original
import {defineConfig} from 'vite';
// Updated
import {defineConfig, loadEnv} from 'vite';

Next, convert the Vite config from a static configuration to a dynamic configuration:

Before (Static Configuration):

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});

After (Dynamic Configuration):

export default defineConfig(({mode}) => {
    return {
        plugins: [
            laravel({
                input: ['resources/css/app.css', 'resources/js/app.js'],
                refresh: true,
            }),
        ],
    }
});

Perfect, now we can load our .env file by adding the following just before the return statement.

const env = loadEnv(mode, process.cwd(), '');
export default defineConfig(({mode}) => {
    const env = loadEnv(mode, process.cwd(), '');

    return {...}
});

Updating Vite’s Server Config

To enable HMR over Cloudflared, modify the Vite server config inside vite.config.js:

server: {
    host: '0.0.0.0',
    port: 5173,
    https: false,    
    cors: true,
    hmr: {
        protocol: env.VITE_HMR_URL ? "wss" : "ws",
        host: env.VITE_HMR_URL ?? "localhost",
        clientPort: env.VITE_HMR_URL ? 443 : 5173,
    }
}

Notable Changes

  1. Server
    • This defines the behavior of the Vite development server running on your local machine (127.0.0.1:5173).
    • Our Cloudfalre Tunnel Ingress rules are handling this
      • We forced HTTP and we told the server to allow CORS.
  2. HMR
    • This specifies the address for the client-side scripts. The JavaScript running in your browser will use it to connect back to the Vite server for live updates.
    • When you access your site through a public URL like https://vite.domain.com, the browser needs to use that same public URL for the WebSocket connection, not 127.0.0.1.
    • Now we’ll use the URL from env.VITE_HMR_URL, assuming a URL has been entered we’ll use WSS ( WebSocket Secure ) over port 443
    • If env.VITE_HMR_URL wasn’t set then we should revert back to: http://localhost:5173
    • The hot file should now look like this: https://vite.domain.com:443

Example .env Settings:

VITE_HMR_URL=vite.domain.com

After running npm run dev, the Vite client script should look like this:

<script type="module" src="https://vite.domain.com:443/@vite/client"></script>

And if you’re using React, add @viteReactRefresh above your primary @vite script.

And that’s it!

Run npm run dev. Your Vite development server should now work seamlessly with Hot Module Reloading. This setup operates over Cloudflared tunnels with HTTPS. 🚀


Adam Patterson

Adam Patterson

User Interface Designer & Developer with a background in UX. I have spent 5 years as a professionally certified bicycle mechanic and ride year-round.

I am a husband and father of two, I enjoy photography, music, movies, coffee, and good food.

You can find me on Twitter, Instagram, and YouTube!