Skip to content
Published 8 min read

A Comprehensive Guide to Containerizing Node.js Applications

This guide covers everything you need to know about writing efficient Dockerfiles for Node.js applications. Before diving into Dockerfiles, it's essential to have a good grasp of Docker fundamentals. Familiarity with basic Docker concepts such as images, containers, and the container lifecycle will make this guide much more effective.

Docker Basics Refresher

Docker is an open-source platform that automates application deployment in lightweight, portable containers. Below is a breakdown of essential terms and the advantages of Docker for developers:

Key Concepts

  • Image

    A Docker image is a pre-configured environment with your app’s code, dependencies, and OS libraries. Think of it as a blueprint or snapshot that defines what goes into the container.

  • Container

    A container is a running instance of a Docker image. It's an isolated, standalone environment where your app runs. Containers are lightweight, sharing the OS kernel while isolating processes.

What is a Dockerfile?

A Dockerfile is a simple text file that tells Docker how to package up your application into a container - like a set of instructions for making a self-contained version of your program that can run anywhere. It tells Docker what operating system to use, what files to include, what commands to run during setup, and how to start your program. Think of it as a blueprint that makes sure your application works exactly the same way no matter which computer it runs on.

Here's an example of a simple Dockerfile:

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

Dockerfile Anatomy: Understanding Key Instructions

Writing an efficient Dockerfile involves using key instructions that define your image and control the container's behavior. Let’s walk through each one and understand its impact.

FROM: Choosing The Foundation

The FROM instruction sets the base image for a container. Every Dockerfile begins with a FROM statement to define the OS and environment upon which the rest of the image is built.

# Official Node.js LTS image
FROM node:22-alpine

Image Selection Considerations:

  • Alpine Images: Opt for Alpine-based images for a smaller image size and faster deployments, though be mindful that Alpine may lack some libraries available in larger distributions.
  • LTS Versions: Use Long-Term Support (LTS) versions to ensure stability, especially in production environments.
  • Version Pinning: Specify exact versions when possible (e.g., node:22.11-alpine) to maintain consistency across builds.

WORKDIR: Setting Up The Space

WORKDIR defines the working directory for subsequent instructions, setting up a dedicated space for the application’s files inside the container. This helps organize files and improves readability.

# Set working directory in the container
WORKDIR /app

Each command following WORKDIR will operate from this directory, reducing the need to specify absolute paths.

COPY and ADD: Moving Files Into The Container

COPY and ADD bring files from the local filesystem into the container.

# Copy package files first for better caching
COPY package*.json ./
 
# Copy application code
COPY . .

Best Practices:

  • Use COPY over ADD unless you need ADD's extra features (like downloading from a URL). COPY is more explicit and better suited for most tasks.
  • By copying only necessary files (e.g., package.json) first, you can cache dependencies, avoiding redundant re-installs if your source code changes but dependencies don’t.

RUN: Executing Build Commands

RUN executes commands at build time, creating layers in the final image. It’s typically used to install dependencies or perform build-related tasks.

# Update system packages (if needed)
RUN apk add --no-cache python3 make g++
 
# Install dependencies
RUN npm install

Tips:

  • Minimize Layers: Combine commands with && to reduce layers, but keep commands readable.
  • Avoid Unnecessary Packages: Only install tools needed for your app to avoid bloating the image.
  • Consider npm ci for Reproducibility: When working with Node.js, npm ci is faster and more reliable than npm install for installing dependencies.

ENV: Setting Environment Variables

ENV sets environment variables inside the container, which can be useful for configuring the app based on deployment needs.

# Set environment to production
ENV NODE_ENV=production
ENV PORT=3000

Notes:

  • Standardize Environments: Setting NODE_ENV=production ensures that packages marked as development dependencies aren’t installed, reducing image size.
  • Avoid Secrets in Dockerfiles: Keep sensitive information, like API keys, out of ENV to prevent it from being exposed in the final image. Use Docker secrets or environment variables at runtime instead.

EXPOSE: Declaring Ports

EXPOSE informs Docker of the ports the container will listen on. This is primarily documentation for other developers, as it doesn’t expose ports on the host machine by default (you need to use -p during docker run for that).

# Expose application port
EXPOSE 3000

This command helps clarify the purpose of each port in your container, especially in multi-service applications.

