Skip to main content

🔥 Hot Reloading in Containers

Bind-mounting your source into a container delivers fresh code — but the process inside still has to notice the change and restart. This lesson closes that loop: nodemon for a Node API, Vite --host for a frontend, and the one-line polling fix that rescues file-watching when a container just won't see your edits.

Week 11 · Day 4 (Thursday: Docker for Development) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Describe the two halves of the hot-reload loop: file delivery (bind mount) and change detection (watcher)
  • Configure nodemon to restart a Node.js server on change inside a container
  • Run a Vite dev server with --host 0.0.0.0 so the browser can reach it and HMR works
  • Diagnose why native file-system events fail on some mounts and fix it with CHOKIDAR_USEPOLLING
  • Tune polling intervals and watch scope to keep CPU usage sane
  • Wire up full-stack hot reload for a frontend + API + database together

Estimated Time: 65 minutes

Practice: Make a Vite frontend and an Express API both hot-reload inside Compose, then prove it survives a broken watcher.

In This Lesson

The Instant-Feedback Loop

Hot reloading is the practice of automatically applying code changes to a running application without a manual stop-rebuild-start cycle. For a backend that usually means restarting the process; for a frontend it can mean Hot Module Replacement (HMR) — swapping a single module in the live page while keeping your scroll position and component state.

🎸 The band-rehearsal analogy

Without hot reload, development is like a band stopping dead after every wrong note, re-tuning, and restarting the song from the top. With hot reload the musicians adjust while the music keeps playing — the guitarist fixes a chord mid-song and hears the result instantly, never losing the groove. That preserved momentum is exactly why it makes you faster.

graph LR A["Save a file"] --> B["Bind mount delivers it
into the container"] B --> C["Watcher detects the change"] C --> D{"Backend or frontend?"} D -->|Backend| E["nodemon restarts the process"] D -->|Frontend| F["Vite swaps the module via HMR"] E --> G["Refresh and test"] F --> G G --> A

The payoff is real: instead of waiting seconds (or on a big app, tens of seconds) for a rebuild, you see your change almost immediately. Over a day of dozens or hundreds of edits, that compounds into a very different working experience.

How Reload Works in a Container

Hot reload inside Docker is really two independent problems stitched together:

  1. Getting the changed file into the container. The bind mount from the previous lesson (./:/app) already does this — edits on your host appear instantly in the container's filesystem.
  2. Getting the process to notice. A file watcher inside the container detects the change and triggers a restart (nodemon) or a module swap (Vite/webpack HMR).

Step 1 is solved. This whole lesson is really about step 2 — and about the ways step 2 quietly breaks in containers.

💡 Two ways to watch files

Native FS events (inotify on Linux, FSEvents on macOS) are efficient — the OS tells the watcher the instant a file changes. Polling re-scans files on a timer. Polling is heavier on CPU but works everywhere, which matters because native events frequently don't cross the container/host boundary on macOS and Windows.

Remember the volume recipe that makes any of this possible — bind-mount the source, anonymous-volume the modules:

volumes:
  - ./:/app              # deliver source edits live
  - /app/node_modules    # keep the container's installed modules intact

Backend Reload with nodemon

nodemon wraps your Node process and restarts it whenever a watched file changes. It's the standard choice for Express and other Node backends. Install it as a dev dependency and run it as your dev command.

# Dockerfile.dev — backend
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci                 # nodemon is in devDependencies
COPY . .
EXPOSE 4000
CMD ["npm", "run", "dev"]
# package.json (scripts)
{
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  }
}

For fine-grained control, add a nodemon.json. Watching only src/ and ignoring tests keeps restarts snappy:

// nodemon.json
{
  "watch": ["src"],
  "ext": "js,json",
  "ignore": ["src/**/*.test.js"],
  "delay": 300
}

⚠️ nodemon says "clean exit" but never restarts

If nodemon runs but ignores your edits, its watcher isn't receiving file-system events through the mount — the classic macOS/Windows symptom. The fix is polling mode (the --legacy-watch flag or the legacyWatch option), covered in the Polling Fix section below.

Terminal output

