Containerizing a Node.js application is easy, but doing it well is another story. To create images that are efficient, secure, and consistent across environments, you need to make the right choices in your build strategy, dependency management, and base image selection. In this article, I’ll walk you through practical tips to containerize your Node.js applications properly.

Multi-stage build or not ?

Multi-stage builds are mainly useful when the projects require a build step to produce the final JavaScript files, such as with TypeScript. Generally, when the package.json contains a build scripts, it’s a good idea to use a multi-stage build.

The advantage is that the final image only contains the necessary files to run the application, which reduces the image size and improves security by excluding build tools and dependencies.

# -------- Build --------
FROM node:24-slim AS builder
WORKDIR /app

COPY . .
RUN npm ci

RUN npm run build

# -------- Runtime --------
FROM node:24-slim
WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev

COPY --from=builder /app/dist .

USER node
EXPOSE 80
CMD ["node", "index.js"]

The main idea behind multi-stage build is to separate the containers for build and execution.


The build stage contains the source code and its own dependencies and tools, while the runtime stage only includes what is required to run the application. During the complete build process, we copy only the necessary files from the build stage to the runtime stage.

However, if your project is pure JavaScript without any build step, a single-stage build is sufficient and simpler.

Run the same packages in development and production

Your developers are the first users of your application. To avoid the ‘it works on my machine’ problem, it is important to ensure consistency between development and production environments.

That means using the same package versions in both environments.

To achieve this, use the command ‘npm ci’ instead of ‘npm install’ in your Dockerfile.

The ‘npm ci’ command installs the exact versions of the packages specified in the package-lock.json file, ensuring that both development and production environments use the same dependencies.

Note: add the option `–omit=dev` in runtime build to avoid installing dev dependencies.

Carefully choose the base image

The base image is the foundation of your Docker image. Choosing the right base image can have a significant impact on the size, security, and performance of your application.

First, choosing a Node.js official image is a good practice. The most important is to select the right variant of the image. Image *-alpine is a popular choice because of its small size, but you must understand the implications

Alpine images use musl as C library instead of glibc, commonly, it’s not a problem for pure JavaScript applications. However, if your application relies on native modules or certain npm packages that expect glibc, you may encounter compatibility issues.

Another alternative is to use slim images, which are based on Debian with glibc. They are larger than alpine images but provide better compatibility with native modules.

When to choose a slim instead of alpine?

When your application depends on native modules or npm packages that require glibc, it’s safer to use a slim image to avoid compatibility issues. With the popularity of alpine images, the ecosystem has improved its support for musl, but some exotic packages may still cause incompatibility issues (or may require to compile binaries from sources).

Be aware of this when you choose your base image.

Conclusion

Containerizing a Node.js application requires careful consideration of build strategies, dependency management, and base image selection. All these choices depend on your application requirements. Therefore, evaluating your needs and choosing the best approach for your specific use case is the key to containerizing your application effectively.