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

  1. The Docker client sends the Dockerfile and the filtered build context to BuildKit. Files excluded by .dockerignore are not available to COPY.
  2. Docker resolves node:20-alpine. If the image layers are not present locally, Docker pulls the manifest and layers from a registry.
  3. WORKDIR and ENV update image configuration and set defaults for later instructions and runtime containers.
  4. COPY package.json package-lock.json ./ creates a layer containing only dependency manifests. Docker can reuse the next layer unless those files change.
  5. RUN npm ci --omit=dev executes inside a temporary build container and commits the installed production dependencies as a new read-only layer.
  6. COPY server.js ./ adds application code in a later layer. Normal source edits now rebuild this small layer without reinstalling dependencies.
  7. When a container starts, Docker mounts the read-only image layers, adds one writable layer, attaches networking, maps ports requested with -p or Compose ports:, switches to USER node, and starts the CMD process.
  8. When the container exits, --rm removes the container writable layer. The image remains until you remove it with docker 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; avoid latest for repeatable builds.
  • Commit package-lock.json and prefer npm ci inside images for deterministic installs.
  • Copy dependency manifests before application source to preserve the expensive npm install cache.
  • Add a .dockerignore file that excludes node_modules, Git data, logs, coverage output, and local environment files.
  • Run production containers as a non-root user, such as the official image’s node user.
  • Use EXPOSE for documentation only; publish ports with -p or Compose ports:.
  • 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

  1. Take an existing Express app and write a Dockerfile that uses node:20-alpine, installs with npm ci --omit=dev, runs as node, and documents port 3000. Expected end state: docker run -p 3000:3000 reaches the app.
  2. Create a .dockerignore for a Node project with node_modules, .git, .env, coverage, and log files. Hint: rebuild and compare the transferred context size.
  3. Convert a TypeScript app into a multi-stage build. The builder should run npm ci and npm run build; the final stage should install production dependencies and copy only dist.

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.
  • .dockerignore keeps local clutter and accidental files out of the build context.
  • EXPOSE documents a container port but does not publish it; use -p or Compose ports:.
  • Production images should use pinned base tags, non-root users, production dependencies, and no baked-in secrets.