Back to Blog
The Right Way to Dockerize Node.js Applications
devopsdockernodejs

The Right Way to Dockerize Node.js Applications

A guide to production-ready Node.js Docker builds and common pitfalls

The typical approach

Dockerizing a Node.js app feels pretty straightforward for the first time.

Just grab a base image, copy files, run npm install and good to go.

If the container runs locally, it's ready for production, right?

Here's what the flow usually looks like:

Create a Dockerfile, use node:latest, copy everything in, expose a port, and ship it.

Unfortunately, NO

It's not simple actually. What you have done is created a bloated, insecure, and slow-building image.

So what's the issue with the "basic" Dockerfile?

The issue is -

  • It breaks layer caching
  • It creates massive image sizes (1GB+)
  • It runs as root (security risk)
  • It leaks secrets and node_modules junk

The problem

The "Naive" Dockerfile

Let's start with a common example.

I've built a simple Express API.

Here's what the typical (but problematic) implementation looks like:

# Dockerfile
FROM node:latest

WORKDIR /app

COPY . .

RUN npm install

CMD ["npm", "start"]

This works, but it creates several critical problems:

Problem 1 : Breaking the Cache

In Docker, layers are cached based on changes.

When you do COPY . . before RUN npm install, you are copying your source code (which changes often) before installing dependencies.

Every time you change a single line of code in index.js and rebuild:

  1. Docker sees the file changed.
  2. It invalidates the cache for that layer.
  3. It forces npm install to run again from scratch.

This makes your CI/CD pipeline painfully slow.

Problem 2 : The Image Size

Using FROM node:latest pulls the full Debian-based image. It includes tools you don't need like git, curl, and system libraries.

Your simple "Hello World" API might end up being 1.2GB.

Problem 3 : Security Permissions

By default, Docker containers run as root.

If an attacker compromises your Node.js application (via a dependency vulnerability), they have root access inside that container.

The Solution

Multi-Stage Builds & Alpine

The industry standard is using Multi-Stage builds.

This allows us to install dependencies in one stage, build the app, and then copy only the necessary artifacts to a tiny production image.

Here's the implementation:

# Stage 1: The Builder
FROM node:18-alpine AS builder

WORKDIR /app

# Copy package.json FIRST
COPY package*.json ./

# Install dependencies
RUN npm ci

# Copy the rest of the code
COPY . .

# Build the app (if using TypeScript/NestJS)
RUN npm run build
# Stage 2: The Runner
FROM node:18-alpine AS runner

WORKDIR /app

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copy only necessary files from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json

# Switch to non-root user
USER appuser

EXPOSE 3000

CMD ["node", "dist/main.js"]

This approach:

  • Optimizes Caching: npm ci only runs if package.json changes, not your source code.
  • Reduces Size: Using alpine and discarding build tools drops image size from ~1GB to ~150MB.
  • Security: The app runs as appuser, not root.

Handling Environment Variables

A common mistake is baking secrets into the image.

Never put .env files in your COPY commands if they contain secrets.

The .dockerignore File

Just like .gitignore, you need a .dockerignore file.

If you don't have this, you might accidentally copy your local node_modules (which are OS-specific) or your local .env file into the container.

# .dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.env
dist

Process Management (The PID 1 Problem)

Node.js is not designed to run as PID 1 (Process ID 1).

PID 1 handles system signals (like SIGTERM or SIGINT). If you just run node index.js, your app might not shut down gracefully when Docker tries to stop it.

Solution: Tini

Alpine Linux comes with a tiny init process called tini.

It handles signals properly and spawns your Node process.

You can add it to your runner stage:

# In the Runner stage
RUN apk add --no-cache tini

# Use Tini as the entrypoint
ENTRYPOINT ["/sbin/tini", "--"]

CMD ["node", "dist/main.js"]

Summary

  1. Use Specific Tags like node:18-alpine instead of latest to ensure stability and small size.
  2. Optimize Layer Caching by copying package.json and installing dependencies before copying source code.
  3. Use Multi-Stage Builds to separate build tools from the production runtime.
  4. Implement .dockerignore to prevent local garbage and secrets from entering the image.
  5. Run as Non-Root User to limit the blast radius of security vulnerabilities.
  6. Handle Signals using an init process like tini for graceful shutdowns.

Design & Developed by AkshayMoolya
© 2026. All rights reserved.