Author: Bjørnar Hagen
Date published: 2023-07-17T11:15:00Z
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:
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.