How to containerize Next.js app with docker build

2 August 2023

6 min read

Share
Author

Table of Contents

Introduction

Next.js is a React Framework which provide a robust solution to build production grade web applications. In the cloud-native era, there is a need to containerize the web app to deploy in kubernetes environments. There are 2 popular ways to build the container image for the app.

  1. With Docker Build
  2. With Cloud Native Buildpacks

In this session, we would perform simple steps to containerize the webapp with docker build. For building Next.js app image with buildpack, please have a look into another blog - How to containerize Next.js app with buildpacks.

Initial setup

We can build a Next.js based webapp with npx create-next-app@latest. We can extend the web app with more functionalities and API's. Then we can add the Dockerfile in the root folder for the app.

It's recommended to get the app resources for deployment separately for the webapp so that the app image size can be reduced to minimum, so we can use Next.js feature for generating standalone output.

next.config.js
module.exports = {
  output: 'standalone',
}

Above config will create a folder at .next/standalone which can then be deployed on its own without installing node_modules.

Prepare Dockerfile

The crude way to build container with Dockerfile would be to copy source-code to container created with Next.js builder image and build compile the app and expose the app for app with an endpoint.

There are many problems with above approach :

  1. Container image will be bulky, on average 1gb or above.
  2. It would expose security issues, as secure data as well as configs which needed only for build would be always available in container layers and can be exploited.
  3. It would have maintenance and upgrade issues, as the entire image layers would need to be replaced to upgrade and change code in the webapp.

To solve the issues, we can use multi-stage builds for creating app image.

For Next.js app, we would have 3 stages :

  1. Prepare env for Next.js app build
  2. Build Next.js app with npm run build
  3. Build runner image with standalone app

Stage-1: Build Next.js env

As part of this stage, we use the package.json to perform npm install and prepare the Next.js app env.

FROM node:20-alpine as deps
 
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

Stage-2: Perform app build

Using the image from previous stage, we can build the app image.

FROM node:20-alpine as builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
ENV NEXT_TELEMETRY_DISABLED 1
ARG PUBLIC_APP_NAME
ENV NEXT_PUBLIC_APP_NAME=$PUBLIC_APP_NAME
RUN npm run build

Stage-3: Create app runner image

Finally we build the app image with image from previous stage. We can secure this image with linux user having permission for app resources and expose on certain port.

FROM node:20-alpine as runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
 
RUN addgroup --system --gid 1001 nodegrp
RUN adduser --system --uid 1001 nodeuser
RUN mkdir -p -m 0755 /app/.next/cache
RUN chown nodeuser:nodegrp /app/.next/cache
 
# If you are using a custom next.config.js file, uncomment this line.
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
 
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nodegrp:nodeuser /app/.next/standalone ./
COPY --from=builder --chown=nodegrp:nodeuser /app/.next/static ./.next/static
 
USER nodeuser
 
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]

Final Dockerfile

With the above steps, we would get the final Dockerfile with 3 stages, and the final stage for the build would produce a slim version of app image. The usual app size is approximately 200mb.

FROM node:20-alpine as deps
 
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
# RUN npm install --frozen-lockfile
RUN npm ci
 
FROM node:20-alpine as builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
ENV NEXT_TELEMETRY_DISABLED 1
ARG PUBLIC_APP_NAME
ENV NEXT_PUBLIC_APP_NAME=$PUBLIC_APP_NAME
RUN npm run build
 
FROM node:20-alpine as runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
 
RUN addgroup --system --gid 1001 nodegrp
RUN adduser --system --uid 1001 nodeuser
RUN mkdir -p -m 0755 /app/.next/cache
RUN chown nodeuser:nodegrp /app/.next/cache
 
# If you are using a custom next.config.js file, uncomment this line.
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
 
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nodegrp:nodeuser /app/.next/standalone ./
COPY --from=builder --chown=nodegrp:nodeuser /app/.next/static ./.next/static
 
USER nodeuser
 
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]

Conclusion

