Next.js with Docker Compose for Development

Integrating Docker Compose with Next.js 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 Next.js production deployment, showing how we could shave down the image to 1/5th 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 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 Next.js app, an API, a database, and Redis all under one hood makes it a lot easier (and safer) to integrate those systems locally.

# Prerequisites

  • A Next.js 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 Next.js looks a little like this:

dockerfile
# Dockerfile.prod

FROM node:18-alpine AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then npm i -g pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN \
  if [ -f yarn.lock ]; then yarn build; \
  elif [ -f package-lock.json ]; then npm run build; \
  elif [ -f pnpm-lock.yaml ]; then npm i -g pnpm && pnpm build; \
  else echo "Lockfile not found." && exit 1; \
  fi


# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"

CMD ["node", "server.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
# Dockerfile.dev

FROM node:18-alpine

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

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then npm i -g pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi

COPY . .

CMD \
  if [ -f yarn.lock ]; then yarn dev; \
  elif [ -f package-lock.json ]; then npm run dev; \
  elif [ -f pnpm-lock.yaml ]; then pnpm dev; \
  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 dev command from our npm scripts.

Finally, let's also add our Docker Compose file:

yaml
# docker-compose.yaml

version: '3.8'

services:
  nextjs-app:
    container_name: nextjs-app
    restart: unless-stopped
    build:
      context: .
      dockerfile: Dockerfile.dev
    volumes:
      - .:/app
      - /app/.next
      - /app/node_modules
    ports:
      - 3000:3000
    env_file:
      - ./.env

Here, we're doing a few things:

  • We create a new container called nextjs-app.
  • 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 .next 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 Next.js 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 Next.js app 🎉 In the next few guides in this series, we'll deploy our dockerized Next.js application to a DigitalOcean Droplet, as well as see what it's like deploying it straight to DigitalOcean App Platform.

Until next time — happy coding!

Richard Solomou

Richard Solomou

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