Dockerizing a Node.js Application
Dockerizing a Node.js application means packaging the app, its Node runtime, and its dependencies into a repeatable image. It matters because the same image can run on your laptop, in CI, and on a server without depending on whatever Node version or npm packages happen to be installed on that machine.
A good Node image is more than a working Dockerfile. It should rebuild quickly, avoid shipping development clutter, run as a non-root user, and make the difference between development bind mounts and production images clear.
Overview: How it works
A Docker image is a read-only template made from stacked layers. In a Node.js image, typical layers come from the base image, the working directory, copied dependency manifests, installed node_modules, copied source files, and runtime metadata such as CMD and EXPOSE. A container is a running or stopped instance of that image plus a thin writable layer and one main process, usually node server.js or node dist/server.js.
When you run docker build, the Docker client sends the build context to the Docker builder. The context is the project directory after .dockerignore has excluded files such as node_modules, .git, logs, and local secrets. BuildKit reads the Dockerfile from top to bottom and decides whether each instruction can reuse a cached layer. For Node apps, this cache behavior is critical because dependency installation is often the slowest step.
The usual cache-friendly pattern is to copy package.json and package-lock.json first, run npm ci, then copy the rest of the source. If you copy the whole project before installing dependencies, every source edit invalidates the dependency layer and Docker must reinstall packages. If only src/server.js changes, the dependency layer should remain reusable.
At runtime, Docker mounts the image layers, adds the container writable layer, applies configuration such as environment variables and user, connects the container to a network, and starts the configured process. EXPOSE 3000 is only image metadata documenting the intended container port. It does not publish anything to your host. Use docker run -p 3000:3000 or Compose ports: to make the service reachable from outside the container.
For production, use pinned base image tags such as node:20-alpine, not node or node:latest. The latest tag is a moving target, so a rebuild can silently change your Node version or operating system packages. For compiled TypeScript or bundled apps, a multi-stage build is often best: one stage installs dev dependencies and builds the app, while the final stage installs only production dependencies and copies only the built output.
Syntax
FROM node:version
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE container-port
USER node
CMD ["node", "server.js"]
| Part | Meaning |
|---|---|
FROM node:20-alpine |
Starts from a specific Node runtime image. Pin the tag for reproducible builds. |
WORKDIR /app |
Sets the directory used by later COPY, RUN, and CMD instructions. |
ENV NODE_ENV=production |
Sets a runtime environment variable commonly used by Node frameworks and libraries. |
COPY package.json package-lock.json ./ |
Copies dependency manifests before source files so the npm install layer can be cached. |
RUN npm ci --omit=dev |
Installs exactly from the lockfile and skips development dependencies for production images. |
COPY . . |
Copies the application source after dependencies are installed. |
EXPOSE 3000 |
Documents the port inside the container. It does not publish the port to the host. |
USER node |
Runs the app as the non-root node user included in the official Node image. |
CMD ["node", "server.js"] |
Defines the default process for containers started from the image. |
Examples
Example 1: Create a tiny Express app to containerize
This command sequence creates a small Node application with an HTTP endpoint. In a real project, you would already have these files and a committed lockfile.
mkdir -p node-api
cd node-api
npm init -y
npm install express@4.19.2
printf '%s
' "const express = require('express');" "const app = express();" "const port = process.env.PORT || 3000;" "app.get('/', (req, res) => res.json({ status: 'ok' }));" "app.listen(port, () => console.log('Listening on port ' + port));" > server.js
Output:
added 68 packages, and audited 69 packages
created package.json
created package-lock.json
created server.js
The important Docker detail is the lockfile. npm ci expects package-lock.json and uses it to install repeatable dependency versions. The app listens on process.env.PORT so the same image can run on different platforms, while defaulting to port 3000 for local Docker use.
Example 2: Write a production Dockerfile
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3000
USER node
CMD ["node", "server.js"]
Output:
Dockerfile created for a production Node.js image.
This Dockerfile installs production dependencies before copying server.js. Editing the server file will not invalidate the npm ci layer. The official node:20-alpine image includes a non-root node user, so USER node is a simple hardening step. EXPOSE 3000 tells readers and tools what port the app expects, but it does not publish the port.
Example 3: Build and run the image
docker build -t node-api:1.0 .
docker run --rm --name node-api -p 3000:3000 node-api:1.0
Output:
[+] Building 8.2s (9/9) FINISHED
=> [4/6] RUN npm ci --omit=dev
=> exporting to image
=> naming to docker.io/library/node-api:1.0
Listening on port 3000
The first command builds the image and tags it as node-api:1.0. The second command starts a container and publishes host port 3000 to container port 3000. While the container is running, http://localhost:3000 reaches the Express app from the host.
Example 4: Add a .dockerignore file
node_modules
npm-debug.log
.git
.gitignore
.env
coverage
Dockerfile
README.md
Output:
#1 [internal] load .dockerignore
#1 transferring context: 124B
#2 [internal] load build context
#2 transferring context: 31.04kB
The build context should contain source files needed by COPY, not local dependency folders, Git history, coverage output, or environment files. Never rely on .dockerignore as your only secret control, but do use it to prevent accidental bloat and reduce the chance of copying unwanted files into an image layer.
Example 5: Multi-stage Dockerfile for a built Node app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS final
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
Output:
[+] Building 11.5s (13/13) FINISHED
=> [builder 4/6] RUN npm ci
=> [builder 6/6] RUN npm run build
=> [final 4/5] RUN npm ci --omit=dev
=> [final 5/5] COPY --from=builder /app/dist ./dist
=> naming to docker.io/library/node-api:2.0
The builder stage installs all dependencies because build tools usually live in devDependencies. The final stage starts fresh, installs only production dependencies, and copies only dist. This keeps source-only files and build tooling out of the shipped image. Intermediate builder layers may remain in the local build cache, but they are not part of the final image.
Example 6: Use Compose for local development
services:
api:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: development
volumes:
- .:/app
- node_modules:/app/node_modules
command: npm run dev
volumes:
node_modules:
Output:
docker compose up --build
[+] Building 3.4s (9/9) FINISHED
[+] Running 2/2
✔ Network node-api_default created
✔ Container node-api-api-1 created
api-1 | Listening on port 3000
This Compose file bind-mounts the project directory into the container so local code changes appear immediately. The named volume at /app/node_modules prevents the host bind mount from hiding the container’s installed dependencies. Use bind mounts for local source-code development; use a built image without a source bind mount for production.
How it works step by step
- The Docker client sends the Dockerfile and the filtered build context to BuildKit. Files excluded by
.dockerignoreare not available toCOPY. - Docker resolves
node:20-alpine. If the image layers are not present locally, Docker pulls the manifest and layers from a registry. WORKDIRandENVupdate image configuration and set defaults for later instructions and runtime containers.COPY package.json package-lock.json ./creates a layer containing only dependency manifests. Docker can reuse the next layer unless those files change.RUN npm ci --omit=devexecutes inside a temporary build container and commits the installed production dependencies as a new read-only layer.COPY server.js ./adds application code in a later layer. Normal source edits now rebuild this small layer without reinstalling dependencies.- When a container starts, Docker mounts the read-only image layers, adds one writable layer, attaches networking, maps ports requested with
-por Composeports:, switches toUSER node, and starts theCMDprocess. - When the container exits,
--rmremoves the container writable layer. The image remains until you remove it withdocker rmi.
Common Mistakes
Using latest as the base image
FROM node:latest
WORKDIR /app
COPY . .
CMD ["node", "server.js"]
This may work today and change tomorrow. latest can move to a new Node major version or a different package set. Use a specific supported tag such as node:20-alpine, and review upgrades intentionally.
Copying source before installing dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]
This invalidates the install layer whenever any source file changes. The fix is to copy package.json and package-lock.json first, run npm ci, then copy application source.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
Expecting EXPOSE to publish the service
docker run --rm node-api:1.0
If the Dockerfile contains EXPOSE 3000, this command still does not publish port 3000 to the host. The fix is a port mapping.
docker run --rm -p 3000:3000 node-api:1.0
Baking secrets into the image
FROM node:20-alpine
WORKDIR /app
ENV API_TOKEN="<YOUR_API_TOKEN>"
COPY . .
CMD ["node", "server.js"]
Do not store tokens, passwords, or private keys in ENV, ARG, copied files, or build logs. Image layers and metadata can be inspected later, and a later RUN rm does not remove data from an earlier layer. Pass configuration at runtime, mount secrets, or use your orchestrator’s secret store.
Best Practices
- Use pinned Node base tags such as
node:20-alpine; avoidlatestfor repeatable builds. - Commit
package-lock.jsonand prefernpm ciinside images for deterministic installs. - Copy dependency manifests before application source to preserve the expensive npm install cache.
- Add a
.dockerignorefile that excludesnode_modules, Git data, logs, coverage output, and local environment files. - Run production containers as a non-root user, such as the official image’s
nodeuser. - Use
EXPOSEfor documentation only; publish ports with-por Composeports:. - Use multi-stage builds for TypeScript, bundled front ends, native module build steps, and test stages.
- Keep production images focused on the runtime process. Use Compose to run databases, queues, and workers beside the app.
- Do not bake real secrets into any image layer. Supply them at runtime through safer secret mechanisms.
Practice Exercises
- Take an existing Express app and write a Dockerfile that uses
node:20-alpine, installs withnpm ci --omit=dev, runs asnode, and documents port3000. Expected end state:docker run -p 3000:3000reaches the app. - Create a
.dockerignorefor a Node project withnode_modules,.git,.env,coverage, and log files. Hint: rebuild and compare the transferred context size. - Convert a TypeScript app into a multi-stage build. The builder should run
npm ciandnpm run build; the final stage should install production dependencies and copy onlydist.
Summary
- A Dockerized Node.js app packages the runtime, dependency tree, application files, and startup command into an image.
- Images are read-only layer stacks; containers add a thin writable layer and run the configured process.
- Dependency manifests should be copied before source files so Docker can reuse the npm install layer.
.dockerignorekeeps local clutter and accidental files out of the build context.EXPOSEdocuments a container port but does not publish it; use-por Composeports:.- Production images should use pinned base tags, non-root users, production dependencies, and no baked-in secrets.
