The internet is full of stories about the troubles of running Vite-enabled projects through a SSH tunnel.
When Vite’s dev server runs locally but is accessed remotely through an SSH reverse tunnel → nginx → TLS → browser, idle HMR WebSocket pings can get delayed or suppressed enough that the client times out and force reloads the entire page.
TL;DR: Set server.hmr.timeout: 10000 (= 10 sec ping interval) in your Vite config.
I got finally it to work - this is my setup for a VueJS project.
Open two terminals, cd to the project root and run bin/tunnel in one terminal and npm run dev:tunnel in the other.
server {
server_name app.proxy-domain.tld;
access_log /var/log/nginx/$server_name.log;
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/app.proxy-domain.tld/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.proxy-domain.tld/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000/;
# WebSocket / HMR support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Long-lived WebSocket timeouts
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# Don’t buffer / mangle streaming/chunked responses
proxy_buffering off;
proxy_request_buffering off;
proxy_redirect off;
error_page 502 @tunnel_closed;
}
location @tunnel_closed {
default_type text/html;
return 502 "<H1>Tunnel closed!</H1>";
}
}{
"scripts": {
"dev": "vite",
"dev:tunnel": "vite --mode tunnel",
},
}import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
plugins: [
vue(),
vueDevTools()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: '127.0.0.1',
allowedHosts: ['.proxy-domain.tld'],
hmr: {
protocol: env.VITE_HMR_SCHEME ?? "ws",
host: env.VITE_HMR_HOST ?? "127.0.0.1",
clientPort: env.VITE_HMR_PORT ?? 5173,
timeout: 10000, // 10 sec ping interval. This was the key solution to keep the websocket open and thus avoid reloading.
}
}
}
});VITE_HMR_HOST=app.proxy-domain.tld
VITE_HMR_PORT=443
VITE_HMR_SCHEME=wss
#!/bin/sh
echo "== Starting tunnels and tailing access logs =="
ssh \
-o ServerAliveInterval=15 \
-o ServerAliveCountMax=4 \
-o TCPKeepAlive=yes \
-o ExitOnForwardFailure=yes \
-R 3000:127.0.0.1:5173 \
proxy-domain.tld \
tail -F -n 0 /var/log/nginx/app.proxy-domain.tld &
trap "pkill -P $$; echo \"\r== Closing down tunnels ==\"; exit" SIGHUP SIGINT SIGTERM
wait