Tom RayTom Ray
Published on
Last updated:

How to write a NestJS Dockerfile optimized for production

How to build a NestJS Docker image for production

This is a step-by-step tutorial on how to write a Dockerfile for a NestJS project that creates a production optimized image.

With this Dockerfile you are then setup for local development as well as containerized deployment, for example, with Cloud Run.

Ready? Let's dive in.

P.S. if you want to just copy and paste the production ready Dockerfile, just skip to this section.

NestJS logo
Free NestJS CourseWant to use NestJS to it's full potential and understand how it really works? Check out my free course which covers concepts like Dependency Injection, IoC Containers and more:
Table of Contents

Writing the Dockerfile

A container image is an isolated package of software that includes everything you need to run the code. You can define container images by writing a Dockerfile which provides the instructions on how to build the image.

Let's add the Dockerfile now:

touch Dockerfile

And then let's add the instructions to the Dockerfile. See the comments which explain each step:

Dockerfile
# Base image
FROM node:18

# Create app directory
WORKDIR /usr/src/app

# A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package*.json ./

# Install app dependencies
RUN npm install

# Bundle app source
COPY . .

# Creates a "dist" folder with the production build
RUN npm run build

# Start the server using the production build
CMD [ "node", "dist/main.js" ]

Similar to a .gitignore file, we can add a .dockerignore file which will prevent certain files from being included in the image build.

touch .dockerignore

Then exclude the following files from the image build:

.dockerignore
Dockerfile
.dockerignore
node_modules
npm-debug.log
dist

Test the container locally

Let's now do some testing locally to check if the Dockerfile behaves as we expect.

Let's first build the image using the command in your terminal at the root of your project (you can swap out nest-cloud-run with your project name). Don't forget the .!

docker build -t nest-cloud-run .

You can verify the image has been created by running docker images which will output a list of Docker images you have on your local machine:

docker images
REPOSITORY                   TAG       IMAGE ID       CREATED          SIZE
nest-cloud-run               latest    004f7f222139   31 seconds ago   1.24GB

Let's now start the container and run the image with this command (be sure to same image name used above):

docker run -p80:3000 nest-cloud-run

You can now access the NestJS app by visiting http://localhost in your browser (just http://localhost without any port numbers).

I ran into a couple of issues on my machine when running the container, mainly due to with conflicts with ports from other containers I had running.

If you run into similar trouble, you can try running the command docker rm -f $(docker ps -aq) which stops and removes all running containers.

Optimize Dockerfile for production

Now that we've confirmed the image is working locally, let's try to reduce the size of the image and make it more efficient for production. We'll also want to ensure the image is as secure as possible.

Deployment tools like Cloud Run factor in the size of the image when calculating how much to charge you, so it's a good idea to keep the image size as small as possible.

Running the command docker images gives us our image size:

docker images
REPOSITORY                   TAG       IMAGE ID       CREATED          SIZE
nest-cloud-run               latest    004f7f222139   31 seconds ago   1.24GB

1.24GB is pretty big! Let's dive back into our Dockerfile and make some optimizations.

Use Alpine node images

It's recommended to use the Alpine node images when trying to optimize for image size. Using node:18-alpine instead of node:18 by itself reduces the image size from 1.24GB to 466MB!

Add a NODE_ENV environment variable

Many libraries have optimizations built in when the NODE_ENV environment variable is set to production, so we can set this environment variable in the Dockerfile build by adding the following line to our Dockerfile:

ENV NODE_ENV production

On a side note, check out this tutorial if you're interested in how to use environment variables with configuration files in NestJS.

Use npm ci instead of npm install

npm recommendeds using npm ci instead of npm install when building your image. Here's a quote taken from their website on the reason why:

"npm ci is similar to npm install, except it's meant to be used in automated environments such as test platforms, continuous integration, and deployment -- or any situation where you want to make sure you're doing a clean install of your dependencies."

This fits perfectly with what we're doing, so we'll use npm ci instead of npm install in our Dockerfile.

RUN npm ci

The USER instruction

By default, if you don't specify a USER instruction in your Dockerfile, the image will run using the root permissions. This is a security risk, so we'll add a USER instruction to our Dockerfile.

The node image we're using already has a user created for us called node, so let's use that:

USER node

Whenever you use the COPY instruction, it's also good practice to add a flag to ensure the user has the correct permissions.

You can achieve this by using --chown=node:node whenever you use the COPY instruction, for example:

COPY --chown=node:node package*.json ./

Use multistage builds

In your Dockerfile you can define multistage builds which is a way to sequentially build the most optimized image by building multiple images.

Outside of using a small image, multistage builds is where the biggest optimizations can be made.

Dockerfile
###################
# BUILD FOR LOCAL DEVELOPMENT
###################

FROM node:18-alpine As development

# ... your development build instructions here

###################
# BUILD FOR PRODUCTION
###################

# Base image for production
FROM node:18-alpine As build

# ... your build instructions here

###################
# PRODUCTION
###################

# Base image for production
FROM node:18-alpine As production

# ... your production instructions here

This multistage build uses 3 stages:

  1. development - This is the stage where we build the image for local development.
  2. build - This is the stage where we build the image for production.
  3. production - We copy over the relevant production build files and start the server.

If you're not interested in using Docker locally to run your NestJS app, you could combine step 1 and step 2 into a single stage.

However, what's nice about the above multi-stage setup is you then have a single Dockerfile that can be used in local development (in combination with a docker-compose.yml file) AND also creates a Docker image optimized for production.

