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.
- We need a way of passing in our public Vite URL To do this we’ll use the
.envfile. - Adjust Vite’s server configuration:
- Enable CORS
- Force HTTP internally
- 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
- 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
HTTPand we told the server to allowCORS.
- We forced
- 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 useWSS( WebSocket Secure ) over port443 - If
env.VITE_HMR_URLwasn’t set then we should revert back to:http://localhost:5173 - The
hotfile 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. 🚀