How to containerize a Node.js app with Docker
Containerize a Node.js app with Docker: write a Dockerfile, use base images and multi-stage builds, expose ports, manage dependencies and environment variables, and run your container locally — open-license sources.
On this page
- What containerization and Docker mean
- Prerequisites
- Step 1 — Understand the parts of a Dockerfile
- Step 2 — Choose a good base image and use .dockerignore
- Step 3 — Install only what you need
- Step 4 — Use a multi-stage build to keep the image small
- Step 5 — Handle environment variables and non-root users
- Step 6 — Build and run the container
- Common mistakes to avoid
- FAQ
- What is the difference between an image and a container?
- Is Docker free to use?
- Why use a multi-stage build?
- What does -p 3000:3000 do?
- Do I need Node.js installed to run the container?
- Summary
Containerization packages your application together with its runtime and dependencies into a single, portable image that runs the same way on any machine that has Docker. This guide shows how to containerize a Node.js app: what goes into a Dockerfile, how to choose a base image, how to use a multi-stage build to keep the final image small, how to handle dependencies and environment variables, and how to build and run the container locally with real commands you can adapt to your own project.
What containerization and Docker mean
A container is a runnable instance of an image — a lightweight, standalone, executable package that includes everything the software needs to run: code, runtime, system tools, libraries, and settings. Containers share the host operating system kernel and run as isolated processes, which is why they start in seconds and use far fewer resources than a full virtual machine.
Docker is a platform for building, sharing, and running containers. Its main pieces are:
- Image — a read-only template with instructions for creating a container.
- Container — a runnable instance of an image.
- Dockerfile — a text file of instructions that build an image.
- Docker Engine — the background service that builds and runs images.
- Docker Hub / registries — places to store and share images.
Because the image bundles the exact runtime and dependencies, the same image behaves identically on a laptop, a test server, or a cloud production machine. This removes the classic "it works on my machine" problem.
Prerequisites
- Docker installed and running — Docker Desktop on Windows and macOS, Docker Engine + CLI on Linux. Verify with
docker --version. - A Node.js project — at minimum a
package.jsonwith astartscript and a source entry point (for exampleindex.js). - Basic familiarity with the command line.
You do not need Node.js installed locally to build or run the container, because the base image provides the runtime.
Step 1 — Understand the parts of a Dockerfile
A Dockerfile is a build script for your image. Each instruction is a line that Docker executes, and each instruction creates a layer. Common instructions:
FROM— the base image your image starts from (the runtime and OS it builds on).WORKDIR— sets the working directory for the commands that follow.COPY— copies files or directories from your build context into the image.RUN— executes a command during the build (used to install dependencies).ENV— sets an environment variable.EXPOSE— documents the port the app listens on (informational; it does not publish the port).CMD— the default command run when a container starts from the image.
The simplest possible Node.js Dockerfile looks like this:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install --omit=dev
EXPOSE 3000
CMD ["node", "index.js"]
This is a valid starting point, but it has problems: it copies your whole directory, installs everything including tools you only need for building, and can be large. The next sections improve it.
Step 2 — Choose a good base image and use .dockerignore
The base image in FROM provides the runtime. For Node.js, the official images are published as node:<version> with tags such as node:20, node:20-alpine, and node:20-slim. The plain variant is based on Debian, while the -alpine variant is much smaller because it is built on Alpine Linux. Smaller images are faster to pull and have a smaller attack surface, so a common choice for the final runtime stage is an -alpine or -slim variant.
Create a .dockerignore file in your project root. It works like .gitignore and tells docker build which files to leave out of the build context, keeping the build faster and preventing local folders such as node_modules from being copied in:
node_modules
npm-debug.log
.git
.env
.containerignore
Dockerfile
.dockerignore
If you already have a local node_modules, excluding it here prevents the build from copying the wrong (host-specific) dependencies into the image.
Step 3 — Install only what you need
Inside the container you install dependencies with npm. A key detail is the difference between npm install and npm ci:
npm ciinstalls from the lock file (package-lock.json) and is faster and more reproducible — it is the recommended choice when a lock file is present.npm install --omit=devinstalls only production dependencies, skippingdevDependenciessuch as test frameworks and build tools.
A production-first install looks like:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
Copying package.json and package-lock.json before the rest of the source lets Docker cache the dependency-install layer. Because package.json changes far less often than your source code, Docker reuses the cached layer on rebuilds and only reinstalls when the lock file actually changes — this makes most rebuilds much faster.
Step 4 — Use a multi-stage build to keep the image small
Many Node.js projects need a build step: compiling TypeScript, bundling a frontend, or running a build tool. If you run that build inside the same image that ships to production, the final image carries the build tools and dev dependencies with it — unnecessarily large.
A multi-stage build uses multiple FROM instructions. Each FROM starts a new stage, and you copy only selected artifacts from an earlier stage into the final one with COPY --from. The earlier stages are discarded, so the final image contains only what the app needs at runtime.
# Stage 1 — build
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 — production runtime
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/package.json ./
COPY --from=build /app/dist ./dist
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", "dist/index.js"]
Here the build stage installs all dependencies (including dev ones) and runs npm run build, and the final stage copies only the built output and the lock file, then installs only production dependencies. The result is a small image that ships exactly the runtime artifacts.
Step 5 — Handle environment variables and non-root users
Configuration that changes between environments — database URLs, API keys, feature flags — should come from environment variables, not be baked into the image. You can set a default in the image with ENV, but you override it at runtime.
FROM node:20-alpine
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
Pass real values when you run the container:
docker run -e PORT=8080 -e DATABASE_URL=postgres://user:pass@host/db -p 8080:8080 my-app
Never store secrets in the image — do not put tokens or passwords in the Dockerfile or copy an .env file in. Pass secrets at runtime with -e (or a secrets mechanism) instead.
It is also good practice to avoid running the app as root. The official node images already provide a node user; switch to it before starting the app:
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
USER node
ENV PORT=3000
EXPOSE 3000
CMD ["node", "index.js"]
Running as a non-root user reduces the impact if the container is compromised.
Step 6 — Build and run the container
With the Dockerfile in place, build the image from your project root:
docker build -t my-app .
The -t my-app gives the image a name and tag. (The trailing . is the build context directory.) You can view it with docker images.
Run a container from the image:
docker run -p 3000:3000 my-app
The -p 3000:3000 maps port 3000 on your host to port 3000 in the container — this is what makes the app reachable from your browser. (The EXPOSE line in the Dockerfile only documents the port; the -p flag actually publishes it.) Open http://localhost:3000 to see your app.
Run it detached in the background and give it a name:
docker run -d --name my-app -p 3000:3000 my-app
Manage containers with docker ps (running), docker ps -a (all), docker logs my-app (logs), docker stop my-app, and docker rm my-app. To remove an unneeded image, use docker image rm my-app.
The ordinary workflow is: change your code → docker build again → docker run the new image → test. Because the dependency layers are cached, iterative rebuilds after the first are fast.
Common mistakes to avoid
- Missing
.dockerignore— your localnode_modulesgets copied into the image, producing a huge, broken image. - Installing dev dependencies in production — use
npm ci --omit=devin the final stage. - Copying all source before installing — copy
package.json/package-lock.jsonfirst to keep the dependency cache valid. - Ship the build environment — a single-stage build with a build step ships build tools; use a multi-stage build to copy only the output.
- Hard-coding secrets — never bake tokens or
.envfiles into the image; pass them at runtime. - Forgetting
-p—EXPOSEonly documents the port; you must publish it with-pto reach the app from your host. - Running as root — use the non-root
nodeuser unless you have a concrete reason not to.
FAQ
What is the difference between an image and a container?
An image is a read-only template with the instructions (layers) for creating a container. A container is a runnable instance of that image. You can build one image and run many containers from it, each isolated on the host.
Is Docker free to use?
Docker Engine and the Docker CLI are open source, and the Docker platform offers a free tier; Docker Desktop's licensing has a paid tier for larger companies. The container format and the docker build/docker run commands themselves are free to use on your own machines, and many container registries offer free public storage.
Why use a multi-stage build?
A multi-stage build separates building from running. You do the heavy work (installing dev dependencies, compiling, bundling) in a build stage, then copy only the needed artifacts into a slim final stage. The final image is smaller, faster to pull, has a smaller attack surface, and does not leak build tools or secrets.
What does -p 3000:3000 do?
It publishes the container's port 3000 to port 3000 on your host, so the application is reachable at http://localhost:3000. The left number is the host port, the right the container port; they can differ (for example -p 8080:3000).
Do I need Node.js installed to run the container?
No. The base image (node:<version>) contains the Node.js runtime. The whole point of containerizing is that the image bundles everything the app needs, so the machine only needs Docker — not a local Node.js install.
Summary
Containerizing a Node.js app is a small, repeatable process: write a Dockerfile that selects a base image, copies your manifest first for fast cached installs, installs only production dependencies, exposes and maps the port, and starts your app with CMD. Use a .dockerignore to keep the build clean, use a multi-stage build when you have a build step so the production image stays small, keep secrets and environment configuration out of the image, and run as a non-root user. Then docker build -t my-app . and docker run -p 3000:3000 my-app give you a portable containerized version of your app that runs identically anywhere Docker is installed.
Sources
- Docker Docs — Multi-stage builds (docs.docker.com/build/building/multi-stage)
- Docker Docs — Dockerfile reference (docs.docker.com/reference/dockerfile)
- Docker Docs — Build
- tag
- and publish an image (docs.docker.com/get-started/docker-concepts/building-images/build-tag-and-publish-an-image)
- Docker Docs — Language guide: React.js (docs.docker.com/guides/reactjs)
- Node.js Docs — guide (nodejs.org/docs/latest/api/)
- Wikipedia — Docker (en.wikipedia.org/wiki/Docker_(software))
Share this guide
Related guides
- How to use Docker Compose: multi-container apps explained
Learn Docker Compose to run multi-container apps from one YAML file: services, networks, volumes, health checks, Compose Watch, env vars, docker compose up and common commands — with a real Node.js example.
- Docker explained: how containers work
Docker explained: what containers are, how Docker Engine uses Linux namespaces and cgroups, how images, Dockerfiles, and Docker Compose fit together, and how Docker compares with virtual machines.
- How to monitor a Node.js application with Prometheus and Grafana
Build a complete Node.js + Prometheus + Grafana monitoring stack: instrument Express with prom-client, scrape metrics, run Prometheus in Docker, build Grafana dashboards with PromQL, and configure alerts. Includes a working docker-compose example.