Skip to content

How to Dockerize a Node.js Application: A Beginner's Guide with Working Examples

The Problem

I deployed my Node.js app to production. It crashed on startup. The error? “Module not found.” But it worked perfectly on my laptop.

I checked the production server. Node version was 16. My laptop had Node 18. Different versions. Different behavior.

The Classic Environment Problem
Developer Laptop: Node 18 + npm 9 + Works fine
CI Server: Node 16 + npm 8 + Tests fail
Production: Node 16 + npm 8 + App crashes

Every environment was different. Dependencies conflicted. Setup took hours. And every time I added a new teammate, they spent a day configuring their machine before they could write code.

The Solution: Docker

Docker solves this by creating an isolated, reproducible environment. Your app runs inside a container that has everything it needs: the exact Node version, the exact dependencies, the exact configuration.

I tried Dockerizing my Node.js app. It took me 10 minutes to write a Dockerfile, build an image, and run it. The app worked identically on my laptop, the CI server, and production.

Here’s how I did it.

Step 1: Write the Dockerfile

The Dockerfile is a text file that tells Docker how to build your app’s image. I started with this:

Dockerfile
# Use an existing image as base
FROM node:18
# Set working directory
WORKDIR /app
# Copy package files first (leverage caching)
COPY package.json ./
RUN npm install
# Copy remaining source code
COPY . .
# Expose the port
EXPOSE 3000
# Start the app
CMD ["node", "index.js"]

Each line is a instruction that creates a layer in the Docker image. Let me explain each one:

FROM node:18: This tells Docker to use the official Node.js image with version 18. It already has Node and npm installed, so I don’t need to install them manually.

WORKDIR /app: This creates a working directory inside the container. All subsequent commands run from /app. It’s cleaner than running everything from the root directory.

COPY package.json ./ and RUN npm install: These two lines handle dependencies. I copy package.json first, then run npm install. This is intentional. I’ll explain why in the next section.

COPY . .: This copies all the source code from my project into the container’s /app directory.

EXPOSE 3000: This documents that the app listens on port 3000. It doesn’t actually publish the port (I do that when running the container).

CMD ["node", "index.js"]: This is the command that runs when the container starts. It starts my Node.js app.

Step 2: Why Layer Order Matters

I made a mistake at first. I copied all files before installing dependencies:

Wrong Dockerfile Order
FROM node:18
WORKDIR /app
COPY . . # Copy everything first
RUN npm install # Then install dependencies
EXPOSE 3000
CMD ["node", "index.js"]

This worked, but rebuilds were slow. Every time I changed a single line in my source code, Docker re-ran npm install. That took 30 seconds each time.

The reason is Docker’s layer caching. Docker builds images in layers. Each instruction creates a layer. If a layer hasn’t changed, Docker reuses it from cache.

Docker Layer Caching
Layer 1: FROM node:18 -> Cached (base image)
Layer 2: WORKDIR /app -> Cached (no files changed)
Layer 3: COPY package.json -> Cached if package.json unchanged
Layer 4: RUN npm install -> Cached if package.json unchanged
Layer 5: COPY . . -> NOT cached (source changed)
Layer 6: CMD ... -> Cached

When I copy all files first (COPY . .), any source change invalidates that layer. Since npm install comes after it, Docker can’t use the cache for npm install.

The correct order is: copy package.json first, then npm install, then copy source code. This way:

Correct Layer Order with Cache Benefits
Source code changes -> Only Layer 5 invalidated
package.json unchanged -> Layer 3 and 4 use cache (npm install skipped)
Result: Fast rebuilds (1-2 seconds vs 30 seconds)

Step 3: Add .dockerignore

I forgot to add .dockerignore at first. My image was 500MB. Way too large.

The problem: COPY . . copies everything, including node_modules (which I already have locally from running npm install on my machine). The node_modules folder was 300MB, and it got copied into the container. Then npm install inside the container created another node_modules. Double the size.

The fix is a .dockerignore file that tells Docker what to skip:

.dockerignore
node_modules
.git
logs
.env
*.md
.DS_Store

With this file, Docker skips node_modules when copying. The image size dropped to 200MB.

Step 4: Build and Run

Now I built the image:

Build the Docker image
docker build -t my-node-app .

The -t flag tags the image with a name (my-node-app). The . tells Docker to use the current directory as the build context.

Then I ran it:

Run the Docker container
docker run -p 3000:3000 my-node-app

The -p 3000:3000 maps port 3000 on my machine to port 3000 inside the container. Without this, I couldn’t access the app from outside the container.

I opened http://localhost:3000 in my browser. The app worked.

Docker Workflow
Dockerfile (text file)
|
| docker build -t my-node-app .
|
Docker Image (built artifact, ~200MB)
|
| docker run -p 3000:3000 my-node-app
|
Docker Container (running process, isolated environment)

Step 5: Verify It Works Anywhere

To prove Docker solved my environment problem, I ran the same image on a different machine. This machine had Node 14 installed. My app requires Node 18.

Without Docker, the app would fail. With Docker, I just ran:

Same command on any machine
docker run -p 3000:3000 my-node-app

The app started. It used Node 18 from inside the container, not Node 14 from the host machine.

The environment mismatch problem was gone.

Common Mistakes I Made

Mistake 1: Using :latest tag

Wrong: Unpredictable base image
FROM node:latest

:latest always points to the newest version. Today it might be Node 18, tomorrow Node 20. My app breaks when the base image changes without notice.

Right: Pin specific version
FROM node:18

I pin the version. Builds are predictable and reproducible.

Mistake 2: Not using .dockerignore

I already covered this. Without .dockerignore, my images were bloated with unnecessary files.

Mistake 3: Wrong layer order

I already covered this too. Copying source before package.json made rebuilds slow.

Mistake 4: Hardcoding environment variables

Wrong: Secrets in Dockerfile
ENV DATABASE_PASSWORD=mypassword123

Never put secrets in Dockerfile. They’re visible in the image layers.

Right: Pass secrets at runtime
docker run -e DATABASE_PASSWORD=mypassword123 -p 3000:3000 my-node-app

Pass secrets via environment variables at runtime. Or use Docker secrets in production.

Summary

Dockerizing a Node.js app takes minutes with a proper Dockerfile. The key points:

  1. Layer order matters: Copy package.json before source code to maximize cache efficiency. Fast rebuilds save time.

  2. Use .dockerignore: Skip node_modules and other unnecessary files. Keeps images lean.

  3. Pin Node version: Use FROM node:18 not FROM node:latest. Reproducible builds.

  4. Port mapping: Use -p 3000:3000 to access the app from outside the container.

  5. No secrets in Dockerfile: Pass secrets at runtime via -e flag.

The result: my app runs identically on any machine. No version conflicts. No missing dependencies. No “works on my machine” excuses.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments