How to Set Up Docker for a Full Stack JavaScript Project: Compose, Hot Reload and Volumes

Most Docker tutorials stop at “look, the container starts”. Then you hit real life: you save a file and nothing reloads, your local node_modules overwrites the one inside the image, a rebuild takes four minutes, and your production image ships with Vite and 800 MB of dev dependencies.

This guide is the fix. We are going to build a complete docker compose full stack javascript setup with three services: a Node.js/Express API, a React (Vite) frontend and a PostgreSQL database, all orchestrated by a single Compose file. Hot reloading will work in both the API and the frontend, and we will finish with a clean separation between development and production Dockerfiles.

What we are building

Service Stack Dev port Hot reload mechanism
web React 19 + Vite 5173 Vite HMR over a bind mount
api Node.js 24 + Express 4000 node --watch or nodemon
db PostgreSQL 17 5432 Named volume for persistence

Prerequisites

  • Docker Desktop or Docker Engine with Compose v2 (run docker compose version, you want 2.24 or newer so that watch is available).
  • Basic familiarity with npm scripts and Express.
  • No local Node.js required. That is the whole point: the toolchain lives in the containers.
docker containers code

1. Project structure

Keep each app self contained with its own Dockerfile. A flat, predictable tree makes build contexts small and caching effective.

fullstack-js/
├── compose.yaml
├── compose.prod.yaml
├── .env
├── api/
│   ├── Dockerfile.dev
│   ├── Dockerfile
│   ├── .dockerignore
│   ├── package.json
│   └── src/
│       └── server.js
└── web/
    ├── Dockerfile.dev
    ├── Dockerfile
    ├── .dockerignore
    ├── nginx.conf
    ├── package.json
    └── src/

Note: the modern file name is compose.yaml. docker-compose.yml still works, and the version: key at the top is obsolete, you can delete it.

2. The .dockerignore file (do this first)

Ninety percent of “my Docker build is slow” complaints come from a missing .dockerignore. Without it, Docker sends your entire local node_modules and .git folder to the daemon on every single build.

Create the same file in api/ and web/:

node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
dist
build
coverage
Dockerfile*
.DS_Store
docker containers code

3. The development Dockerfiles

api/Dockerfile.dev

FROM node:24-alpine

WORKDIR /usr/src/app

# Copy manifests first so this layer is cached
# until dependencies actually change
COPY package.json package-lock.json ./
RUN npm ci

# Source is bind mounted at runtime, this COPY is only
# a fallback so the image is usable on its own
COPY . .

EXPOSE 4000
CMD ["npm", "run", "dev"]

web/Dockerfile.dev

FROM node:24-alpine

WORKDIR /usr/src/app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

The --host 0.0.0.0 flag is not optional. By default Vite binds to localhost inside the container, which is unreachable from your host machine. Same idea applies if you use Next.js, Astro or webpack dev server. Originally covered on https://forums.docker.com.

The package.json scripts that make reload work

// api/package.json
{
  "type": "module",
  "scripts": {
    "dev": "node --watch src/server.js",
    "start": "node src/server.js"
  }
}

Node 24 ships --watch natively, so nodemon is no longer mandatory. If your file watching does not trigger inside the container (this happens on some Windows and older macOS setups), fall back to nodemon --legacy-watch.

4. The single compose.yaml that runs everything

name: fullstack-js

services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10

  api:
    build:
      context: ./api
      dockerfile: Dockerfile.dev
    restart: unless-stopped
    environment:
      NODE_ENV: development
      PORT: 4000
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    ports:
      - "4000:4000"
    volumes:
      - ./api:/usr/src/app
      - /usr/src/app/node_modules
    depends_on:
      db:
        condition: service_healthy

  web:
    build:
      context: ./web
      dockerfile: Dockerfile.dev
    restart: unless-stopped
    environment:
      VITE_API_URL: http://localhost:4000
    ports:
      - "5173:5173"
    volumes:
      - ./web:/usr/src/app
      - /usr/src/app/node_modules
    depends_on:
      - api

volumes:
  db_data:

And the matching .env at the repository root:

POSTGRES_USER=app
POSTGRES_PASSWORD=change_me_locally
POSTGRES_DB=app_db

Start the whole stack with one command:

docker compose up --build

5. The node_modules problem, explained properly

This is the single biggest friction point in any docker compose full stack javascript setup, so let us slow down here.

