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
- The dev compose file (hot reload that actually works)
- The production Dockerfile (multi-stage build)
- The env-var-at-build-time trap
.dockerignoreis not optional- Volumes: what needs to persist, what doesn't
- Networking and port publishing
- Common pitfalls
- Verification checklist
- Wrapping up
- Further reading
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 isThe 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 editHealthchecks 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_healthyThe 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.jsNotes that came from actually building this, not just theory:
- Non-root user. Create a dedicated system user/group and
USERinto it beforeCMD. Anything the container needs to write at runtime (see volumes below) has to bechown'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 meansoutput: 'standalone'innext.config.ts. Skip it and theCOPYstep 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 nopublic/dir — that line fails every build until removed. Always confirm everyCOPYsource path actually exists in this project. HEALTHCHECKin 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 buildThis 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 uploadsVolumes: what needs to persist, what doesn't
| Needs a volume | Why |
|---|---|
| Database data dir | Obviously — 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 mediaNetworking 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
exposeinstead ofports— the proxy reaches the container directly over the Docker network by service name, andexposeavoids 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 noports: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_modulesneeds its own volume in dev — native deps built on the host silently break in the container. - Assuming
--env-fileondocker composechanges whatenv_file:inside the YAML loads — it doesn't.--env-fileonly 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, aCOPYfor 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
.dockerignoreand only noticing.envwas 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-rootWrapping 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
- Compose file reference — full syntax for
volumes,depends_on,healthcheck, and more - Dockerfile multi-stage builds — the pattern behind the production Dockerfile in this guide
- Dockerfile
HEALTHCHECKreference — syntax and defaults for image-level healthchecks - Compose startup order (
depends_on+condition) — why plaindepends_onisn't enough .dockerignorefiles — keeping secrets and bloat out of the build context- Docker volumes — persistence and ownership behavior referenced in the permissions section
- Docker Desktop file sharing — why bind-mount file-watching behaves differently on Mac/Windows
- Next.js
output: 'standalone'— required for the.next/standalonecopy step to work