Let’s go.
First, let’s think about the use cases to design the docker build:
In order to cover both of the use cases, we’ll use Docker’s multi-stage builds and split the build into 3 stages:
It’s fairly straightforward. All we need to do is to install npm dependencies and apply some of the best practices:
# 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.
# 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.
# 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"]
# 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"]
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
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.
This article was originally posted on Daw-Chih’s website.