When you write ./api:/usr/src/app, Docker mounts your host folder over the container folder. Everything that npm ci installed during the build is now hidden, replaced by whatever is on your host. Two failure modes follow:

  • You have no local node_modules: the container starts, then crashes with Cannot find module 'express'.
  • You do have a local node_modules: it was installed on macOS or Windows, and native modules such as bcrypt, sharp or esbuild were compiled for the wrong platform. You get errors like invalid ELF header.

The fix: an anonymous volume on top of the bind mount

volumes:
  - ./api:/usr/src/app          # your source, live edited
  - /usr/src/app/node_modules   # container-only, shields the install

The second line has no host path. Docker creates an anonymous volume mounted at a deeper path, and deeper mounts win. Result: your source code is shared, but node_modules stays exactly as the image built it.

Comparison of the available strategies

Strategy How it works Pros Cons
Anonymous volume - /usr/src/app/node_modules One line, no host pollution, correct binaries Editor cannot see the modules for IntelliSense
Named volume - api_modules:/usr/src/app/node_modules Survives docker compose down, faster restarts Goes stale, needs down -v after dependency changes
Modules outside the app dir Install in /deps, set NODE_PATH No mount collision at all Non standard, breaks some tooling
Compose develop.watch with sync Compose copies changed files in, no bind mount Fastest on macOS and Windows, precise ignore rules Requires a foreground docker compose watch

Important habit

When you add a dependency, do it inside the container so the lockfile and the installed binaries stay consistent:

docker compose exec api npm install zod
docker compose up -d --build api
docker containers code

6. When hot reload still refuses to fire

Bind mounts on macOS and Windows go through a virtualization layer that does not always propagate inotify events. Your files change, the container never hears about it. Two ways out. innominds.com walks through the specifics.

Option A: enable polling

// web/vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    host: true,
    port: 5173,
    watch: {
      usePolling: true,
      interval: 300
    }
  }
})

Polling always works but costs CPU. Keep the interval at 300 ms or higher and only enable it in development.

Option B: use Compose watch (the modern approach)

Compose v2 has a develop.watch block that syncs files without a bind mount and can rebuild automatically when the lockfile changes. Add it to each service:

  api:
    build:
      context: ./api
      dockerfile: Dockerfile.dev
    develop:
      watch:
        - action: sync
          path: ./api/src
          target: /usr/src/app/src
          ignore:
            - node_modules/
        - action: rebuild
          path: ./api/package.json

Then run:

docker compose watch

Now editing a route file syncs in milliseconds, and touching package.json triggers a full rebuild of that service only. This is noticeably faster than bind mounts on macOS and Windows.

7. Killing slow rebuilds

If docker compose up --build reinstalls every dependency each time, your layer order is wrong. Apply these five rules:

  1. Copy package.json and the lockfile before the source. Docker invalidates a layer and everything below it as soon as an input changes. Source changes constantly, dependencies do not.
  2. Use npm ci, never npm install, in images. It is deterministic and considerably faster on a clean cache.
  3. Add a cache mount so the npm cache survives between builds:
    RUN --mount=type=cache,target=/root/.npm \
        npm ci
  4. Rebuild one service, not the whole stack: docker compose build api then docker compose up -d api.
  5. Pin base images to a minor tag like node:24-alpine rather than node:latest, so you are not silently pulling a new base and busting every layer.
docker containers code

8. Separating development from production

A dev image contains devDependencies, source maps, a file watcher and a bind mount. None of that belongs in production. Use multi-stage builds and a second Compose file.

api/Dockerfile (production)

FROM node:24-alpine AS deps
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

FROM node:24-alpine AS runner
ENV NODE_ENV=production
WORKDIR /usr/src/app
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
USER node
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://localhost:4000/health || exit 1
CMD ["node", "src/server.js"]

web/Dockerfile (production)

FROM node:24-alpine AS build
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build

FROM nginx:1.27-alpine AS runner
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

The React app becomes static files served by nginx, which also proxies /api to the Node service so the browser never deals with CORS:

# web/nginx.conf
server {
  listen 80;
  root /usr/share/nginx/html;
  index index.html;

  location /api/ {
    proxy_pass http://api:4000/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }

  location / {
    try_files $uri $uri/ /index.html;
  }
}

compose.prod.yaml

services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    environment:
      NODE_ENV: production
    volumes: []
    ports: []

  web:
    build:
      context: ./web
      dockerfile: Dockerfile
      args:
        VITE_API_URL: /api
    volumes: []
    ports:
      - "80:80"

  db:
    ports: []

Then deploy by layering the files:

docker compose -f compose.yaml -f compose.prod.yaml up -d --build

Dev versus prod at a glance

Aspect Development Production
Source code Bind mounted or synced Baked into the image
Dependencies All, including dev --omit=dev only
Frontend Vite dev server, HMR Static build served by nginx
User root (convenient) non-root node user
Postgres port Published to host Internal network only
Secrets Local .env Docker secrets or the platform vault

9. Connecting the pieces correctly

Service names are hostnames

Inside the Compose network, the API reaches Postgres at db:5432, not localhost:5432. Each container has its own loopback interface. This one confusion causes more ECONNREFUSED errors than anything else.

The browser is not in the network

Your React code runs in the user browser, on the host. So the frontend must call http://localhost:4000 in development, even though the API container is named api. In production, the nginx proxy makes it a relative /api path and the problem disappears.

Wait for the database, do not just depend on it

depends_on alone only waits for the container to start, not for Postgres to accept connections. That is why we added condition: service_healthy together with a pg_isready healthcheck. Add retry logic in your Node client as well, because containers restart.

10. Troubleshooting table

Symptom Likely cause Fix
Cannot find module 'express' Bind mount hid the installed modules Add the anonymous volume line
invalid ELF header / esbuild error Host modules compiled for another OS Delete local node_modules, rebuild
Vite loads but site is unreachable Server bound to 127.0.0.1 Add --host 0.0.0.0 or server.host: true
Saving a file changes nothing inotify events not propagated Enable polling or switch to compose watch
ECONNREFUSED 127.0.0.1:5432 API using localhost for the DB Use the service name db as host
Postgres ignores new credentials Volume already initialized docker compose down -v then up again
Port is already allocated A local Postgres or Node is running Remap, for example "5433:5432"
docker containers code

11. Command cheat sheet

  • docker compose up --build : build and start everything in the foreground
  • docker compose watch : start with automatic file sync and selective rebuilds
  • docker compose logs -f api : follow one service log
  • docker compose exec api sh : shell into the running API container
  • docker compose exec db psql -U app -d app_db : open a psql session
  • docker compose down : stop and remove containers, keep volumes
  • docker compose down -v : also wipe the database volume (destructive)
  • docker compose config : print the merged, resolved configuration
  • docker system prune -af : reclaim disk space from unused images and caches

12. Sensible next steps

  1. Seed data: drop SQL files into db/init/. Postgres runs them alphabetically on first initialization only.
  2. Migrations: add a one-shot service running Prisma, Drizzle or Knex with depends_on: db and a restart: no policy.
  3. Tests in CI: reuse the same Compose file with a compose.ci.yaml override and run docker compose run --rm api npm test.
  4. Image size: compare with docker images. A well built API image should land under 200 MB, the nginx frontend around 60 MB.
  5. Secrets: move from .env to Docker secrets or your host platform secret manager before going live.

FAQ

Do I need Docker for a full stack JavaScript project?

Not strictly, but it removes the “works on my machine” class of bugs entirely. Every developer gets the same Node version, the same Postgres version and the same environment variables. Onboarding drops from a page of instructions to one command.

Should I put node_modules in a volume or rebuild the image?

Use an anonymous or named volume in development so the container keeps the Linux compiled binaries. Rebuild the image whenever package.json or the lockfile changes. In production, never mount anything, bake the dependencies into the image.

Why is my hot reload working for React but not for Node?

Vite has its own websocket based HMR, while Node needs an explicit watcher. Make sure your dev script uses node --watch or nodemon, and that the container command actually runs that script and not npm start.

Is one Dockerfile with build targets better than two files?

Both are valid. A single multi-stage Dockerfile with target: development and target: production keeps everything in one place, while separate files stay easier to read. Pick one convention and apply it to every service in the repository.

How do I persist Postgres data between restarts?

Mount a named volume at /var/lib/postgresql/data, exactly as in the Compose file above. Data survives docker compose down and is only removed when you pass the -v flag.

Can I use pnpm, yarn or Bun instead of npm?

Yes. The layer caching principle is identical: copy the manifest and lockfile, install with the frozen lockfile flag (pnpm install --frozen-lockfile, yarn install --immutable, bun install --frozen-lockfile), then copy the source. For pnpm, also mount the store directory as a cache mount.

How do I add a reverse proxy in front of everything?

Add an nginx or Traefik service, publish port 80 and 443 on it, and stop publishing ports on api and web. Traefik can also handle automatic TLS certificates, which is the usual production choice for a Compose based deployment.

Wrapping up

A working docker compose full stack javascript environment comes down to five decisions: bind mount your source, shield node_modules with a separate volume, bind dev servers to 0.0.0.0, order Dockerfile layers so dependencies cache, and keep production images completely separate from development ones. Get those right and docker compose up becomes the only setup instruction your project needs.