paint-brush
The Ultimate NestJS Dockfile for Optimized Production Image and Local Developmentby@dawchihliou
215 reads

The Ultimate NestJS Dockfile for Optimized Production Image and Local Development

by Daw-Chih LiouApril 27th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

🚢 We’ll write a docker multi-stage build together. 🎉 We’ll discover how to use the docker file in development and production. ✨ We’ll apply some of the best practices recommended by the Docker team.
featured image - The Ultimate NestJS Dockfile for Optimized Production Image and Local Development
Daw-Chih Liou HackerNoon profile picture

In this article

  • 🚢 We’ll write a docker multi-stage build together.
  • 🎉 We’ll discover how to use the docker file in development and production.
  • ✨ We’ll apply some of the best practices recommended by the Docker team.


Let’s go.


First, let’s think about the use cases to design the docker build:


  • To install all the dependencies to support the local dev server.
  • To run a production server with an optimized bundle.


In order to cover both of the use cases, we’ll use Docker’s multi-stage builds and split the build into 3 stages:


Docker stages


  • dev: to simply install all the dependencies.
  • build: to compile a production build with optimized bundle size.
  • prod: to serve the production build.


The Docker Multi-stage Build

The “dev” Stage

It’s fairly straightforward. All we need to do is to install npm dependencies and apply some of the best practices:


  • We’ll use “node:alpine” as the based image to produce minimal image size.
  • We’ll install missing shared libraries from node:alpine.
  • We’ll assign a non-root user to Docker to limit its privileges
  • We’ll set the environment to “development”.
  • We’ll install the dependencies based on yarn lock file to achieve consistent installation across machines.


# Dockerfile

#
# 🧑‍💻 Development
#
FROM node:18-alpine as dev
# add the missing shared libraries from alpine base image
RUN apk add --no-cache libc6-compat
# Create app folder
WORKDIR /app

# Set to dev environment
ENV NODE_ENV development

# Create non-root user for Docker
RUN addgroup --system --gid 1001 node
RUN adduser --system --uid 1001 node

# Copy source code into app folder
COPY --chown=node:node . .

# Install dependencies
RUN yarn --frozen-lockfile

# Set Docker as a non-root user
USER node


The “build” Stage


The build stage is the most interesting stage. The purpose is to compile the source code and generate the least amount of assets.


  • We’ll use the same base image and best practices as the dev stage.
  • We’ll set the environment to “production”.
  • We’ll copy the dependencies we installed in the dev stage to run the Nest CLI build script.
  • Once the build is completed, we’ll install the production-only dependencies and clean the yarn cache. This will minimize the bundle size.
# Dockerfile

#
# 🏡 Production Build
#
FROM node:18-alpine as build

WORKDIR /app
RUN apk add --no-cache libc6-compat

# Set to production environment
ENV NODE_ENV production

# Re-create non-root user for Docker
RUN addgroup --system --gid 1001 node
RUN adduser --system --uid 1001 node

# In order to run `yarn build` we need access to the Nest CLI.
# Nest CLI is a dev dependency.
COPY --chown=node:node --from=dev /app/node_modules ./node_modules
# Copy source code
COPY --chown=node:node . .

# Generate the production build. The build script runs "nest build" to compile the application.
RUN yarn build

# Install only the production dependencies and clean cache to optimize image size.
RUN yarn --frozen-lockfile --production && yarn cache clean

# Set Docker as a non-root user
USER node


The “prod” Stage


Now that we have the optimized production build, we can complete the docker build by serving the NestJS server.


  • We’ll use the same base image and best practices as the build stage.
  • We’ll copy the compiled output and the production-only node_modules from the build stage. These are the necessary files we need to run the server.
  • Finally, start the server with Node. “nest start” command is not available at this stage because the Nest CLI is a dev dependency.


# Dockerfile

#
# 🚀 Production Server
#
FROM node:18-alpine as prod

WORKDIR /app
RUN apk add --no-cache libc6-compat