CMD and ENTRYPOINT: Starting The App

These instructions define the commands that run when a container starts:

  • CMD: Specifies the default command and arguments for the container.
  • ENTRYPOINT: Defines a command that is always executed, even if you provide a command override at runtime. CMD then provides default arguments to ENTRYPOINT.
# Simple approach
CMD ["npm", "start"]
 
# More specific
CMD ["node", "dist/server.js"]
 
# Using ENTRYPOINT with CMD
ENTRYPOINT ["node"]
CMD ["server.js"]

Best Practices:

  • Use CMD for Defaults: For simple containers where you might want to override the command at runtime, use CMD alone.
  • Combine ENTRYPOINT and CMD for Flexibility: Use ENTRYPOINT when you need a specific executable (like node), but allow flexibility in arguments via CMD.

The Problem with Simple Dockerfiles

The basic Dockerfile we've looked at works, but it has a significant drawback: it's bloated. When we build our Node.js application this way, we're packing everything into a single image - both the tools needed to build the app and the components needed to run it. It's like shipping the entire woodshop with every chair you make.

What's actually going into our image?

  • Build tools and compilers
  • Development dependencies
  • Source code and build artifacts
  • Testing frameworks
  • Documentation and README files
  • Version control files
  • And finally, our actual production application

To put this in perspective, a simple Node.js app might only need 50MB to run, but the Docker image could easily balloon to 500MB or more. That's a lot of unnecessary baggage.

Why Small Images Matter

Running bloated containers in production is like carrying a toolbox everywhere when all you need is a screwdriver. Here's why lean images are better:

  • Speed: Faster deployments, quicker startups, and reduced CI/CD build times.
  • Security: Smaller attack surface with fewer packages, reducing vulnerabilities.
  • Cost: Lower storage and bandwidth costs, and better host resource utilization.
  • Maintainability: Easier debugging, faster scanning, and clearer insight into container contents.

The solution? Multi-stage builds, which will be covered next.

Optimizing Dockerfile with Multi-Stage Builds

Multi-stage builds are a Docker feature that allows you to use multiple FROM statements in a single Dockerfile to create multiple stages, each with its own environment. This approach is particularly useful for reducing the size of the final image, as it allows you to separate the build process from the runtime environment. Let’s break down how multi-stage builds work and why they’re so beneficial:

How Multi-Stage Builds Work

A multi-stage Dockerfile typically has:

  • Build Stage: This stage is where you install build dependencies, run tests, and compile or bundle code. It often uses a larger base image because it requires additional tools or libraries.
  • Production Stage: The final, slimmer image that will actually be deployed. This stage only includes the application and its runtime dependencies, excluding anything unnecessary for production.

Each stage in a multi-stage build has its own FROM statement. By using COPY --from=<stage_name> or COPY --from=<stage_number>, you can selectively copy files from previous stages, making sure the final image only contains what is needed to run the application.

Here’s a multi-stage Dockerfile for a Node.js application that builds and then copies over only the essential files:

# Stage 1: Build Stage
FROM node:22-alpine AS build
 
# Set working directory
WORKDIR /app
 
# Install dependencies only for production (no devDependencies)
COPY package*.json ./
RUN npm install --only=production
 
# Copy the source code and build it
COPY . .
RUN npm run build  # Assume this compiles code into a "dist" directory
 
# Stage 2: Production Stage
FROM node:22-alpine
 
# Set environment variable
ENV NODE_ENV=production
ENV PORT=3000
 
# Set working directory
WORKDIR /app
 
# Copy only necessary files from the build stage
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
 
# Expose application port
EXPOSE 3000
 
# Run the application
CMD ["node", "dist/server.js"]

With this setup, the final image is optimized, containing only what’s needed for the application to run in production without unnecessary dependencies, build artifacts, or development tools. This results in a leaner, more efficient image ideal for deployment.

Conclusion

Containerizing Node.js applications with Docker can significantly streamline our development and deployment processes. By understanding the fundamentals of Docker and applying best practices in our Dockerfiles, we can create efficient, lightweight images that enhance performance and security. Multi-stage builds, in particular, are a game-changer, allowing us to separate our build environment from our runtime environment, resulting in smaller, cleaner images.

References


If you found this article helpful, please let me know on Twitter!