If you're interested in using this multistage Dockerfile with Docker Compose for local development (with hot reloading), check out this post.

Putting it all together

Using all the techniques described above, here is the Dockerfile we'll use to build our production optimized image:

Dockerfile
###################
# BUILD FOR LOCAL DEVELOPMENT
###################

FROM node:18-alpine As development

# Create app directory
WORKDIR /usr/src/app

# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY --chown=node:node package*.json ./

# Install app dependencies using the `npm ci` command instead of `npm install`
RUN npm ci

# Bundle app source
COPY --chown=node:node . .

# Use the node user from the image (instead of the root user)
USER node

###################
# BUILD FOR PRODUCTION
###################

FROM node:18-alpine As build

WORKDIR /usr/src/app

COPY --chown=node:node package*.json ./

# In order to run `npm run build` we need access to the Nest CLI which is a dev dependency. In the previous development stage we ran `npm ci` which installed all dependencies, so we can copy over the node_modules directory from the development image
COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules

COPY --chown=node:node . .

# Run the build command which creates the production bundle
RUN npm run build

# Set NODE_ENV environment variable
ENV NODE_ENV production

# Running `npm ci` removes the existing node_modules directory and passing in --only=production ensures that only the production dependencies are installed. This ensures that the node_modules directory is as optimized as possible
RUN npm ci --only=production && npm cache clean --force

USER node

###################
# PRODUCTION
###################

FROM node:18-alpine As production

# Copy the bundled code from the build stage to the production image
COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=build /usr/src/app/dist ./dist

# Start the server using the production build
CMD [ "node", "dist/main.js" ]

Once you've updated your Dockerfile, you'll need to re-run the commands to build your image:

docker build -t nest-cloud-run .

And then the command to spin up your container:

docker run -p80:3000 nest-cloud-run

If you run docker images again to check our image size, you'll see it's now signifantly smaller:

docker images
REPOSITORY                   TAG       IMAGE ID       CREATED          SIZE
nest-cloud-run               latest    004f7f222139   31 seconds ago   189MB
NestJS logo
Free NestJS CourseWant to use NestJS to it's full potential and understand how it really works? Check out my free course which covers concepts like Dependency Injection, IoC Containers and more:

Troubleshooting

You might come into the following errors:

Error: Cannot find module 'webpack'

It's likely you're using the wrong node version in your base image if you're getting errors like the following:

  • Error: Cannot find module 'webpack'

For example, instead of using FROM node:14-alpine, use FROM node:18-alpine to solve this issue.

Error: nest command not found

When you run npm run build, it uses the Nest CLI to generate the build files.

The Nest CLI is a dev dependency, so if you're getting the error nest command not found, you need to either:

  • (Recommended option): Run npm run build in a multi-stage Dockerfile setup where you've installed both production and develpoment dependencies (with npm ci)
  • Update your package.json file to include the Nest CLI package in your production dependencies. The only downside of this approach as it increases the size of your node_modules resulting in a larger image

The recommended option is what is implemented in the Dockerfile mentioned above in this tutorial if you want an example of how that works.

Dockerfile with pnpm package manager

If you're using pnpm as the package manager in your NestJS project, the Dockerfile will need to be as follows:

Dockerfile
###################
# BUILD FOR LOCAL DEVELOPMENT
###################

FROM node:18 As development
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm

WORKDIR /usr/src/app

COPY --chown=node:node pnpm-lock.yaml ./

RUN pnpm fetch --prod

COPY --chown=node:node . .
RUN pnpm install

USER node

###################
# BUILD FOR PRODUCTION
###################

FROM node:18 As build
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm

WORKDIR /usr/src/app

COPY --chown=node:node pnpm-lock.yaml ./

COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules

COPY --chown=node:node . .

RUN pnpm build

ENV NODE_ENV production

RUN pnpm install --prod

USER node

###################
# PRODUCTION
###################

FROM node:18-alpine As production

COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=build /usr/src/app/dist ./dist

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

NestJS Dockerfile with Fastify

If you're using Fastify as your server in NestJS instead of the default express server, you'll need to modify the server to listed on 0.0.0.0.

For example, here's how you would edit the bootstrap() function in the main.ts file:

main.ts
import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  await app.listen(process.env.PORT || 3000, '0.0.0.0');
}
bootstrap();

This is noted inside the Fastify docs if you'd like to read more about this.

Conclusion

In summary, here is our production optimized Docker image for a NestJS project (without all the explainer comments):

Dockerfile
###################
# BUILD FOR LOCAL DEVELOPMENT
###################

FROM node:18-alpine As development

WORKDIR /usr/src/app

COPY --chown=node:node package*.json ./

RUN npm ci

COPY --chown=node:node . .

USER node

###################
# BUILD FOR PRODUCTION
###################

FROM node:18-alpine As build

WORKDIR /usr/src/app

COPY --chown=node:node package*.json ./

COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules

COPY --chown=node:node . .

RUN npm run build

ENV NODE_ENV production

RUN npm ci --only=production && npm cache clean --force

USER node

###################
# PRODUCTION
###################

FROM node:18-alpine As production

COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=build /usr/src/app/dist ./dist

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

Here's a couple of extra resources related to deploying to production that might be helpful:

Do you have any further optimizations you can make to the above Docker image? Drop them in the comments below!

NestJS logo
Free NestJS CourseWant to use NestJS to it's full potential and understand how it really works? Check out my free course which covers concepts like Dependency Injection, IoC Containers and more: