Delivering a Node application as a Docker image is easy, and it works straight away. Most likely because of this simplicity many don’t even know it is done incorrectly. In this article, I am going to explain how to build a Docker image for a Node application and how multi-stage builds can help in this.

Good Will Hunting Movie

I will create a node application to experiment with. It doesn’t matter what it is, I just want to be able to build and run it. You can follow me and do the same, just run the following command and select defaults.

npx @nestjs/cli new docker-demo

This will generate a Node application based on the NestJS framework. It would have a single rest endpoint that returns “Hello World” on it’s root path.

Now I will create a Docker image in a straightforward way:

FROM node:18.8.0-alpine3.16
WORKDIR /app
COPY package*.json ./
COPY tsconfig*.json ./
COPY src src
RUN  npm ci && npm run build
ENTRYPOINT ["npm", "run", "start:prod"]

This is more or less what a Dockerfile looks like in many projects. It works but it is not optimal. The problem here is that the project gets built and runs in the same environment. The building phase requires the TypeScript compiler and a bunch of other dependencies. These dependencies are not needed in runtime.

In order to optimize this, I will split building and running phases into separate docker build stages. This way, once the application is built, it will preserve only the built artifacts and install only production dependencies. Let’s look at the resulting Dockerfile.

FROM node:18.8.0-alpine3.16 as builder
WORKDIR /app
COPY package*.json ./
COPY tsconfig*.json ./
COPY src src
RUN  npm ci && npm run build

FROM node:18.8.0-alpine3.16
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY --from=builder /app/dist/ dist/
ENTRYPOINT ["npm", "run", "start:prod"]

The building stage here is separated from the running stage. In the line COPY --from=builder /app/dist/ dist/, I only take the dist folder from the builder image. It is much smaller because I install only production dependencies in the resulting image. Let’s compare the difference.

Run docker system df -v to see the detailed information

Output of 'docker system df -v'

The size of the unoptimized image is almost 200Mb larger. If you take a look at the unique size property, the difference is even more notable. The footprint of the unoptimized image is at least three times larger. Unique size is the amount of space we add on top of the base image (in my case node:18.8.0-alpine3.16). Note, this is only a hello world application. In real-world applications, the difference might be slightly larger.

The reduced image size is not the only benefit. Unused node modules that are placed into a Docker image, are a potential security risk. Malicious code might execute some of this code, or it might have vulnerabilities on its own.

It doesn’t matter what kind of application you are building, always be sure you are including only the needed dependencies and generally follow best practices for writing Dockerfiles.