NestJS with Docker Compose for Development

Integrating Docker Compose with NestJS for easy and secure local development, compatible with npm, yarn, and pnpm

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.

  1. 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.
  2. 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:

dockerfile
FROM 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:

dockerfile
FROM 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:

yaml
version: '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 our dist and node_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:

bash
docker 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!

Richard Solomou

Richard Solomou

Full-Stack Engineer 👨‍💻, proud dad 👶, and coffee junkie ☕️