Next.js Deployment with PKG and Docker

Next.js, pkg, and Docker can all be combined to make a powerful yet minimalist containerized Node deployment...

a year ago

Latest Post Automatic Offline Backup With a Raspberry Pi by Tyler Moon

Next.js is a lightweight React framework for static and server-rendered applications. In many modern environments it may be needed to deploy a Next.js application in a Docker container (Often times through using an orchestrator such as Kubernetes or Docker Compose). While you can install Node.js in a Docker container in order to run the Next.js application there is another Node package that can make it even easier. This package is called pkg and is a command line interface that enables you to create a Node.js executable that can run on devices without Node.js installed.

A little separate from all this Node stuff is another tool that I have been personally starting to use of late. Make is a build automation tool that has typically been used for compiling C and C++ code but can actually be used to compile and run any programming stack. To specify the build commands needed for your application, just include a Makefile with the needed commands and if you have Make installed then run make. We will see more on this later in this article. Make is useful when bouncing between multiple projects that all have different build platforms and commands (such as NPM, Yarn, Bower, Gulp, Python, ect.) because it can give you a standard way of building and running without remembering the project specific commands. And if you need to know those specific commands you can look at the Makefile where they are specified.

Lets jump into an example. In this article we are going to build a simple 2 page hello world static site using Next.js. Then we are going to compile that application into a binary executable using pkg. And finally we are going to build and deploy our application on a Docker container.

Prerequisites

Note: All of these technologies are available on all modern operating systems however in this article I will be demonstrating based off the Linux command line. The commands would be the same on MacOS but would have to differ slightly on Windows

Setup

First things first we need to create our project directory structure and some blank files to fill in later

mkdir nextjs-pkg-docker && cd nextjs-pkg-docker
mkdir pages
touch Dockerfile Makefile next.config.js package.json renovate.json server.js pages/index.js pages/about.js

And now you should have a directory structure that looks something like the following

nextjs-pkg-docker
  |- pages
    |- about.js
    |- index.js
  |- Dockerfile
  |- Makefile
  |- next.config.js
  |- package.json
  |- renovate.json
  |- server.js

Now we need to install the needed Node.js packages. First open up the package.json file and add the following content to specify which packages should be installed.

{
  "name": "nextjs-pkg-docker",
  "version": "1.0.0",
  "description": "Deploy a Next.js application with pkg and docker.",
  "bin": "server.js",
  "dependencies": {
    "next": "6.0.2",
    "react": "16.3.2",
    "react-dom": "16.3.2"
  },
  "devDependencies": {
    "pkg": "4.3.1"
  }
}

And then use npm i to install the listed dependencies.

Next.js Application

Add the following content to build out the skeleton of our simple Next.js application.

Add some configuration to the server

// next.config.js

module.exports = {
  serverRuntimeConfig: {
    // Will only be available on the server side
    mySecret: 'secret',
  },
  publicRuntimeConfig: {
    // Will be available on both server and client
    API_URL: process.env.API_URL,
  },
};
// renovate.js

{
  extends: [config:base]
}

Create the node.js server

// server.js

// require needed packages
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const nextConfig = require('./next.config'); // next.config.js

const port = parseInt(process.env.PORT, 10) || 3003; // default to port 3003 if not specified
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev, dir: __dirname, conf: nextConfig }); // Create a new next instance
const handle = app.getRequestHandler();

// start up the server on the specified port and route traffic to the pages directory
app.prepare().then(() => {
  createServer((req, res) =>
    handle(req, res, parse(req.url, true).pathname),
  ).listen(port, err => {
    if (err) throw err;
    console.log(`> Ready on http://localhost:${port}`);
  });
});

Add some content

// pages/index.js

// Imports
import React from 'react';
import getConfig from 'next/config';

// get the config as specified in next.config.js and loaded in server.js line 11
const { publicRuntimeConfig } = getConfig();

const { API_URL } = publicRuntimeConfig; // pull out the variable from the config

// Export the HTML content when requested
export default () => (
  <div>
    <h1> Hello World </h1>
    <a href="/about">About</a>
  </div>
);
// pages/about.js

// imports
import React from 'react';

// Export the HTML content when requested
export default () => (
  <div>
    <h1> About </h1>
    <a href="/">Home</a>
  </div>
);

In Next.js every file in the pages directory is automatically mapped to the url of the filename. So in this case pages/about.js is being automatically mapped to /about. This makes it very easy to add new pages as you just need to add a new js file in the directory. No need to worry about configuring or maintaining a router instance or anything.

Now that we have the basics down here lets go back to the package.json file and add some npm scripts to help us build and run our application. Modify your package.json to match the following

{
  "name": "nextjs-pkg-docker",
  "version": "1.0.0",
  "description": "Deploy a Next.js application with pkg and docker.",
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js",
  },
  "dependencies": {
    "next": "6.0.2",
    "react": "16.3.2",
    "react-dom": "16.3.2"
  },
  "devDependencies": {
    "pkg": "4.3.1"
  }
}

And now run npm run build and then npm run start and your webapp should be available on localhost:3003

Exciting Next.js Application

pkg it up!

In order to setup our minimalist Docker image we first need to configure pkg to build our standalone binary. Luckily this is pretty easy to do and just requires modifying our package.json file with some additional values:

{
  "name": "nextjs-pkg-docker",
  "version": "1.0.0",
  "description": "Deploy a Next.js application with pkg and docker.",
  "bin": "server.js",
  "pkg": {
    "assets": [".next/**/*"],
    "scripts": [".next/dist/**/*.js"]
  },
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js",
    "pkg": "pkg . --targets node9-alpine-x64 --out-path pkg"
  },
  "dependencies": {
    "next": "6.0.2",
    "react": "16.3.2",
    "react-dom": "16.3.2"
  },
  "devDependencies": {
    "pkg": "4.3.1"
  }
}

The pkg element specifies which directories to package up and the pkg script line specifies to target the node9-alpine-x64 architecture. This is important because thats the architecture of the Docker container we are going to build. Alpine Linux is a super slimmed down distro of Linux which is great for lightweight Docker containers. Now run npm run pkg to create the binary in the ./pkg directory.

Docker Time

I'm not going to cover everything about Docker containers in this article so if you are unfamiliar with this technology I would recommend reading through the documentation here

Docker containers are built out of an image which can be either remotely pulled from a server or specified through a Dockerfile. Open up our projects Dockerfile and add the following content.

# Do the npm install or yarn install in the full image
FROM mhart/alpine-node:10.0.0 AS builder
WORKDIR /app
COPY . .
RUN yarn install --pure-lockfile --ignore-engines
ENV NODE_ENV=production
RUN yarn run build
RUN rm -rf node_modules/webpack node_modules/webpack-dev-middleware node_modules/webpack-hot-middleware
RUN yarn run pkg

# And then copy pkg binary from that stage to the smaller base image
FROM alpine:3.7
RUN apk update && \
  apk add --no-cache libstdc++ libgcc ca-certificates && \
  rm -rf /var/cache/apk/*
WORKDIR /app
COPY --from=builder /app/pkg .
ENV NODE_ENV=production
ENV PORT=3003
ENV API_URL=https://API_URL.com
EXPOSE 3003
CMD ./nextjs-pkg-docker-alpine

And the following commands will build and deploy your new container

docker build -t nextjs-pkg-docker-alpine .

docker run --rm -it \
		-p 3003:3003 \
		-e "PORT=3003" \
		-e "API_URL=https://API_URL.com" \
		nextjs-pkg-docker-alpine

And you should have your Next.js application running on localhost:3003 again but this time being served out of a Docker container running Alpine Linux! And now this application is truly platform independent and can run anywhere Docker is installed.

Wrapping it up with Make

As we have seen through this article there are a lot of different commands that can be used to build and deploy different parts of this application stack including npm, pkg, and docker. It can be difficult to remember all of these especially if you step away from the project for a while. This is why I like to write up a Makefile and use Make to run my projects. Add the following to your Makefile

build:
	npm run build
	npm run pkg

run:
	npm run start

deploy:
	sudo docker build -t nextjs-pkg-docker-alpine .
	sudo docker run --rm -it \
		-p 3003:3003 \
		-e "PORT=3003" \
		-e "API_URL=https://API_URL.com" \
		nextjs-pkg-docker-alpine

And now you can run make build, make run, and make deploy and do not have to worry about the underlying commands. But if you want to know what commands to use you can just look at the Makefile. If you setup Makefiles in other projects then it can become a standard place for build commands!

I like to use the build, run, and deploy as my arguments in most of my Makefiles. This makes it so that you do not have to remember the make arguments either. However you can modify those as needed per project.

Summary

In this article we saw how to build a simple Next.js web app, create a standalone executable binary of a Node.js project, and deploy that binary to a Docker container running Alpine Linux that did not have Node installed on it. Finally, we saw how using Make and a Makefile can help standardize build commands across multiple projects that use different CLI's and build tools.

Tyler Moon

Published a year ago

Comments?

Leave us your opinion.