api-1  | [nodemon] 3.1.0
api-1  | [nodemon] watching path(s): src/**/*
api-1  | [nodemon] starting `node src/index.js`
api-1  | Server on http://0.0.0.0:4000
api-1  | [nodemon] restarting due to changes...
api-1  | Server on http://0.0.0.0:4000

💡 Node's built-in watcher

Modern Node (18.11+) ships node --watch src/index.js, which covers many cases without an extra dependency. nodemon still wins for its config file, ignore patterns, and — critically for containers — its legacyWatch polling mode.

Frontend HMR with Vite

Frontends get something better than a restart: Hot Module Replacement swaps just the changed module into the live page, preserving state. Vite (the default for modern React/Vue projects) does this out of the box — but two container-specific settings are non-negotiable.

1. Bind the server to 0.0.0.0

By default a dev server listens on localhost — meaning inside the container only. Your browser on the host would get "connection refused." Binding to 0.0.0.0 makes it accept connections from outside the container so your published port actually works.

// package.json
{
  "scripts": {
    "dev": "vite --host 0.0.0.0"
  }
}

2. Let the HMR websocket connect back

HMR pushes updates over a websocket. In a container the client sometimes can't figure out the right address to connect back to, and you'll see HMR "connecting..." forever. Pin it in vite.config.js:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    host: '0.0.0.0',
    port: 5173,
    watch: {
      // Poll when native FS events don't cross the mount (see next section)
      usePolling: true,
      interval: 100,
    },
    hmr: {
      // The host/port the BROWSER uses to reach the HMR socket
      clientPort: 5173,
    },
  },
});
# compose.override.yaml (frontend service)
services:
  web:
    build:
      dockerfile: Dockerfile.dev
    ports:
      - "5173:5173"
    volumes:
      - ./:/app
      - /app/node_modules
    command: npm run dev

✅ Why HMR beats a full refresh

A full page reload throws away everything — form input, which tab was open, how far you'd scrolled. HMR replaces one module and keeps the rest of the app alive, so tweaking a component's styles doesn't reset the state you were testing against. It's the difference between editing and re-navigating.

The Polling Fix

Here's the single most common Docker-dev frustration: your bind mount is correct, the file is updated inside the container, but the watcher never fires. The cause is almost always that native file-system events don't propagate across the virtualized mount on macOS and Windows (and occasionally on certain network filesystems).

The universal fix is to switch the watcher from native events to polling — periodically re-scanning files for changes. Many JS tools watch via the chokidar library, which reads an environment variable to force polling everywhere at once:

# compose.override.yaml
services:
  web:
    environment:
      - CHOKIDAR_USEPOLLING=true   # force chokidar-based watchers to poll
      - CHOKIDAR_INTERVAL=100      # optional: poll every 100ms

Per-tool equivalents, if you'd rather be explicit:

ToolForce polling
nodemonnodemon --legacy-watch src/index.js
Viteserver.watch.usePolling: true
webpack dev serverwatchOptions: { poll: 1000 }
chokidar-based toolsCHOKIDAR_USEPOLLING=true
sequenceDiagram participant You participant Host as Host FS participant Mount as Bind mount participant Watch as Watcher You->>Host: save src/app.js Host->>Mount: file updated in container Note over Watch: native events did not cross the boundary Watch->>Mount: poll on a timer and re-scan files Mount-->>Watch: detected a newer timestamp Watch->>Watch: trigger restart or module swap

⚠️ Polling has a CPU cost

Polling re-scans watched files on every interval, so a wide watch scope over thousands of files pegs a CPU core. Enable polling only when native events fail, keep the interval reasonable (100–300ms), and narrow the watch scope — ignore node_modules, dist, and build output. On native Linux you usually don't need polling at all.

Full-Stack Hot Reload

Putting it together: a Vite frontend, an Express API with nodemon, and a database — all hot-reloading, all started with one command. The frontend swaps modules on change; the API restarts on change; the database just runs.

# compose.yaml (base)
services:
  web:
    build: ./web
    ports:
      - "5173:5173"
  api:
    build: ./api
    ports:
      - "4000:4000"
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

