What Actually Belongs in a Production Dockerfile for Node (and What Doesn't)
Generating a Dockerfile is easy. Generating one that builds fast, runs as a non-root user, and produces a small image is a different task. Most templates on the internet get the easy part right and skip the parts that matter in production. The decisions below are the ones that actually affect build time, image size, and runtime safety for a Node service.
A Dockerfile for a Node app is short. The hard part is not writing one, it is writing one that does not waste disk, rebuild slowly, or run as root. Most of the pain people associate with Docker, slow builds, large images, broken layer caching, comes from a handful of decisions made early in the file. This is a walkthrough of those decisions and why they matter.
Pick the base image on purpose
The node:20 image is convenient and large. It is based on Debian and ships a full build toolchain you probably do not need at runtime. The node:20-alpine image is much smaller and is the right default for a Node service that does not require glibc-specific native modules.
The catch is native modules. Some packages, sharp, bcrypt, and anything built on node-gyp, may need build tools that Alpine does not ship by default. The standard fix is a multi-stage build: compile the native modules in a full node:20 image, then copy the result into a smaller runtime image. If you hit mysterious segmentation faults on Alpine, glibc versus musl is the first thing to check, not your code.
Avoid latest. It moves under you. Pin to a major version, and pin the patch version in CI if you want reproducible builds.
Cache dependencies separately from your source
The single biggest build-time mistake is copying package.json and src/ in the same layer. Every code change invalidates the dependency layer, so Docker reinstalls everything on every build.
The fix is the two-step copy that every good Node Dockerfile uses:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
With this order, the npm ci layer is cached as long as the lockfile does not change. A one-line code change rebuilds only the final COPY . . layer, which takes seconds. Getting this wrong turns a 20-second rebuild into a 3-minute one, and it is the first thing to check when builds feel slow.
Use npm ci instead of npm install in the image. npm ci reads the lockfile, removes node_modules first, and installs the exact tree. It is faster and more reproducible than npm install, which is allowed to resolve a new tree.
Multi-stage builds keep the image small
A Node service needs its dependencies at runtime, but it does not need the compiler toolchain, the source maps, or the dev dependencies that were used to build it. A multi-stage build separates the build environment from the runtime environment.
The build stage installs everything, including devDependencies, and compiles TypeScript if needed. The runtime stage copies only node_modules (production dependencies), the compiled output, and the package.json. The result is an image that is often half the size of a single-stage build and does not ship typescript, eslint, or test frameworks.
If you use Prisma, the engine binary is platform-specific. Build it in the build stage for the runtime image's architecture, or you will get a runtime error about a missing engine when the container starts. The Docker tool on this site includes a Prisma option precisely because this is easy to get wrong.
The NODE_ENV trap
NODE_ENV=production does two things that surprise people. It makes Express, Koa, and other frameworks skip slow development checks, which is what you want. It also causes npm install to skip devDependencies, which is sometimes not what you want during the build stage, because the build stage needs TypeScript and the test runner.
The fix is to set NODE_ENV=production only in the final runtime stage, or to install with npm ci --omit=dev explicitly when you want dev dependencies excluded. Setting NODE_ENV=production globally in the build stage and then wondering why tsc is missing is a common and confusing failure.
Run as a non-root user
By default, Node images run as root. If a vulnerability in your app allows file writes, the attacker writes as root inside the container. Creating a dedicated user and switching to it is a one-time setup that removes a whole class of escalation risk.
RUN addgroup -S app && adduser -S app -G app
USER app
Most orchestrators can also enforce this at the platform level with a runAsNonRoot policy, but setting it in the Dockerfile means the image is safe by default, even on platforms that do not enforce it.
docker-compose is for development, not production
Compose is excellent for local development: one command brings up the app, Postgres, and Redis together. But a compose file that mirrors production invites trouble. Production rarely runs a single node, rarely uses the local filesystem for state, and rarely applies configuration the same way.
Keep compose for development. Use the same image for production, but configure it through the orchestrator's own mechanisms, environment variables, secrets, health checks, and restart policies. If your production setup is "docker-compose up on a big server", you have a scaling and reliability problem that compose will not solve for you.
A reasonable development compose file defines the app, a database, and maybe a cache, with volumes for the database so data survives restarts. It does not define production replicas, load balancing, or rolling deploys. Those belong elsewhere.
What to check before you ship
Build the image and run docker image inspect on it. Check the size. If a Node service image is over 400 MB without a reason, the base image or a missing multi-stage build is usually the cause. Run docker history <image> to see which layer is large. The dependency layer being large is expected; the source layer being large often means node_modules was copied from the host instead of installed inside the image.
Run the container and confirm it starts as the non-root user. Confirm the health check passes. Confirm that a code change triggers a fast rebuild, which is the real test of whether your layer caching is set up correctly.
The Docker tool on this site generates a Dockerfile and compose file with these decisions baked in, so you can start from a reasonable baseline and adjust for your specific app instead of rewriting from a generic template.
Primary references
Standards and official documentation used to check the technical details in this guide.
Generate a Dockerfile and compose file locally
The Docker tool on this site produces a Dockerfile and docker-compose.yml for Node, with optional Postgres, Redis, and Prisma support, tuned for the decisions below. Everything runs in your browser; nothing is uploaded.
Open the Docker toolRelated guides
Continue with practical guides from the same topic area.
Catching Missing Translation Keys and Interpolation Mismatches Before Users Do
A missing translation key renders the raw key path to users, and a mismatched interpolation parameter renders an empty string or a crash. Both are easy to miss in review because the developer's locale always has every key. A guide to comparing locale JSON files, finding missing keys, and catching parameter mismatches before they ship.
Mock Data That Actually Exercises Your UI (Not Just Fills It)
Most mock data is ten copies of the same row with a different id. It fills the page and tests nothing. A guide to generating mock data that exercises layout edge cases, long names, missing fields, empty states, and the date and number formats that break formatting code, with field inference so a sample JSON becomes a realistic dataset in one step.
Converting cURL to Fetch, Axios, or Python Without Losing the Headers That Matter
cURL is the lingua franca of HTTP debugging, but pasting a cURL command into application code is a category error. A guide to what survives the conversion to fetch, Axios, and Python requests, what gets lost, and the headers and body encodings that quietly change behavior when you translate.