Skip to content

Instantly share code, notes, and snippets.

@ta
Last active December 12, 2025 07:56
Show Gist options
  • Select an option

  • Save ta/29ba3b487382e3485d52c1e90ae39ea7 to your computer and use it in GitHub Desktop.

Select an option

Save ta/29ba3b487382e3485d52c1e90ae39ea7 to your computer and use it in GitHub Desktop.
Running Vite through a SSH tunnel

Vite & SSH tunnels

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.

Usage

Open two terminals, cd to the project root and run bin/tunnel in one terminal and npm run dev:tunnel in the other.

Nginx Config

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>";
    }
}

project/package.json

{
  "scripts": {
    "dev": "vite",
    "dev:tunnel": "vite --mode tunnel",
  },
}

project/vite-config.js

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.
      }
    }
  }
});

project/.env.tunnel.local

VITE_HMR_HOST=app.proxy-domain.tld
VITE_HMR_PORT=443
VITE_HMR_SCHEME=wss

project/bin/tunnel

#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment