Deploying Next.js to K8s

nextjs blog bash

The Next.js documentation tends to focus on using the Vercel service for deploying Next.js, glossing over details on self-hosted deployments.  Understanding and properly implementing these details is essential to successful deployment.  Here we fill in those details, specifically for deploying a Next.js application to Kubernetes.

The following assumes a bash shell.  If you are on a Mac, you may need to update bash and your other CLI utilities by installing coreutils.  On Windows, you can get bash either from Git for Windows, Cygwin, or Windows Subsystem for Linux.

Set Up a Next.js Development Environment

  1. Install the latest LTS version of Node.js from https://nodejs.org/en/download/prebuilt-binaries.
  2. For creating Next.js applications, there is a utility similar to create-react-app called create-next-app.  When we run a Next.js application, it will need a utility called sharp.  Install these pacakages globally.
  3. npm install -g create-next-app sharp

Generate an Application

create-next-app has numerous command line options, and you probably want to create all your applications with the same options.  To simplify, save the following wrapper script as $HOME/bin/do-create-next-app.sh.

#!/bin/bash -fu    
    
declare -ir EXIT_SUCCESS=0    
declare -ir EXIT_FAILURE=1    
declare -ir EXIT_USAGE=2    
    
declare -r SCRIPT_NAME="${0##*/}"    
declare -r USAGE_MSG="Usage: $SCRIPT_NAME project-name"    
    