# Set to production environment
ENV NODE_ENV production

# Re-create non-root user for Docker
RUN addgroup --system --gid 1001 node
RUN adduser --system --uid 1001 node

# Copy only the necessary files
COPY --chown=node:node --from=build /app/dist dist
COPY --chown=node:node --from=build /app/node_modules node_modules

# Set Docker as non-root user
USER node

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


The Complete Multi-stage Dockerfile

# Dockerfile

#
# 🧑‍💻 Development
#
FROM node:18-alpine as dev
# add the missing shared libraries from alpine base image
RUN apk add --no-cache libc6-compat
# Create app folder
WORKDIR /app

# Set to dev environment
ENV NODE_ENV dev

# Create non-root user for Docker
RUN addgroup --system --gid 1001 node
RUN adduser --system --uid 1001 node

# Copy source code into app folder
COPY --chown=node:node . .

# Install dependencies
RUN yarn --frozen-lockfile

# Set Docker as a non-root user
USER node

#
# 🏡 Production Build
#
FROM node:18-alpine as build

WORKDIR /app
RUN apk add --no-cache libc6-compat

# Set to production environment
ENV NODE_ENV production

# Re-create non-root user for Docker
RUN addgroup --system --gid 1001 node
RUN adduser --system --uid 1001 node

# In order to run `yarn build` we need access to the Nest CLI.
# Nest CLI is a dev dependency.
COPY --chown=node:node --from=dev /app/node_modules ./node_modules
# Copy source code
COPY --chown=node:node . .

# Generate the production build. The build script runs "nest build" to compile the application.
RUN yarn build

# Install only the production dependencies and clean cache to optimize image size.
RUN yarn --frozen-lockfile --production && yarn cache clean

# Set Docker as a non-root user
USER node

#
# 🚀 Production Server
#
FROM node:18-alpine as prod

WORKDIR /app
RUN apk add --no-cache libc6-compat

# Set to production environment
ENV NODE_ENV production

# Re-create non-root user for Docker
RUN addgroup --system --gid 1001 node
RUN adduser --system --uid 1001 node

# Copy only the necessary files
COPY --chown=node:node --from=build /app/dist dist
COPY --chown=node:node --from=build /app/node_modules node_modules

# Set Docker as non-root user
USER node

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


How to Use It in Local Development

We are done with the hard part!


To use the docker file for local development, all we need to do is to build the docker image with the dev stage. For example, we can build and run the NestJS server with watch mode in a docker compose file:


# docker-compose.yml

services:
  api:
    container_name: nestjs
    image: nestjs-dev
    restart: unless-stopped
    build:
      context: .
      dockerfile: Dockerfile
      # ✨ Target the dev stage
      target: dev
    # Mount host directory to docker container to support watch mode
    volumes:
      - .:/app
      # This ensures that the NestJS container manages the node_modules folder
      # rather than synchronizes it with the host machine
      - /app/node_modules
    env_file:
      - docker.env
    ports:
      - 8082:8082
    networks:
      - nest
    depends_on:
      - postgres
    command: npx nest start --watch

  # ...other services


How to Use It in Production

You can simply build the production image by building all 3 stages. For example, we can run “docker build” in GitHub Actions as part of the deployment workflow.


# .github/workflows/ci.yml

jobs
  deploy:
    steps:
      - uses: actions/checkout@v3
 
      - uses: actions/setup-node@v3
        with:
          node-version: '18.x'

      - name: Build NestJS image
        env:
          REGISTRY: nestjs-regiestry
          REPOSITORY: nestjs-example
          IMAGE_TAG: example
        # ✨ target the production stage
        run: docker build . -t $REGISTRY/$REPOSITORY:$IMAGE_TAG --target prod

      # ...rest of the steps



Since the “prod” is the last stage, we don’t need to specify the target for the build command. Running

docker build . -t $REGISTRY/$REPOSITORY:$IMAGE_TAG

will build the same image.

References


This article was originally posted on Daw-Chih’s website.