We earn commissions when you shop through the links below.
If you’ve ever struggled with “it works on my machine” problems, you already know why containers exist. Learning how to dockerize a Node.js application is one of the highest-leverage skills you can add to your workflow — it eliminates environment drift, simplifies deployments, and makes onboarding new developers trivially easy. In this guide I’ll walk you through the entire process from scratch: writing a Dockerfile, optimizing it for production, and getting your container running reliably.
Prerequisites
Before we start, make sure you have:
- Docker Desktop installed and running (or Docker Engine on Linux)
- A Node.js application to containerize (we’ll build a minimal Express app as the example)
- Basic familiarity with the terminal
Step 1: Set Up a Simple Node.js App
If you don’t already have an app, let’s create a minimal Express server to use as our target. Initialize a project and install Express:
mkdir docker-node-demo
cd docker-node-demo
npm init -y
npm install expressCreate index.js:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({ message: 'Hello from Docker!', env: process.env.NODE_ENV });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});Add a start script to package.json:
"scripts": {
"start": "node index.js"
}Step 2: Write Your Dockerfile
The Dockerfile is the blueprint for your container image. Here’s a production-ready Dockerfile that covers everything you need when you want to dockerize a Node.js application properly:
# Use the official Node.js LTS image on Alpine for a smaller footprint
FROM node:20-alpine AS base
# Set working directory inside the container
WORKDIR /app
# Copy package files first to leverage Docker layer caching
COPY package*.json ./
# Install only production dependencies
RUN npm ci --omit=dev
# Copy the rest of the application source
COPY . .
# Expose the port your app listens on
EXPOSE 3000
# Set NODE_ENV for production
ENV NODE_ENV=production
# Start the application
CMD ["node", "index.js"]A few things worth noting here:
- node:20-alpine — Alpine Linux images are roughly 5x smaller than the default Debian-based ones. This matters for push/pull times in CI and deployment.
- Copying package files first — Docker caches layers. If you copy your source code first, every code change invalidates the npm install layer. Copying
package*.jsonfirst means dependencies only reinstall when they actually change. - npm ci instead of npm install —
npm ciinstalls from the lockfile exactly, which is deterministic and faster in automated environments.
Step 3: Create a .dockerignore File
Just like .gitignore, a .dockerignore file tells Docker what to exclude from the build context. This keeps your image lean and prevents accidentally copying secrets or large directories:
node_modules
npm-debug.log
.git
.gitignore
.env
*.md
Dockefile
.dockerignoreWithout this, Docker copies your entire node_modules folder into the build context unnecessarily — which can add seconds to every build and bloat the final image.
Step 4: Build and Run the Image
Build your image with a tag so you can reference it easily:
docker build -t docker-node-demo:latest .Run it locally, mapping port 3000 on your host to port 3000 inside the container:
docker run -p 3000:3000 --name my-node-app docker-node-demo:latestVisit http://localhost:3000 and you should see the JSON response. To run it in the background, add the -d flag (detached mode):
docker run -d -p 3000:3000 --name my-node-app docker-node-demo:latestStep 5: Use Docker Compose for Local Development
Running a single container is fine, but real applications usually need a database, cache, or other services alongside them. Docker Compose handles this elegantly. Create a docker-compose.yml:
version: '3.9'
services:
app:
build: .
ports:
- '3000:3000'
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://user:password@db:5432/mydb
volumes:
- .:/app
- /app/node_modules
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:The volume mount .:/app syncs your local source code into the container, so changes reflect immediately without rebuilding. The /app/node_modules mount prevents your host’s node_modules from overwriting the container’s.
Start everything with:
docker compose up --buildStep 6: Multi-Stage Builds for Smaller Production Images
If your application has a build step (TypeScript compilation, bundling, etc.), multi-stage builds let you separate the build environment from the runtime environment — keeping the final image as small as possible:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]The final image only contains the compiled output and production dependencies — no TypeScript compiler, no dev tooling, no source maps unless you want them. This is how you dockerize a Node.js application for serious production use.
Deploying Your Docker Container
Once your image is working locally, you need somewhere to run it. A few solid options:
Container platforms: Railway is one of the fastest ways to deploy Docker containers without managing infrastructure. You push your image or connect your repo and it handles the rest — no Kubernetes configuration required.
Cloud VMs with Docker: If you want more control, spinning up a Droplet on DigitalOcean and running Docker directly gives you a clean, affordable setup. Their Marketplace has a one-click Docker image so you can skip the installation entirely and focus on deploying your container.
For pushing your image to a registry before deployment:
# Tag for Docker Hub
docker tag docker-node-demo:latest yourusername/docker-node-demo:latest
# Push to Docker Hub
docker push yourusername/docker-node-demo:latestCommon Mistakes to Avoid
- Running as root inside the container — Add a non-root user:
RUN addgroup -S appgroup && adduser -S appuser -G appgroupthenUSER appuserbefore your CMD. - Hardcoding secrets in the Dockerfile — Always pass sensitive values via environment variables at runtime, never bake them into the image.
- Not setting a health check — Add
HEALTHCHECK CMD wget -qO- http://localhost:3000/health || exit 1so orchestrators know when your container is actually ready. - Using
latesttags in production — Pin your base image to a specific version (node:20.14-alpine) to prevent unexpected changes between builds.
Wrapping Up
Knowing how to dockerize a Node.js application properly — with layer caching, multi-stage builds, and a sensible .dockerignore — makes a real difference in build times, image sizes, and deployment reliability. Start with the patterns in this guide and you’ll avoid the most common pitfalls that slow teams down. If you want to go deeper on Docker, Kubernetes, and container orchestration in general, Udemy has several well-regarded courses that cover production-grade container workflows from end to end.