# compose.override.yaml (dev conveniences, auto-merged)
services:
  web:
    volumes:
      - ./web:/app
      - /app/node_modules
    environment:
      - CHOKIDAR_USEPOLLING=true
    command: npm run dev
  api:
    volumes:
      - ./api:/app
      - /app/node_modules
    command: npm run dev
graph TD subgraph Frontend["web container"] W1["React source"] --> W2["Vite dev server"] W2 --> W3["HMR to the browser"] end subgraph Backend["api container"] A1["Express source"] --> A2["nodemon"] A2 --> A3["restarted API"] end subgraph Data["db container"] DB[("Postgres")] end W3 -->|API calls| A3 A3 -->|queries| DB

Edit a React component and the browser updates in place. Edit a route handler and nodemon restarts the API in under a second. Neither touches the database, whose data persists in its volume.

Practice & Quiz

🏋️ Exercise 1: Make nodemon see your edits

Goal: An Express API in a container starts fine and serves requests, but editing src/index.js never triggers a restart. The mount is confirmed correct (the file is updated inside the container). Fix the reload without changing the mount.

💡 Hint

If files reach the container but the watcher stays silent, native FS events aren't crossing the mount. Force nodemon into polling mode.

✅ Solution
// package.json
{ "scripts": { "dev": "nodemon --legacy-watch src/index.js" } }

Or, if other chokidar-based tools are involved too, set it once at the environment level in the override:

services:
  api:
    environment:
      - CHOKIDAR_USEPOLLING=true

🏋️ Exercise 2: The frontend loads but HMR won't connect

Goal: A Vite React app runs in a container and the page loads at http://localhost:5173, but changes never appear and the console shows the HMR websocket stuck "connecting". Name the two settings you'd check.

✅ Solution
  1. Server host: run vite --host 0.0.0.0 (or server.host: '0.0.0.0') so the container accepts outside connections at all.
  2. HMR client port: set server.hmr.clientPort to the published port so the browser can reach the websocket back through the port mapping. And, if edits aren't detected, add server.watch.usePolling: true.

🎯 Quick Quiz

Question 1: Your file reaches the container but the watcher never fires on macOS. The best first fix is:

Question 2: Why must a Vite dev server in a container use --host 0.0.0.0?

Question 3: What's the main downside of leaving polling on for everything, always?

Best Practices & Pitfalls

✅ Do

  • Bind-mount source and anonymous-volume node_modules — the foundation reload builds on
  • Bind dev servers to 0.0.0.0 so the host can reach them
  • Enable polling only when native events fail, and narrow the watch scope
  • Set hmr.clientPort to the published port when the HMR socket won't connect
  • Ignore node_modules, dist, and test files in your watcher config

❌ Don't

  • Leave polling on globally on native Linux — you don't need it and it wastes CPU
  • Watch your entire tree with a tiny poll interval over thousands of files
  • Forget to publish the dev server's port in Compose (ports:)
  • Ship these dev settings to production — hot reload belongs only in the override
  • Rely on native FS events across a macOS/Windows mount and hope they work

💡 Prove your reload actually works

Trigger a change from inside the container to isolate the watcher from the mount: docker compose exec api touch src/index.js. If that restarts the process but editing on your host doesn't, the mount is the problem; if neither works, the watcher is.

Summary

🎉 Key Takeaways

  • Hot reload is two steps: the bind mount delivers the file, a watcher detects it
  • Use nodemon to restart a Node backend; use Vite HMR to swap frontend modules while keeping state
  • A container dev server must bind to 0.0.0.0 or the host can't reach it
  • When files reach the container but the watcher stays silent, force polling (CHOKIDAR_USEPOLLING=true, --legacy-watch, usePolling)
  • Polling costs CPU — enable it selectively and narrow the watch scope

📚 Additional Resources

🚀 What's Next?

Fast feedback is great until something breaks and the container swallows the error. Next up: Debugging Containerized Apps — reading logs with logs -f, dropping into a shell with exec, and attaching a real debugger through the Node inspector on --inspect=0.0.0.0:9229.

🎉 Loop closed!

Your containerized app now updates the instant you hit save — front to back.