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.
Developer Laptop: Node 18 + npm 9 + Works fineCI Server: Node 16 + npm 8 + Tests failProduction: Node 16 + npm 8 + App crashesEvery 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:
# Use an existing image as baseFROM node:18
# Set working directoryWORKDIR /app
# Copy package files first (leverage caching)COPY package.json ./RUN npm install
# Copy remaining source codeCOPY . .
# Expose the portEXPOSE 3000
# Start the appCMD ["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:
FROM node:18WORKDIR /appCOPY . . # Copy everything firstRUN npm install # Then install dependenciesEXPOSE 3000CMD ["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.
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 unchangedLayer 4: RUN npm install -> Cached if package.json unchangedLayer 5: COPY . . -> NOT cached (source changed)Layer 6: CMD ... -> CachedWhen 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:
Source code changes -> Only Layer 5 invalidatedpackage.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:
node_modules.gitlogs.env*.md.DS_StoreWith this file, Docker skips node_modules when copying. The image size dropped to 200MB.
Step 4: Build and Run
Now I built the 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:
docker run -p 3000:3000 my-node-appThe -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.
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:
docker run -p 3000:3000 my-node-appThe 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
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.
FROM node:18I 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
ENV DATABASE_PASSWORD=mypassword123Never put secrets in Dockerfile. They’re visible in the image layers.
docker run -e DATABASE_PASSWORD=mypassword123 -p 3000:3000 my-node-appPass 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:
-
Layer order matters: Copy
package.jsonbefore source code to maximize cache efficiency. Fast rebuilds save time. -
Use
.dockerignore: Skipnode_modulesand other unnecessary files. Keeps images lean. -
Pin Node version: Use
FROM node:18notFROM node:latest. Reproducible builds. -
Port mapping: Use
-p 3000:3000to access the app from outside the container. -
No secrets in Dockerfile: Pass secrets at runtime via
-eflag.
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