Hot Reload in a Container

Hot reload in a container means running your development server inside Docker while source code edits on your host machine are noticed immediately by the process in the container. It matters because you keep the same runtime, dependencies, and service wiring as the rest of your Docker workflow without rebuilding the image after every small code change.

The key idea is simple: build an image with the development tools installed, then use a bind mount so the container sees your live project files. The details matter, because a poorly chosen mount can hide installed dependencies, slow file watching, or make the container behave differently on Linux, macOS, and Windows.

Overview: How Hot Reload Works

A normal production-style Docker image is a read-only stack of layers. You build the app into the image, run a container from that image, and the container starts one main process from CMD. If you edit a source file on your host, the running container does not automatically see that change, because the file inside the image layer is still the old copied file. Rebuilding creates a new image layer, but that is too slow for normal application development.

Hot reload changes the workflow. Instead of relying only on files copied into the image, you mount the project directory from the host into the container. A bind mount maps a specific host path, such as the current directory, to a path in the container, such as /app. When you save src/server.js on the host, the file at /app/src/server.js changes inside the container too. A watcher process such as nodemon, vite, flask --debug, or uvicorn --reload notices the change and restarts or refreshes the app.

This is different from production. In production, you usually want an immutable image: the application code is copied into the image at build time, dependencies are installed in the image, and no host source directory is mounted over the app. In development, you trade immutability for fast feedback. You still get Docker’s runtime isolation, networking, and dependency consistency, but the source tree remains editable on the host.

The main gotcha is that a bind mount hides whatever was already at the target path in the image. If your image installed Node dependencies into /app/node_modules, then Compose mounts .:/app, the host project directory replaces the image’s /app view. If the host does not have compatible node_modules, the app may fail. A common fix is a second Docker-managed named volume mounted at /app/node_modules. That lets source files come from the host while dependencies stay inside Docker-managed Linux storage.

File watching is another practical detail. On native Linux Docker Engine, bind mounts usually deliver file events directly. On Docker Desktop for macOS or Windows, Linux containers run inside a lightweight VM, and file events cross a host-to-VM boundary. Many tools still work, but some need polling mode, such as CHOKIDAR_USEPOLLING=true for Node watchers or a framework-specific watch option. Polling uses more CPU, so enable it only when ordinary watch events are unreliable.

Syntax

The general pattern is to run a development command while mounting the project into the container:

docker run --rm -it \
  --name dev-container \
  -p host-port:container-port \
  --mount type=bind,source="$PWD",target=/app \
  --mount type=volume,source=deps-volume,target=/app/node_modules \
  image-name:tag \
  command-to-start-watcher
Part Meaning
--mount type=bind Connects a real host directory to a container path so file edits are visible immediately.
source="$PWD" Uses the current project directory as the host source. Quote it so paths with spaces are handled safely.
target=/app The path where the project appears inside the container.
--mount type=volume Creates or reuses Docker-managed storage, commonly for dependencies that should not come from the host.
-p host:container Publishes the development server port. EXPOSE alone does not publish a port.
command-to-start-watcher Runs the framework’s development process, such as npm run dev.

Compose expresses the same pattern more cleanly for day-to-day development:

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - node_modules:/app/node_modules
    command: npm run dev

volumes:
  node_modules:

Examples

Example 1: A Node.js development image with nodemon

This Dockerfile installs dependencies from a lockfile and starts a watcher. The base image uses a pinned tag, node:20-alpine, instead of node or latest so rebuilds stay predictable.

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=development
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

Output:

Dockerfile.dev created for a Node.js hot-reload container.

The important cache detail is the order. Dependency manifests are copied before the rest of the source, so changing src/server.js does not invalidate the npm ci layer during rebuilds. EXPOSE 3000 documents the container port, but it does not publish it; the run command or Compose file must still include a port mapping.

Example 2: Run the watcher with docker run

After building the development image, start it with a source bind mount and a separate dependency volume:

docker build -f Dockerfile.dev -t node-hot-reload:dev .
docker volume create node-hot-reload-modules
docker run --rm -it --name node-hot-reload -p 3000:3000 --mount type=bind,source="$PWD",target=/app --mount type=volume,source=node-hot-reload-modules,target=/app/node_modules node-hot-reload:dev

Output:

[+] Building 6.1s (9/9) FINISHED
node-hot-reload-modules
[nodemon] starting `node src/server.js`
Server listening on port 3000

The bind mount makes host edits visible at /app. The named volume at /app/node_modules prevents the host bind mount from replacing Linux-installed dependencies with a missing or incompatible host dependency folder. If you save a watched file, nodemon restarts the process without rebuilding the image.

Example 3: Use Compose for the same development loop

Compose is usually the better interface because it records the hot-reload mounts, ports, environment, and command in one file:

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    environment:
      CHOKIDAR_USEPOLLING: "true"
    volumes:
      - .:/app
      - node_modules:/app/node_modules
    command: npm run dev

volumes:
  node_modules:

Run it with the modern Compose V2 command:

docker compose up --build

Output:

[+] Building 4.8s (9/9) FINISHED
[+] Running 2/2
 ✔ Volume "myapp_node_modules"  Created
 ✔ Container myapp-web-1         Created