We can use the dockerfile to perform docker build operation docker build -t myapp . to produce app image.

$ docker build -t myapp .
[+] Building 73.0s (26/26) FINISHED                                                                                                                                         docker:default
 => [internal] load build definition from Dockerfile                                                                                                                                  0.0s
 => => transferring dockerfile: 1.36kB                                                                                                                                                0.0s
 => [internal] load .dockerignore                                                                                                                                                     0.0s
 => => transferring context: 112B                                                                                                                                                     0.0s
 => [internal] load metadata for docker.io/library/node:20-alpine                                                                                                                     0.8s
 => [auth] library/node:pull token for registry-1.docker.io                                                                                                                           0.0s
 => [internal] load build context                                                                                                                                                     0.0s
 => => transferring context: 2.30kB                                                                                                                                                   0.0s
 => [runner  1/13] FROM docker.io/library/node:20-alpine@sha256:49f1c207f12f52e7dd4878e1c10a911c05ed7f534e6526b879ddc6dabed058f6                                                      0.0s
 => CACHED [deps 2/5] RUN apk add --no-cache libc6-compat                                                                                                                             0.0s
 => CACHED [deps 3/5] WORKDIR /app                                                                                                                                                    0.0s
 => [deps 4/5] COPY package.json package-lock.json ./                                                                                                                                 0.0s
 => [deps 5/5] RUN npm ci                                                                                                                                                            16.7s
 => CACHED [runner  2/13] WORKDIR /app                                                                                                                                                0.0s
 => CACHED [builder 3/5] COPY --from=deps /app/node_modules ./node_modules                                                                                                            0.0s
 => [builder 4/5] COPY . .                                                                                                                                                            0.1s
 => [builder 5/5] RUN npm run build                                                                                                                                                  49.7s
 => CACHED [runner  3/13] RUN addgroup --system --gid 1001 nodegrp                                                                                                                    0.0s
 => CACHED [runner  4/13] RUN adduser --system --uid 1001 nodeuser                                                                                                                    0.0s
 => CACHED [runner  5/13] RUN mkdir -p -m 0755 /app/.next/cache                                                                                                                       0.0s
 => CACHED [runner  6/13] RUN chown nodeuser:nodegrp /app/.next/cache                                                                                                                 0.0s
 => CACHED [runner  7/13] RUN mkdir -p -m 0755 /app/logs                                                                                                                              0.0s
 => CACHED [runner  8/13] RUN chown nodeuser:nodegrp /app/logs                                                                                                                        0.0s
 => CACHED [runner  9/13] COPY --from=builder /app/next.config.js ./                                                                                                                  0.0s
 => CACHED [runner 10/13] COPY --from=builder /app/public ./public                                                                                                                    0.0s
 => [runner 11/13] COPY --from=builder /app/package.json ./package.json                                                                                                               0.0s
 => [runner 12/13] COPY --from=builder --chown=nodegrp:nodeuser /app/.next/standalone ./                                                                                              0.4s
 => [runner 13/13] COPY --from=builder --chown=nodegrp:nodeuser /app/.next/static ./.next/static                                                                                      0.0s
 => exporting to image                                                                                                                                                                0.6s
 => => exporting layers                                                                                                                                                               0.6s
 => => writing image sha256:c77e9b41dd311953566189a37baea73950f24cab247ce1b4470f9bf2097c644a                                                                                          0.0s
 => => naming to docker.io/library/myapp 

As a result of the above build process, we can have the app image generated and verified with docker images. We can verify the built image with docker run.

$ docker images
REPOSITORY                         TAG              IMAGE ID       CREATED        SIZE
myapp                              latest           5c00ac7b4666   36 hours ago   226MB
 
$ docker run --rm  --name app -p 3000:3000 myapp
Listening on port 3000 url: http://2890c12b236e:3000
2023-08-02 11:50:13 [Env: production | hello-route] info: Request headers: {}

Hope this blog will help my friends to get the steps to build docker image for app in quick steps. 😊