In the previous guide, we explored how to create an optimized Dockerfile for your NestJS production deployment, showing how we could shave down the image to 1/3rd of its original size.
Today, we'll create a new Dockerfile, purely for development purposes, and find out how to launch it using Docker Compose for our local development environment!
# Why Docker Compose?
First off, why even bother with Docker Compose? Why not just run something like npm run start:dev
and call it a day?
Well, for 2 reasons.
- If you're deploying to production using Docker, you want your development environment to mimic the production one as closely as possible, to avoid any operating system specific bugs.
- Managing multi-container Docker applications is a lot easier with Docker Compose. For example, if you have a NestJS API, a React application, a database, and Redis all under one hood makes it a lot easier (and safer) to integrate those systems locally.
# Prerequisites
- A NestJS app. Follow this guide if you don't already have one!
- Docker engine installed on your system. I personally use OrbStack for this.
# Previously... ⏮️
Our optimized production Dockerfile for NestJS looks a little like this:
dockerfileFROM node:18-alpine AS deps WORKDIR /app # Copy only the files needed to install dependencies COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ # Install dependencies with the preferred package manager RUN \ if [ -f package-lock.json ]; then npm ci; \ elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ else echo "Lockfile not found." && exit 1; \ fi FROM node:18-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules # Copy the rest of the files COPY . . # Run build with the preferred package manager RUN \ if [ -f package-lock.json ]; then npm run build; \ elif [ -f yarn.lock ]; then yarn build; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm build; \ else echo "Lockfile not found." && exit 1; \ fi # Set NODE_ENV environment variable ENV NODE_ENV production # Re-run install only for production dependencies RUN \ if [ -f package-lock.json ]; then npm ci --only=production && npm cache clean --force; \ elif [ -f yarn.lock ]; then yarn --frozen-lockfile --production && yarn cache clean; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile --prod; \ else echo "Lockfile not found." && exit 1; \ fi FROM node:18-alpine AS runner WORKDIR /app # Copy the bundled code from the builder stage COPY --from=builder --chown=node:node /app/dist ./dist COPY --from=builder --chown=node:node /app/node_modules ./node_modules # Use the node user from the image USER node # Start the server CMD ["node", "dist/main.js"]
As we previously talked about, this uses a multi-stage build approach to only copy over the necessary files at every step. This takes a tiny bit longer to build, but results in a much smaller image, making it ideal for deploying to production!
# Great, but what about local development? 👨💻
Well, we don't need all these bells and whistles, that's for sure. Something like this will do just fine:
dockerfileFROM node:18-alpine WORKDIR /app # Copy only the files needed to install dependencies COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ # Install dependencies with the preferred package manager RUN \ if [ -f package-lock.json ]; then npm ci; \ elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ else echo "Lockfile not found." && exit 1; \ fi # Copy the rest of the files COPY . . # Run development build with the preferred package manager CMD \ if [ -f package-lock.json ]; then npm run start:dev; \ elif [ -f yarn.lock ]; then yarn start:dev; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm start:dev; \ else echo "Lockfile not found." && exit 1; \ fi
Let's call this file Dockerfile.dev
and put it in our project root. Similar to our production Dockerfile, this will work regardless of which package manager you use (npm, yarn, or pnpm).
The difference here is that after installing dependencies, instead of building the app and copying over files, we'll simply use one stage, and just run the start:dev
command from our npm scripts.
Finally, let's also add our Docker Compose file:
yamlversion: '3.8' services: nestjs-api: container_name: nestjs-api restart: unless-stopped build: context: . dockerfile: Dockerfile.dev volumes: - .:/app - /app/dist - /app/node_modules ports: - 3000:3000 env_file: - ./.env
Here, we're doing a few things:
- We create a new container called
nestjs-api
. - We tell Docker Compose to automatically restart our container (e.g. during a crash) unless explicitly stopped.
- We specify which Dockerfile we'll be using for building and what the context (project root) of the container is.
- We mount the current directory as
/app
in the container, but we exclude ourdist
andnode_modules
folders from being mounted. This is because we want to Docker to be fully independent from our system and not use any pre-compiled files or our own operating system's dependencies. - We tell Docker to map the port 3000 on the host to port 3000 in the container, thus exposing our application to http://localhost:3000.
- We tell Docker to grab our
.env
file and make all environment variables we have set up there available to our container.
# Fire it up! 🔥
Open your terminal and navigate to your project root. Run this command:
bashdocker compose --env-file .env up --build
And voilà, your NestJS app is running in a Docker container! Open your web browser and go to http://localhost:3000 to see it in action. 🚢
# Wrapping Up
So there you have it! You've successfully added Docker Compose to your NestJS API 🎉 In the next few guides in this series, we'll deploy our dockerized NestJS API, explore authentication options such as Social Login, and how to integrate our NestJS API in a Turborepo configuration.
Until next time — happy coding!