web-1  | [nodemon] watching path(s): src/**/*
web-1  | Server listening on port 3000

The CHOKIDAR_USEPOLLING variable is useful for many Node-based tools on Docker Desktop when filesystem events are unreliable. On native Linux, you may remove it if normal file watching works. The application is reachable at http://localhost:3000 because Compose publishes 3000:3000.

Example 4: Python Flask hot reload

The same pattern works outside Node. This example installs Flask in an image, bind-mounts source code, and runs the Flask debug server on all container interfaces:

FROM python:3.12-alpine
WORKDIR /app
ENV FLASK_APP=app.py
ENV FLASK_DEBUG=1
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["flask", "run", "--host=0.0.0.0"]

Output:

* Debug mode: on
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000

The container listens on 0.0.0.0 so Docker can forward traffic from the host. Listening only on 127.0.0.1 inside the container would bind to the container’s own loopback interface, not your host’s loopback interface.

How It Works Step By Step

  1. Docker builds the development image from the Dockerfile. The image contains the runtime, development dependencies, default command, and any source copied during build.
  2. When the container is created, Docker prepares the image’s read-only layers and the thin writable container layer.
  3. Docker attaches the bind mount at /app. That mount hides the image’s existing /app files for this container and shows the host project directory instead.
  4. Docker attaches the named volume at /app/node_modules. Because it is mounted inside /app, it gives dependencies their own Docker-managed storage.
  5. The daemon applies the port mapping from -p or Compose ports:. Without this, the server may be running but unreachable from the host.
  6. The watcher command starts as the container’s main process. It scans or subscribes to files under the mounted source directory.
  7. When you edit a file on the host, the bind-mounted file changes in the container. The watcher restarts the app process, reloads modules, or tells the browser dev server to refresh.
  8. Stopping the container removes the container writable layer if --rm was used. The named dependency volume remains until removed with docker volume rm.

Common Mistakes

Rebuilding for every source edit

docker build -t node-hot-reload:dev .
docker run --rm -p 3000:3000 node-hot-reload:dev

This runs the files that were copied into the image during the build. Editing the host source tree will not change the already-running container. Use a bind mount and a watcher command for development.

docker run --rm -it -p 3000:3000 --mount type=bind,source="$PWD",target=/app node-hot-reload:dev npm run dev

Letting the bind mount hide dependencies

services:
  web:
    build: .
    volumes:
      - .:/app
    command: npm run dev

If the image installed dependencies into /app/node_modules, mounting .:/app can hide that directory. The fix is to add a named volume for the dependency path.

services:
  web:
    build: .
    volumes:
      - .:/app
      - node_modules:/app/node_modules
    command: npm run dev

volumes:
  node_modules:

Forgetting to publish the port

docker run --rm --mount type=bind,source="$PWD",target=/app node-hot-reload:dev

The app may be running inside the container, and the Dockerfile may contain EXPOSE 3000, but nothing is published to the host. Add -p 3000:3000 or Compose ports:.

Using development hot reload in production

Watchers are designed for developer feedback, not production reliability. They often run with development dependencies, verbose logging, polling, debug features, and looser defaults. Production should use a normal image command such as node dist/server.js or a real application server, with code copied into the image and no source bind mount.

Best Practices

  • Use bind mounts for editable source code in local development, not for production application code.
  • Keep dependency directories such as node_modules in a named volume when a broad source bind mount would otherwise hide them.
  • Use pinned development base images such as node:20-alpine or python:3.12-alpine; avoid latest.
  • Separate development Dockerfiles or Compose overrides from production configuration when the commands and dependencies differ.
  • Publish ports with -p or Compose ports:; remember EXPOSE is metadata only.
  • On Docker Desktop, enable polling only when file events are unreliable, because polling can use more CPU.
  • Bind mount only the directories the watcher needs. Avoid mounting secrets, build output, or your whole home directory.
  • Keep a useful .dockerignore even for development images so rebuilds do not send .git, logs, local caches, or environment files as build context.
  • Do not bake API keys or passwords into a development image. Use obvious placeholders in examples and runtime environment variables or secret files for real projects.

Practice Exercises

  1. Create a Compose file for a Node app that uses Dockerfile.dev, publishes 3000:3000, bind-mounts the project to /app, and keeps /app/node_modules in a named volume. Expected end state: editing a route restarts or refreshes the server.
  2. Take a Flask app and make it reachable from the host in a hot-reload container. Hint: the server must listen on 0.0.0.0, and Docker must publish the container port.
  3. Temporarily remove the node_modules named volume from a Node Compose setup and observe the failure. Then restore the volume and explain why the bind mount changed what the container could see.

Summary

  • Hot reload in Docker combines a live source bind mount with a watcher command running inside the container.
  • A bind mount makes host file edits visible in the container immediately, but it can also hide files that were built into the image.
  • Use named volumes for dependency directories that should remain Docker-managed during development.
  • Docker Desktop may require polling-based file watching for some frameworks, while native Linux often works with normal filesystem events.
  • EXPOSE documents a port only; use -p or Compose ports: to reach the development server.
  • Hot reload is a development workflow. Production images should be immutable, copied at build time, and run without source bind mounts or watcher processes.