How to run Next.js 13 in Docker

Author: Bjørnar Hagen

Date published: 2023-07-17T11:15:00Z

How to run Next.js 13 in Docker

I recently had to setup a Next.js 13 project with Docker, and I had some trouble finding a good solution. After a bit of work I have a good working solution and thought I’d share it.

The Dockerfiles are based on an example I found from the Next.js team. It didn’t work out of-the-box for me, due to some issues with standalone mode and the new app dir feature as well as some incorrect permissions, so I had to make some changes.

The container images should run fine on both Docker and Podman.

Development container

Update 2024-02-10: I’ve learnt quite a bit about Docker since I wrote this post and have made a dedicated post on how to best use Docker for local development

First of I want to run the project locally inside of Docker, that way I don’t have to deal with installing all the different dependencies on my machine.

Dockerfile.local:

 1FROM node:18-alpine AS base
 2
 3FROM base AS deps
 4# https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine
 5RUN apk add --no-cache libc6-compat
 6WORKDIR /app
 7
 8COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
 9RUN \
10  if [ -f yarn.lock ]; then yarn; \
11  elif [ -f package-lock.json ]; then npm install; \
12  elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \
13  else echo "Lockfile not found." && exit 1; \
14  fi
15
16FROM base AS dev
17WORKDIR /app
18COPY --from=deps /app/node_modules ./node_modules
19ENV NODE_ENV development
20ENV NEXT_TELEMETRY_DISABLED 1
21ENV PORT 3000
22EXPOSE 3000
23CMD ["bash"]

One thing I like to do with local development images is to mount the source code as a volume inside the container, so that changes to the source code are reflected live in the running container. To acheive this I use a script that builds and runs the Dockerfile.local.

run.sh:

 1#!/bin/bash
 2
 3IMAGE_NAME="your-image-name-here"
 4TAG_NAME="latest"
 5CONTAINER_INSTANCE_NAME="instance-name-here"
 6COMMAND_TO_RUN_ON_CONTAINER="yarn dev"
 7
 8command_exists() {
 9    type "$1" &>/dev/null
10}
11
12if command_exists podman; then
13    CONTAINER_TOOL=podman
14elif command_exists docker; then
15    CONTAINER_TOOL=docker
16else
17    echo "Could not find podman or docker. Please install one of them and try again."
18    exit 1
19fi
20
21echo "Using $CONTAINER_TOOL: $(command -v $CONTAINER_TOOL)"
22$CONTAINER_TOOL build --pull --rm -f "Dockerfile.local" -t $IMAGE_NAME:$TAG_NAME "."
23$CONTAINER_TOOL run --rm -it -v $(pwd):/app -p 3000:3000 --name instance-name-here $IMAGE_NAME:$TAG_NAME $COMMAND_TO_RUN_ON_CONTAINER

This script builds and runs the Dockerfile.local. It uses Podman if available, otherwise falls back to Docker. ./ is mounted as a volume on /app inside the container, so that changes to the source code are reflected live in the container.

Running this your application should be available on http://localhost:3000/.

You can use the instance name to connect to the container, like so:

1docker exec -it instance-name-here /bin/sh

When connected you can run commands with npm/yarn/pnpm, this is great when you want to install new dependencies.

Production container

With the local development environment out of the way, let’s setup a production ready image.

First I want to use standalone mode. This allows us to run the built app without having to copy all of node_modules into the image, greatly reducing the size of the image.

next.config.js:

1const nextConfig = {
2  output: 'standalone',
3}
4
5module.exports = nextConfig

Dockerfile:

 1FROM node:18-alpine AS base
 2
 3# Install dependencies only when needed
 4FROM base AS deps
 5# https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine
 6RUN apk add --no-cache libc6-compat
 7WORKDIR /app
 8
 9# Install dependencies based on the preferred package manager
10COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
11RUN \
12  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
13  elif [ -f package-lock.json ]; then npm ci; \
14  elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
15  else echo "Lockfile not found." && exit 1; \
16  fi
17
18# Rebuild the source code only when needed
19FROM base AS builder
20WORKDIR /app
21COPY --from=deps /app/node_modules ./node_modules
22COPY . .
23ENV NEXT_TELEMETRY_DISABLED 1
24RUN yarn lint
25RUN yarn build
26
27# Production image, copy all the files and run node
28FROM base AS runner
29WORKDIR /app
30ENV NODE_ENV production
31ENV NEXT_TELEMETRY_DISABLED 1
32RUN chown node:node .
33COPY --from=builder /app/public ./public
34COPY --from=builder --chown=node:node /app/.next/standalone ./
35COPY --from=builder --chown=node:node /app/.next/static ./.next/static
36USER node
37EXPOSE 3000
38ENV PORT 3000
39CMD ["node", "server.js"]

The only thing I don’t like about this is that the .env is made part of the image. I think that’s somewhat of a security risk. I prefer to inject the .env at runtime, but Next.js needs it at build time, so I haven’t found a good solution for this yet.