With Makefile - Next.js docker deployment and versioning automation

pragmaticfrontend

PragmaticFrontEnd

Posted on May 12, 2024

With Makefile - Next.js docker deployment and versioning automation

Next.js team have an example in their official examples as With Docker - Multiple Deployment Environments, which managed in a Makefile.

This example pushed it further, not only load different environments, but also make Makefile as an Orchestra, let it control every step of a Next.js project docker image release.

Repository: with-makefile-docker-automation

Pain-point and Thinking

When managing multiple environments deployment, it's easy to load the wrong configuration.

To make version verification easier, a Consistent Tag should be injected to all the outputs during the build phase.

A Tag should reveals:

  1. The public version tag
  2. The env file loaded for the build
  3. The git commit hash code upon the build

The tag should be logged in a consistent manner:

  1. Printed in the terminal output, during build a docker image
  2. Printed in the backend log file, once the node service launched
  3. Displayed in the browser console or a landing page, once html page fetched

For Example

The current project package.json looks like below:

"name": "x-app",
"version": "1.0.9",
...
Enter fullscreen mode Exit fullscreen mode

After Build Phase

  • package.json version field will be auto increased.
"version": "1.1.0",
Enter fullscreen mode Exit fullscreen mode
  • Output from shell:
# docker image
x-app-production     v1.1.0       xxx     Less than a second ago      152MB
Enter fullscreen mode Exit fullscreen mode

After Deployed

  • Backend
// log in node server
  Next.js 14.2.3
  - Local:        http://localhost:3000
  - Network:      http://0.0.0.0:3000

  Starting...
 🚀 version: v1.1.0-75a44a7-production
  Ready in 799ms
Enter fullscreen mode Exit fullscreen mode
  • Frontend
// console / on a page
Release: v1.1.0-75a44a7-production
Enter fullscreen mode Exit fullscreen mode

By seeing those, we can announce confidently:

  • x-app project v1.1.0 production is ready 🎉

How to use

Clone the project and install the dependencies

# choose your favorite package manager, we use pnpm here
pnpm install
Enter fullscreen mode Exit fullscreen mode
  • Enter the values in the .env.development, .env.staging, .env.production files to be used for each environments.

Check all available commands by simply run make command on the root path of the project:

make
Enter fullscreen mode Exit fullscreen mode
  • You might need to install the make tool for non-unix based OS. Make for Windows

For Production Deployment

# build a docker image
make build

# push the docker image to registry
# just `make push` if DOCKER_ACCOUNT in shell env
make push DOCKER_ACCOUNT=<YOUR_DOCKER_ACCOUNT>

# git commit the changes
make commit

# shortcut for run the above commands in sequence
make all
Enter fullscreen mode Exit fullscreen mode

For Staging Deployment

make <command> NODE_ENV=staging
Enter fullscreen mode Exit fullscreen mode

For Development Deployment

make <command> NODE_ENV=development
Enter fullscreen mode Exit fullscreen mode

For Local Development

make dev
Enter fullscreen mode Exit fullscreen mode

How it works

Make a demo

As Wikipedia:

  • Make is a build automation tool that builds executable programs, is also a dependency-tracking build utility.
  • Was created in 1976 at Bell Labs. Remains widely used.
  • Controlled by Makefile(s), which specify how to derive the target program

The syntax looks like below:

target: pre­req­ui­sites
<TAB> recipe
Enter fullscreen mode Exit fullscreen mode
  • target: a command name or a file/ directory that needs to be built.
  • pre­req­ui­sites: oth­er tar­gets or files that need to be built before the tar­get can be built.
  • receipt: pre­ced­ed by a tab, a series of any num­ber of shell com­mands that are exe­cut­ed to build the target

Let's hands on a hello world demo:
Firstly, create a Makefile under a directory:

touch Makefile
Enter fullscreen mode Exit fullscreen mode

Secondly, write an echo example to the file:

project=x-app

# syntax
# target:
# <TAB> recipe

echo1:
  @echo "hello"

echo2:
  @echo "world"

start: echo1 echo2
  @echo "start..." 
  @echo "$(project)"
Enter fullscreen mode Exit fullscreen mode
  • Attention: By default a receipt must start with a <TAB>, not spaces, otherwise you will face an error: *** missing separator. Stop.
  • Start with the @ character is to tell make, don't print the origin receipt code onto the console.

Finally, open a terminal and type make start, the output should be

hello
world
start...
x-app
Enter fullscreen mode Exit fullscreen mode

So far, we've got just enough knowledge to move on.

Here are some good tutorials about make and Makefile:

Context and its variables

Before moving forward to define the make commands, we should be aware of there are three runtime contexts involve in the workflow:

  1. Shell context
  2. Docker build context
  3. Node runtime context

Shell context

At the time a terminal window is open, variables can be initially defined and exposed in .zshrc or .bashrc. For shell Configuration please refer to how-do-zsh-configuration-files-work for more details.

make runs as a shell script, it can read and write to the existing shell variables, also create new members to the context.

The shell context are exposed to:

  • npm scripts
  • docker-compose.yml
  • docker .env
  • project .env

Docker build context

This context is created and disposed aline with docker build lifecycle, it's isolated from shell context.

Arguments is passed by --build-arg in shell context:

# title="./Makefile"
  @docker compose -f docker/$(NODE_ENV)/docker-compose.yml build \
  --build-arg GIT_COMMIT=$(GIT_COMMIT) \
  --build-arg TAG=$(TAG) \
  --build-arg ENV=$(NODE_ENV) \
  --build-arg DOCKER_CONTAINER_PORT=$(DOCKER_CONTAINER_PORT)
