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.
- With Docker Build
- 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.
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 :
- Container image will be bulky, on average 1gb or above.
- 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.
- 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 :
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 depsRUN apk add --no-cache libc6-compatWORKDIR /appCOPY 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 builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .ENV NEXT_TELEMETRY_DISABLED 1ARG PUBLIC_APP_NAMEENV NEXT_PUBLIC_APP_NAME=$PUBLIC_APP_NAMERUN 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 runnerWORKDIR /appENV NODE_ENV productionENV NEXT_TELEMETRY_DISABLED 1RUN addgroup --system --gid 1001 nodegrpRUN adduser --system --uid 1001 nodeuserRUN mkdir -p -m 0755 /app/.next/cacheRUN 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 ./publicCOPY --from=builder /app/package.json ./package.json# Automatically leverage output traces to reduce image sizeCOPY --from=builder --chown=nodegrp:nodeuser /app/.next/standalone ./COPY --from=builder --chown=nodegrp:nodeuser /app/.next/static ./.next/staticUSER nodeuserEXPOSE 3000ENV PORT 3000CMD ["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 depsRUN apk add --no-cache libc6-compatWORKDIR /appCOPY package.json package-lock.json ./# RUN npm install --frozen-lockfileRUN npm ciFROM node:20-alpine as builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .ENV NEXT_TELEMETRY_DISABLED 1ARG PUBLIC_APP_NAMEENV NEXT_PUBLIC_APP_NAME=$PUBLIC_APP_NAMERUN npm run buildFROM node:20-alpine as runnerWORKDIR /appENV NODE_ENV productionENV NEXT_TELEMETRY_DISABLED 1RUN addgroup --system --gid 1001 nodegrpRUN adduser --system --uid 1001 nodeuserRUN mkdir -p -m 0755 /app/.next/cacheRUN 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 ./publicCOPY --from=builder /app/package.json ./package.json# Automatically leverage output traces to reduce image sizeCOPY --from=builder --chown=nodegrp:nodeuser /app/.next/standalone ./COPY --from=builder --chown=nodegrp:nodeuser /app/.next/static ./.next/staticUSER nodeuserEXPOSE 3000ENV PORT 3000CMD ["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 imagesREPOSITORY TAG IMAGE ID CREATED SIZEmyapp latest 5c00ac7b4666 36 hours ago 226MB$ docker run --rm --name app -p 3000:3000 myappListening on port 3000 url: http://2890c12b236e:30002023-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. 🙂