Docker Setup for Node.js Development: Dev vs Production

Docker Setup for Node.js Development: Dev vs Production

A practical Docker Compose setup for Node.js and Next.js apps — separate dev and prod configs, hot reload that actually works, multi-stage production builds, and the gotchas you only find by actually running it.

Sakib Ullah Sakib Ullah 11 mins

A practical Docker Compose setup for Node.js apps — one config for fast local development, one for production — plus the specific gotchas that only show up once you actually run them.

Every Node.js project ends up needing two very different things from Docker: something fast to iterate against locally, and something minimal and reproducible to ship. Reach for a single docker-compose.yml to cover both and you'll eventually hit the same wall — bind-mounted source leaking into a "production" image, or a dev server baked into a build meant to run next start/node server.js.

The fix is boring but effective: two separate compose files, one multi-stage Dockerfile, and a handful of details — volumes, healthchecks, build-time env vars, file permissions — that matter far more in practice than they look like they should on paper.

flowchart LR
    subgraph DEV["Dev — docker-compose.dev.yml"]
        HS["Host source<br/>(bind mount)"] --> DC["Container<br/>pnpm dev"]
        NM[("named volume:<br/>node_modules")] -.overlays.-> DC
    end
    subgraph PROD["Prod — docker-compose.prod.yml"]
        SRC["Source"] --> BUILD["Multi-stage build<br/>deps → builder → runner"]
        BUILD --> IMG[("Built image<br/>.next/standalone + static")]
        IMG --> PC["Container<br/>node server.js"]
    end