Enter fullscreen mode Exit fullscreen mode

Accepted by ARG in its own context:

# title="./Dockerfile"
  ARG GIT_COMMIT \ 
      TAG \
      ENV \ 
      DOCKER_CONTAINER_PORT
Enter fullscreen mode Exit fullscreen mode

Node runtime context

This context is created and disposed with next build process, it invoked from dockerfile:

# title="./Dockerfile"
...
  elif [ -f package-lock.json ]; then npm run build; \
...
Enter fullscreen mode Exit fullscreen mode

Which triggered the build npm script:

// title="./package.json"
"scripts" : {
    "build": "cross-env NEXT_PUBLIC_VERSION=$TAG NEXT_PUBLIC_GIT_COMMIT_ID=$GIT_COMMIT NEXT_PUBLIC_ENV_FILE=$ENV next build",
}
Enter fullscreen mode Exit fullscreen mode
  • cross-env package is used here to set variables to node runtime without worrying about operation system.
  • As designed for the consistent tag, we are passing three key variables to the node runtime.
  • NEXT_PUBLIC prefix is a convention in Next.js, to allow node pass the variable to frontend during the build process, which means public.

Illustration

Variables passing flow example

As variable A an example, it defined in shell config as "0", then updated to "1" by makefile and kept as "1" for the rest of usage:

  • Read and used in docker-compose.yml and docker env
  • Passed to docker build context through --build-arg and ARG
  • Passed to node runtime and exposed to public

Design Make commands

With the knowledge above, define targets / commands in Makefile is simple:

  • Define variables and helper targets
# Define Exposed variables
export NODE_ENV := production
export APP_NAME := $(shell npm pkg get name | xargs)# Get project name from package.json config
export TAG :=# Latest Version Tag
export DOCKER_HOST_PORT :=3000
export DOCKER_CONTAINER_PORT :=3000

# Internal variables
DOCKER_ACCOUNT :=$(DOCKER_ACCOUNT)# Docker account name when push the image to registry
GIT_COMMIT :=$(shell git rev-parse --short HEAD)# Get latest commit hash code
DOCKER_IMAGE :=# Latest Docker Image
RECEIPT :=# Used for echo

# Update project version by trigger npm script `version:update`
version-update: 
  @sh -c "npx cross-env NODE_ENV=${NODE_ENV} npm run version:update || (echo 'Version update failed!' && exit 1)"

# Get latest variables
variable-update: 
  $(eval TAG := v$(shell npm pkg get version | xargs))
  $(eval DOCKER_IMAGE :=$(APP_NAME)-$(NODE_ENV):$(TAG))
Enter fullscreen mode Exit fullscreen mode
  • Compose core targets

# All in one when release: build a new image, push to the registry, commit the changes
# Depends on build, push and commit
all: build push commit

# Build a latest docker image
# Depends on version-update, variable-update
build: version-update variable-update 
  @docker compose -f docker/$(NODE_ENV)/docker-compose.yml build \
    --build-arg GIT_COMMIT=$(GIT_COMMIT) ...

# Tag and push the latest image to the docker registry
push: variable-update 
  @docker tag $(DOCKER_IMAGE) $(DOCKER_ACCOUNT)/$(DOCKER_IMAGE)
  @docker push $(DOCKER_ACCOUNT)/$(DOCKER_IMAGE)
  @docker image rm $(DOCKER_ACCOUNT)/$(DOCKER_IMAGE)

# Run the latest docker image in local
run: variable-update 
  @docker container run -it -p $(DOCKER_HOST_PORT):$(DOCKER_CONTAINER_PORT) $(DOCKER_IMAGE)


# Start the dev mode
dev: export NODE_ENV = development
dev: 
# just `@pnpm install` if pnpm enabled already
  @corepack enable pnpm && pnpm install 
  @pnpm run dev
Enter fullscreen mode Exit fullscreen mode

As above, helper targets are reused for my core targets. Makefile can really make it dry!

Display in node and frontend

Next, let's display the consistent tag on server and frontend.

  • Add type definition to node process env
// title="typings/index.d.ts"
namespace NodeJS {
  interface ProcessEnv {
    // for version control
    NEXT_PUBLIC_VERSION: string;
    NEXT_PUBLIC_GIT_COMMIT_ID: string;
    NEXT_PUBLIC_ENV_FILE: string;

    // from env file
    NEXT_PUBLIC_BASE_URL: string;
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Include the type definition
// title="tsconfig"
"include": [
    ...,
    "typings/*.d.ts"
  ],
Enter fullscreen mode Exit fullscreen mode

To log the node server, Next.js provide a way by use Instrumentation:

  • Enable Next.js Instrumentation feature
// title="next.config.mjs"
const nextConfig = {
    experimental: {
      instrumentationHook: true,
    },
    ...
}
Enter fullscreen mode Exit fullscreen mode
  • Register helper function when server boost
// title="instrumentation.ts"
export async function register() {
  logProjectVersion();
}
Enter fullscreen mode Exit fullscreen mode

To display on browser, simply call logProjectVersion function on a page will do.

Unit now, we've completed all the hard work!

Let's deploy and verify

This part is left for you to complete, choose your favourite way to deploy the docker image. Check if you can get the similar outputs as the Example above.

Cheers !

💖 💪 🙅 🚩
pragmaticfrontend
PragmaticFrontEnd

Posted on May 12, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related