# Process the command line.    
if (($# != 1))    
then    
    echo "$USAGE_MSG"    
    exit $EXIT_USAGE    
fi    
declare -r PROJECT_NAME="$1"    
    
export NEXT_TELEMETRY_DISABLED=1    
    
echo "Creating new project $PROJECT_NAME."    
    
if ! create-next-app "$PROJECT_NAME"                            \    
    --app --eslint --src-dir --tailwind --typescript --use-npm  \    
    --import-alias '@/*' --no-turbopack    
then    
    echo 'Error running create-next-app.'    
    exit $EXIT_FAILURE    
fi    
    
exit $EXIT_SUCCESS

 

Now you can create a Next.js application with the following command.

do-create-next-app.sh project-name

 

Configure for Standalone Build

In order for your new application to have a self-managed deployment that contains only the necessary files, set the nextConfig option output to standalone (in next.config.ts).

    output : "standalone"

This will cause running a build to produce an almost complete build in .next/standalone.  But there will be some missing files that must be copied over after the build step completes.  These are public, .next/static, and any env files that we want to include.  To automate copying these files, as well as creating a single archive file for deployment, we need to create a postbuild script.

Below is a script that copies the missing files, and then archives and compresses the build to a file named <package-name><package-version>-build.tar.xz, where the package name and the package version are automatically pulled from the name and version fields in package.json.

#!/bin/bash -fu

declare -ir EXIT_SUCCESS=0

declare -ir EXIT_FAILURE=1

declare -ir EXIT_USAGE=2

declare -r STANDALONE_DIR='.next/standalone'

if ! [[ -v npm_package_name ]]

then

    echo 'Environment variable npm_package_name is not set.  This script can be run only by npm.'

    exit $EXIT_FAILURE

fi

# See https://docs.npmjs.com/cli/v6/using-npm/scripts#packagejson-vars

declare -r PKG_NAME_VER="$npm_package_name-$npm_package_version"

declare -r BUILD_FILE="$PKG_NAME_VER-build.tar.xz"

echo "Running postbuild for $PKG_NAME_VER."

# Files that must be explicitly copied to the standalone directory.

declare -r CP_FILE_LIST='

    public

    .env

    .env.production

    .next/static

'

for CP_FILE in $CP_FILE_LIST

do

    if ! cp -a "$CP_FILE" "$STANDALONE_DIR/$CP_FILE"

    then

        echo "Error copying $CP_FILE to $STANDALONE_DIR."

        exit $EXIT_FAILURE

    fi

done

if ! tar --directory="$STANDALONE_DIR" --transform="s/^./$PKG_NAME_VER/" -Jcf "$BUILD_FILE" .

then

    echo 'Error archiving build files.'

    exit $EXIT_FAILURE

fi

echo "Completed postbuild for $PKG_NAME_VER."

After saving this file in our project as bin/postbuild.sh, we can have it run automatically at the end of every build by adding the following to the scripts section of package.json.  This also modifies the start command to run the standalone build.

        "postbuild" : "bin/postbuild.sh",

        "start"     : "node .next/standalone/server.js"

At this point we have a build artifact that is basically usable for a single K8s node, but some additional configuration is necessary to make the nodes compatible across a cluster, as well as to verify that the Next.js client and server are on the same version.

The Build ID

Next.js uses the build ID to verify that all nodes in the deployment cluster are running the same build.  This value can only be set at build time, and is normally set to the commit hash.  If it is not set, a random string will be generated for it at build time.

To explicitly set a build ID from the build environment, as well as assert that an explicit value is required, add the following to the nextConfig object.

   

generateBuildId : async () => {

        if (!process.env.BUILD_ID) {

            throw new Error("BUILD_ID must be defined in the build environment.")

        }

        return process.env.BUILD_ID

    },

Then set the environment variable as follows by prefixing it to the build command as follows.

        "build": "BUILD_ID=\"$(git rev-parse HEAD)\" next build",

At build time, Next.js takes the value returned by nextConfig.generateBuildId() and stores in the file .next/BUILD_ID.; nextConfig.generateBuildId is not referenced at run-time.  At run-time, this ID is available as process.env.__NEXT_BUILD_ID, but only in the middleware.

The Deployment ID

The deployment ID is used to verify that the Next.js client and server are running the same version of the application.  This value can only be set at build-time.  If there is some deployment ID available from the deployment pipeline, this ID could be used.  If the deployment ID is not set, then there will simply be no deployment ID; Next.js will not randomly generate a deployment ID.

To set a deployment ID, add the following to the nextConfig object.

 

deploymentId : process.env.DEPLOYMENT_ID,

Then we can set the environment variable as we did with build ID.  Here we simply use a timestamp.

        "build": "BUILD_ID=\"$(git rev-parse HEAD)\" DEPLOYMENT_ID=\"$(date +%Y%m%d%H%M)\" next build"

At startup, Next.js takes the value from nextConfig.deploymentId (which is now hard-coded to the value taken from DEPLOYMENT_ID at build time; see .next/standalone/server.js) and loads that into process.env.NEXT_DEPLOYMENT_ID.  (Preferably, it would be possible to set the deployment ID at run-time, to allow builds to be re-deployed without re-building, but Next.js does not currently support this.)

When there is an active deployment ID, it will be visible in the browser console as a query parameter dpl on requests to load scripts and stylesheets.  On other requests it will be seen as the x-deployment-id header.  When the server detects a mismatch in the deployment ID, it forces a page reload to get the client and server back in sync.

The Encryption Key for Server Actions

For server actions, it is necessary for the Next.js client to possess potentially sensitive data that must be encrypted.  For this, Next.js maintains an internal AES encryption key.  Next.js will automatically generate a value for this key, but when running a cluster, it is necessary to generate this value as part of the deployment to ensure that each node uses the same key.  See Closures and encryption for more details.

There is no nextConfig value for this encryption key.  It is read directly from process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY at run-time (although the build-time value is captured, or generated if absent, and stored in the middleware environment; see .next/standalone/.next/server/middleware-manifest.json).  If you inspect this environment variable in the middleware you will see the build-time value, but in a server action the run-time value will be present.

The format of the encryption key is 256 bits of random binary data, base64 encoded.  You can generate one with the following command.

openssl rand -base64 32

Although not strictly necessary, to create one for running locally, run

openssl rand -base64 32 > local-aes.key

and then modify the start command as follows.  We also add a uility for generating the local key.

 

    "start": "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=$(< local-aes.key) node .next/standalone/server.js",

        "genlocalaeskey": "openssl rand -base64 32 > local-aes.key"

If the I/O redirection in the start command does not work, you may need to force NPM to use bash.

npm config set script-shell /bin/bash

Building a Docker Image

Now that we have all the pieces, we are ready to build a docker image.  At a high-level, our Dockerfile will:

  1. Set up up a base Debian system.
  2. Download, verify, and install a specified version of Node.js.
  3. Set static environment variables needed for running a Next.js application.
  4. Accept an encryption key value as input at run-time.
  5. Install and run a stand-alone Next.js build.

In addition to the Dockerfile, we add the following scripts to build a Docker image and to run it locally.

     

"dockerbuild": "bin/dockerbuild.sh",

        "predockerrun": "echo \"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=$(< local-aes.key)\" > .docker-env",

        "dockerrun": "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=$(< local-aes.key) bin/dockerrun.sh",

At this point we have a Docker image with well-defined inputs, and there is nothing Next.js-specific to be concerned about in deploying to K8s.

These scripts and a detailed Dockerfile are available in a working example at https://gitlab.com/lfrost/how-to-deploy-nextjs-to-k8s.