🔥 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.0so 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.
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:
- 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. - 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:
| Tool | Force polling |
|---|---|
| nodemon | nodemon --legacy-watch src/index.js |
| Vite | server.watch.usePolling: true |
| webpack dev server | watchOptions: { poll: 1000 } |
| chokidar-based tools | CHOKIDAR_USEPOLLING=true |
⚠️ 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
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
- Server host: run
vite --host 0.0.0.0(orserver.host: '0.0.0.0') so the container accepts outside connections at all. - HMR client port: set
server.hmr.clientPortto the published port so the browser can reach the websocket back through the port mapping. And, if edits aren't detected, addserver.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.0so the host can reach them - Enable polling only when native events fail, and narrow the watch scope
- Set
hmr.clientPortto 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.0or 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
- Docker Docs — Bind mounts
- Vite — server.host, server.watch & HMR options
- nodemon — configuration & legacy-watch
- chokidar — the file-watching library behind most JS tools
🚀 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.