log in
consulting hosting industries the daily tools about contact

Vite HMR Through nginx in Docker: the WebSocket Config Nobody Documents

Getting Vite's hot module replacement to work through an nginx reverse proxy in Docker took me longer than it should have. Here's the exact config.

Every time I set up a new Dockerized dev environment with Vite behind nginx, I waste at least two hours on the same WebSocket problem. The HMR connection silently fails, the browser console shows a cryptic WebSocket error, and nothing in the Vite docs or the nginx docs quite covers the intersection. I'm writing this down so I stop losing those two hours — and so you don't lose them at all.

What Vite HMR Actually Does (and Why nginx Breaks It)

Vite's hot module replacement works by opening a persistent WebSocket connection from the browser back to the Vite dev server. When you save a file, Vite pushes an update over that socket and the browser swaps the module without a full reload. It's fast and it's good. The catch is that WebSocket connections have specific upgrade semantics that nginx doesn't handle by default — and when you layer a Docker network on top, the hostname and port the browser tries to connect to often don't match what's actually running inside the container.

The failure mode is quiet. Your app loads. The page renders. But when you edit a file, nothing happens. You check the browser console and see something like:

[vite] failed to connect to websocket.
your current setup:
  (browser) localhost:80 <--[HTTP]--> localhost:80 (server)
  (browser) localhost:24678 <--[WebSocket]--> localhost:24678 (server)

Vite is trying to open a WebSocket directly to port 24678 (its default HMR port), which is not exposed through nginx at all. So the connection goes nowhere.

This is the core mismatch: Vite defaults to a separate HMR port, nginx only exposes one port, and Docker's networking means the browser can't reach the container directly even if you wanted it to.

The Fix: Three Moving Parts

You need to touch three things: vite.config.js, your docker-compose.yml, and your nginx config. All three have to agree.

1. vite.config.js

Tell Vite to route HMR through the same port as the main server, and to use the correct client host:

import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    host: '0.0.0.0',   // listen on all interfaces inside the container
    port: 5173,
    strictPort: true,
    hmr: {
      // Tell the browser client to connect to the nginx port on localhost.
      // No separate HMR port — we're tunneling through the same connection.
      clientPort: 80,   // or 443 if you're doing SSL termination at nginx
      host: 'localhost',
      protocol: 'ws',
    },
  },
});

The key insight is clientPort. This tells the Vite client JavaScript (running in the browser) which port to open the WebSocket on. Without it, Vite infers the HMR port from the internal container port — which the browser can't reach. Setting clientPort: 80 tells the browser "connect to port 80 for HMR", which is exactly where nginx is listening.

2. docker-compose.yml

Nothing exotic here, but make sure you're not accidentally exposing the Vite port directly alongside nginx. If you expose 5173 and 80 both, the browser might connect directly to Vite and skip nginx entirely — which works until it doesn't, and it masks the real configuration:

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/dev.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app

  app:
    build: .
    expose:
      - "5173"   # internal only, not published to the host
    volumes:
      - .:/var/www/html
      - /var/www/html/node_modules
    command: npx vite

Note expose instead of ports. That makes port 5173 available to other containers on the Docker network (so nginx can proxy to it) but not to the host machine. The browser can only reach Vite through nginx.

3. nginx config

This is where most tutorials fail you. A standard proxy_pass block is not enough. WebSocket upgrades require specific headers:

server {
    listen 80;
    server_name localhost;

    # Main Vite dev server
    location / {
        proxy_pass http://app:5173;
        proxy_http_version 1.1;

        # These two are required for WebSocket
        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 $scheme;

        # Vite HMR needs this — don't let nginx buffer the stream
        proxy_buffering off;
        proxy_read_timeout 86400s;
    }
}

The Upgrade and Connection headers tell nginx to treat this as a WebSocket-capable connection. The proxy_buffering off and the long proxy_read_timeout are critical: nginx will otherwise close the connection after 60 seconds of perceived inactivity, and a WebSocket that's just sitting open waiting for changes looks inactive.

I've had environments where HMR worked for exactly 60 seconds and then silently stopped. That's the default proxy_read_timeout killing it. Once you've seen it, you never forget.

The Gotchas That Will Still Bite You

VITE_HMR_HOST env var vs. config file. Vite respects VITE_* env vars, but HMR settings don't follow the same naming convention as your app env vars. You configure HMR in vite.config.js, not in .env. I've seen people burn an hour trying to set VITE_HMR_PORT=80 and wondering why nothing changes.

SSL/TLS. If nginx terminates SSL and you're serving on port 443, change clientPort: 443 and protocol: 'wss' in your Vite config. The browser will refuse a ws:// connection on a page served over https://.

Vite's @vite/client injection. The HMR client is injected into your HTML as a script tag pointing to /@vite/client. If nginx is stripping or rewriting paths, that request will 404. Check your browser's network tab for that specific request if HMR is broken but the app itself loads.

Volume mounts and node_modules. In my compose file above I mount an anonymous volume at /var/www/html/node_modules. This prevents the host machine's node_modules from overwriting the container's — a common gotcha on macOS/Windows where symlinks and native binaries differ. If you skip this and wonder why vite isn't found in the container, that's why.

Docker network resolution. proxy_pass http://app:5173 works because Docker Compose puts services on a shared network and resolves service names as hostnames. If you're not using Compose and you've rolled your own network, make sure the nginx container can actually reach the Vite container by name. A quick docker exec -it nginx-container wget -qO- http://app:5173 will tell you fast.

When I'd Reach For This Setup

Every time I'm building a Laravel or Node backend that serves a Vite-powered frontend, and I want the dev environment to mirror production topology. If production is nginx in front of your app, dev should be too. The "just run Vite directly" approach is fine for pure frontend projects, but the moment you have backend routes, API proxying, or any nginx config that affects behavior (rewrites, auth headers, cache rules), you want nginx in the loop during development.

I set this up for an e-commerce project last year — Laravel backend, Vue frontend, nginx handling both. Running Vite bare on a different port meant the Laravel CSRF cookie was scoped wrong because the origins didn't match. Getting everything behind a single nginx on port 80 fixed it in one shot.

When would I skip it? If you're building a pure static frontend with no backend, or you're early in prototyping and the overhead isn't worth it. Also if your team isn't comfortable with Docker yet — adding nginx to the mix raises the debugging floor. For a solo project or a quick client prototype, running vite directly is fine. Just don't be surprised when you have to reconfigure everything before you ship.

The Full Picture

Once all three pieces are in place, start your stack with docker compose up, open the browser, and check the console. You should see:

[vite] connected.

That's it. Edit a file. Watch it reload. It should feel instant.

If you still see a WebSocket error, the diagnostic order I use: (1) is /@vite/client returning 200? (2) is the WebSocket request hitting nginx at all — check nginx access logs? (3) is the Upgrade header present in the request? curl -v -H "Upgrade: websocket" -H "Connection: Upgrade" http://localhost/ will tell you if nginx is passing it through.

This isn't complicated once you understand that there are three systems that all need to agree on one port and one hostname. The documentation vacuum exists because Vite docs don't cover nginx, nginx docs don't cover Vite, and Docker docs don't cover either. You're supposed to synthesize all three. Now you don't have to.

Related

Need help shipping something like this? Get in touch.