This guide walks through the dev compose setup end to end, why hot reload does (or doesn't) work, the production multi-stage Dockerfile, a build-time environment variable trap that's easy to hit with fail-fast config validation, volumes and permissions, networking, and a verification checklist to actually run before shipping any of it.

Contents


The goal

Two different Docker setups, for two different jobs — don't try to make one file do both:

  • Dev: fast hot reload, source lives on the host (so your editor/LSP/git all work normally), containers just provide runtime + services (DB, etc.) the app needs.
  • Prod: the app is built into the image. No bind mounts, no source on the host inside the container, no dev server.

Trying to reuse one docker-compose.yml for both is the most common mistake — it either bind-mounts source into a "production" container (defeats the point of a built image) or bakes a dev server into a build meant to run next start/node server.js.

Convention: docker-compose.dev.yml and docker-compose.prod.yml, named explicitly. Docker Compose only auto-discovers a file literally named docker-compose.yml, so once you split them, every command needs -f docker-compose.dev.yml (wrap it in an npm/pnpm script so nobody has to remember that).

{
  "scripts": {
    "dev:docker": "docker compose -f docker-compose.dev.yml up",
    "db:up": "docker compose -f docker-compose.dev.yml up -d postgres",
    "db:down": "docker compose -f docker-compose.dev.yml down",
    "db:logs": "docker compose -f docker-compose.dev.yml logs -f postgres"
  }
}

The dev compose file (hot reload that actually works)

Bind-mount the source, but not node_modules

services:
  app:
    image: node:22.17.0-alpine
    working_dir: /app
    command: sh -c "corepack enable && corepack prepare pnpm@10.15.1 --activate && pnpm install --frozen-lockfile && pnpm dev"
    volumes:
      - .:/app
      - node_modules:/app/node_modules
      - next_cache:/app/.next # or whatever your framework's build cache dir is

The node_modules and build-cache volumes are layered on top of the bind mount, not instead of it — Docker mounts them in the order listed, so the named volume wins for that subpath while everything else still comes from the host.

This matters for a concrete reason: if node_modules isn't its own volume, the host's node_modules (built for the host's OS/arch) gets bind-mounted into the container, shadowing whatever pnpm install would have put there. Any package with native bindings — sharp, better-sqlite3, bcrypt, etc. — built on macOS/Windows will not load inside the Linux container. Give node_modules its own volume and let the container's own pnpm install populate it.

Why file-watching works without extra config (on Linux)

A bind mount on native Linux goes straight through the kernel — the container's filesystem watcher (inotify, which Vite/Next/Turbopack/nodemon all use under the hood) sees host file changes immediately, no polling needed.

This is not true on Docker Desktop for Mac/Windows, where the bind mount crosses a VM boundary (osxfs / gRPC-FUSE / WSL2's 9p). There, file-watchers frequently miss change events and need to fall back to polling (CHOKIDAR_USEPOLLING=true, WATCHPACK_POLLING=true, or your framework's equivalent). If hot reload "sometimes" works or is slow, that's the first thing to check — but on native Linux you shouldn't need it.

Verifying it actually works (don't just assume):

docker compose -f docker-compose.dev.yml up -d
# edit a file on the host
docker logs <container> --tail 20   # look for an unprompted recompile line
docker exec <container> tail -1 <file>  # confirm the container sees the edit

Healthchecks and depends_on: condition: service_healthy

Plain depends_on: [postgres] only waits for the container to start, not for Postgres to actually be accepting connections — the app container will race it and fail its first DB query. Use a healthcheck and gate on it:

services:
  postgres:
    image: postgres:17
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
      interval: 5s
      timeout: 5s
      retries: 10

  app:
    depends_on:
      postgres:
        condition: service_healthy

The production Dockerfile (multi-stage build)

Three stages: install deps, build, run. Only the last stage ships.

FROM node:22.17.0-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm && pnpm run build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000

HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=10 \
  CMD wget --spider -q http://127.0.0.1:3000 || exit 1

CMD HOSTNAME="0.0.0.0" node server.js

Notes that came from actually building this, not just theory:

  • Non-root user. Create a dedicated system user/group and USER into it before CMD. Anything the container needs to write at runtime (see volumes below) has to be chown'd to that user in the image, before the volume mounts over it.
  • Framework's "standalone" output mode is not optional if your Dockerfile copies .next/standalone. For Next.js this means output: 'standalone' in next.config.ts. Skip it and the COPY step fails outright — the directory won't exist.
  • Don't copy template boilerplate blindly. The stock Next.js Docker example includes COPY --from=builder /app/public ./public. A headless backend with no static frontend has no public/ dir — that line fails every build until removed. Always confirm every COPY source path actually exists in this project.
  • HEALTHCHECK in the Dockerfile itself (not just in compose) means it travels with the image regardless of how it's orchestrated — useful if a platform like Dokploy/Coolify reads container health directly.

The env-var-at-build-time trap

If your app validates required environment variables at module import time (fail-fast config patterns, e.g. a getEnv() helper that throws on a missing var), that validation runs during next build too — because the build process imports your route/config modules to trace them. docker build has no access to your .env (env_file: is a compose runtime directive; it does nothing for docker build), so the build crashes with "missing required environment variable," even though everything is fine at runtime.

Fix: set dummy build-time-only values as ENV in the builder stage, just to satisfy the validation:

FROM base AS builder
...
ENV PAYLOAD_SECRET=docker-build-placeholder-unused-at-runtime \
  DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
RUN pnpm run build

This is safe only if your app re-imports/re-evaluates its config fresh when the process actually starts (true for basically any Node server — modules aren't shared across separate node invocations). The build-time values never reach the running container; the runner stage starts a brand new process against the real runtime env. Verify this assumption by actually booting the built image with real env vars and confirming it behaves correctly — don't just assume it's fine.

If your validation also rejects specific known placeholder strings (e.g. refusing to boot in production with a password literally equal to "postgres") — good, that's a legitimate safety net catching a real .env.example-copied-verbatim mistake. Just make sure your dummy build-time value doesn't accidentally match one of the blocked placeholders, or the build fails for a different reason.

.dockerignore is not optional

Without one, docker build's context (and potentially cached layers) can include .env, .git, local node_modules, and any local upload/data directories. At minimum:

.env
.env*.local
.git
/node_modules
/.next/
/media          # or wherever local dev writes uploads

Volumes: what needs to persist, what doesn't

Needs a volumeWhy
Database data dirObviously — container restarts shouldn't lose data
Uploaded user files (if stored on local disk, no cloud storage adapter)Same reason; also must survive redeploys, which recreate the container
node_modules (dev only)Isolation from host, see above
Build cache dir (dev only)Faster rebuilds, avoids permission fights with the bind mount

Permissions gotcha with named volumes

If a directory doesn't already exist in the image when a named volume mounts over it, Docker creates the mountpoint as root:root — even if the container runs as a non-root USER. Result: EACCES the first time the app tries to write to it.

Fix: create the directory in the Dockerfile and chown it to the runtime user before the volume ever mounts. Docker copies a named volume's initial content (and ownership) from whatever already exists at that path in the image, on first creation:

RUN mkdir media
RUN chown nextjs:nodejs media

Networking and port publishing

  • Dev: bind published ports to 127.0.0.1, not all interfaces — '127.0.0.1:5432:5432' instead of '5432:5432'. No reason your dev DB should be reachable from the LAN.
  • Prod behind a reverse proxy (Traefik, Nginx, Dokploy's built-in proxy, etc.): don't publish the app's port to the host at all. Use expose instead of ports — the proxy reaches the container directly over the Docker network by service name, and expose avoids binding a host port that might collide with other apps on the same box.
  • Databases in prod: if it's a separate managed/external service, it isn't in this compose file at all — just point DATABASE_URL/host env vars at it. If it is in this compose file, give it no ports: entry; only the app service needs to reach it, over the internal compose network.

Common pitfalls

  • Reusing one compose file for dev and prod — always ends up wrong for one of them.
  • Forgetting node_modules needs its own volume in dev — native deps built on the host silently break in the container.
  • Assuming --env-file on docker compose changes what env_file: inside the YAML loads — it doesn't. --env-file only affects ${VAR} interpolation within the compose file itself; env_file: always reads the literal path written in the YAML. If you need a different env source for a one-off test, temporarily swap the actual file, don't rely on --env-file.
  • Copying Dockerfile boilerplate (a public/ copy step, a COPY for a config file that doesn't exist in this project) without checking every path actually exists.
  • Not testing that env validation still passes at real runtime after adding build-time placeholders — it's easy to accidentally make the placeholder match a blocked/insecure value and get a confusing runtime rejection that looks unrelated to the build change.
  • Skipping .dockerignore and only noticing .env was sent to the build context after the fact.

Verification checklist

Don't ship any of this unverified — actually run it:

# Dev: hot reload really works
docker compose -f docker-compose.dev.yml up -d
echo "// test" >> some/watched/file.ts
docker logs <app-container> --tail 5   # expect an unprompted recompile

# Prod: build succeeds standalone
docker build -t app-prod-test .

# Prod: boots with real env, connects to DB, volume is writable
docker compose -f docker-compose.prod.yml --env-file .env up -d
docker exec <app-container> sh -c 'touch /app/media/x && echo OK && rm /app/media/x'
docker exec <app-container> id   # confirm non-root

Wrapping up

One compose file per job, not one for both: docker-compose.dev.yml bind-mounts source for hot reload and isolates node_modules in its own volume, while docker-compose.prod.yml runs a multi-stage-built image with no source or dev server anywhere in it. The parts that actually cause outages in practice are the small ones — a healthcheck-gated depends_on, a .dockerignore that keeps .env out of the build context, a chown'd directory before a named volume mounts over it, and build-time placeholder env vars that satisfy fail-fast validation without leaking into the running container. None of it is exotic; all of it is easy to get subtly wrong and only notice in production. Run the verification checklist before you trust any of it.


Further reading

WRITTEN BY
Sakib Ullah

Sakib Ullah

Full-Stack Developer & AI Automation Specialist

A full-stack developer based in Dhaka, freelancing for clients across multiple industries and building products that solve real business problems.