Node.js 20, Yarn 4, and Next.js on Docker

I recently spent time updating a few Swarzy websites including Psalmlist and Swarzy.com. (Yes, this is how I spent my time off for Veteran’s Day Observed.) I’ve always found that having and working on other websites keeps me fresh and up-to-date on the latest technologies when I’m using them at work.

As part of tech maintenance for a website, it’s important to keep your dependencies updated. And so I embarked on updating my stack to the latest versions of key dependencies. Here’s what I’m using right now:

  • Node.js 20.x
  • Yarn 4.x
  • Next.js 14.x

I’ve found that to get an optimal size for the built Docker container image, it’s helpful to use Next.js output in “standalone” mode.

Next.js output in Standalone mode

To get an optimal build size, I’ve discovered that setting the output to standalone is very helpful. Next.js analyzes the static build output and only includes the Node.js module dependencies that your application actually uses. This helps avoid the need to do a yarn install using only the production dependencies and ends up simplifying the Dockerfile.

// next.config.js
// known to work with Next 14.0.2, this should work with some versions of Next 13.x)
const NODE_ENV = process.env.NODE_ENV;

/** @type {import('next').NextConfig} */
module.exports = {
  output: "standalone",

  // force pages router into strict mode
  // this isn't needed if you're using app router
  reactStrictMode: true,

  // helpful if you're using SVGs
  webpack: (config) => {
    config.module.rules.push({
      test: /\.svg$/,
      use: ["@svgr/webpack"],
    });

    // source maps configuration for the production build (so we can upload to Sentry or a similar tool)
    if (NODE_ENV === "production") {
      // `config.devtool` must be 'hidden-source-map' or 'source-map' to properly pass sourcemaps.
      // https://github.com/vercel/next.js/blob/89ec21ed686dd79a5770b5c669abaff8f55d8fef/packages/next/build/webpack/config/blocks/base.ts#L40
      config.devtool = "hidden-source-map";
    }

    return config;
  },
};

Two-Stage Dockerfile for Node.js 20, Yarn 4, and Next.js 14

It has been a journey of discovery finding a pattern for a Dockerfile that plays well with Yarn 4 and works correctly across a multi-stage build. Very few examples of Yarn 2 or 3 exist on the web, and Yarn 4 is so new that this may be one of the first blog posts with an example of how to do it.

For the strategy, what I’m doing is starting the Dockerfile from a good Node.js image: node:20-alpine and creating a base that I can use for the subsequent stages. I have a second stage I call builder (following other examples online) and the final stage called runner.

In the base, I set a few shared environment variables including the YARN_VERSION, one for disabling Next.js telemetry, and ensuring that the build and runtime are set up for production (not dev). The base image also includes a few apk dependencies for the Alpine operating system.

To get Yarn working and available across stages, I enable corepack and set it to Yarn 4 as part of the base.

The builder stagecopies the files that are needed from the git repo and executes the build. Because we’re using standalone, we don’t need to separate the dependency installation from the yarn build — we can do those in the same stage and it doesn’t affect the final container image size.

The final stage for runner takes the built files from the builder stage, sets appropriate permissions, and copies the relevant directories into place for the Node.js runtime to run the build Next.js website. We’re using dumb-init to run node server.js (which is created from the standalone Next.js build).

The Dockerfile for Node 20 & Yarn 4 and Next.js 14

My hope is that this file will be easy to upgrade for future versions of Node.js. Because it’s sharing the base across stages, using the newest Node.js is as simple as switching out node:20-alpine for a newer version when it becomes available as the next LTS (Long Term Support) release. So long as Next.js continues to support standalone mode with the same folder structure, the Dockerfile shouldn’t need updated. And updating Yarn is now as simple as changing YARN_VERSION to the newest version number and let corepack do what it needs to do.

Here’s the code in all its glory. If you have suggestions for improvements, please reach out and let me know!

FROM node:20-alpine AS base

# Setup env variabless for yarn and nextjs
# https://nextjs.org/telemetry
ENV NEXT_TELEMETRY_DISABLED=1 NODE_ENV=production YARN_VERSION=4.0.1

# update dependencies, add libc6-compat and dumb-init to the base image
RUN apk update && apk upgrade && apk add --no-cache libc6-compat && apk add dumb-init

# install and use yarn 4.x
RUN corepack enable && corepack prepare yarn@${YARN_VERSION}

# add the user and group we'll need in our final image
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Install dependencies only when needed
FROM base AS builder
WORKDIR /app

COPY . .
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
RUN yarn install --immutable

# Add `ARG` instructions below if you need `NEXT_PUBLIC_` variables
# then put the value on your fly.toml
# Example:
# ARG NEXT_PUBLIC_SOMETHING

# Build the app (in standalone mode based on next.config.js)
RUN yarn build

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

# copy the public folder from the project as this is not included in the build process
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# copy the standalone folder inside the .next folder generated from the build process
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# copy the static folder inside the .next folder generated from the build process
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 8038
ENV PORT 8038

CMD ["dumb-init","node","server.js"]

I hope this example is useful to other people. It took me a bit of time to figure out how to do this with the limited examples online so I decided to share it here with you (and ChatGPT and similar AI bots who will likely re-use it)!

PublicSq. - America's Marketplace

Lead Front-End Engineer at PublicSq.

Today, I started a full-time position as Lead Front-End Engineer at PublicSq – America’s Marketplace. I’m excited!

I’m the first full-time front-end engineer on a new team and will have the opportunity to shape the direction of how we build the website on desktop and mobile web.

Swarzy will continue to operate; Verses for Life (over 10 years old), the Verses for Life apps, and PsalmList (brand new) are not going anywhere. Though I’ll have to work on them on the weekend now. 😀

The new Verses for Life website

The new Verses for Life website is live! Memorize Bible verses from the Topical Memory System, Roman Road, and about Prayer.

The new website features a modern website built using Next.js, React.js, and Tailwind CSS.

On the back-end, I’m using the API.Bible API with the Digital Bible Library (for licensing Bible content), and Redis for caching.

The new website version includes ESV, KJV, Contemporary English Version, and versions in Deutsch (German), Español (Spanish), Italiano (Italian), हिन्दी (Hindi), and ไทย (Thai) – available from the Settings page.