With Makefile - Next.js docker deployment and versioning automation
Tags: nextjs, makefile, docker, webdev
Published: 2024-05-12 10:38:55

Next.js team have an example in their official examples as [With Docker - Multiple Deployment Environments](https://github.com/vercel/next.js/tree/canary/examples/with-docker-multi-env), which managed in a [Makefile](https://github.com/vercel/next.js/blob/canary/examples/with-docker-multi-env/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](https://github.com/PragmaticFrontEnd/blog-nextjs-with-makefile-docker-automation.git)

## 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:

```json
"name": "x-app",
"version": "1.0.9",
...
```

#### After Build Phase
- `package.json` version field will be auto increased.

```json
"version": "1.1.0",
```

- Output from shell:

```bash
# docker image
x-app-production     v1.1.0       xxx     Less than a second ago      152MB
```

#### After Deployed

- Backend

```js
// 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
```

- Frontend

```js
// console / on a page
Release: v1.1.0-75a44a7-production
```

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

```bash
# choose your favorite package manager, we use pnpm here
pnpm install
```

- 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:

```bash
make
```
- You might need to install the make tool for non-unix based OS. [Make for Windows](https://gnuwin32.sourceforge.net/packages/make.htm)


### For Production Deployment

```bash
# 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
```

### For Staging Deployment

```bash
make <command> NODE_ENV=staging
```

### For Development Deployment

```bash
make <command> NODE_ENV=development
```

### For Local Development

```bash
make dev
```
---
## How it works
### `Make` a demo
As [Wikipedia](https://en.wikipedia.org/wiki/Make_(software)):
- 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
```
- 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: 
```bash
touch Makefile
```
Secondly, write an echo example to the file:
```Makefile
project=x-app

# syntax
# target:
# <TAB> recipe

echo1:
  @echo "hello"

echo2:
  @echo "world"

start: echo1 echo2
  @echo "start..." 
  @echo "$(project)"
```
- 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 
```bash
hello
world
start...
x-app
```

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

Here are some good tutorials about `make` and `Makefile`:
  - [Learn Makefiles With the tastiest examples](https://makefiletutorial.com/)
  - [Using Make & Makefiles to Automate your Frontend Workflow](https://nystudio107.com/blog/using-make-makefiles-to-automate-your-frontend-workflow)

### 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](https://www.freecodecamp.org/news/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:

```Makefile 
# 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)
```

Accepted by `ARG` in its own context:

```Dockerfile 
# title="./Dockerfile"
  ARG GIT_COMMIT \ 
      TAG \
      ENV \ 
      DOCKER_CONTAINER_PORT
```

#### Node runtime context

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

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

Which triggered the build npm script:

```json 
// 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",
}
```

- [`cross-env`](https://www.npmjs.com/package/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]((https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables)), to allow `node` pass the variable to frontend during the build process, which means public.

#### Illustration

![Variables passing flow example](https://raw.githubusercontent.com/PragmaticFrontEnd/blog-nextjs-with-makefile-docker-automation/307c8cfbd12e1b3375aa5d8f55a2ca76b7710d0d/doc/imgs/Variables_Passing_Flow_Example_Black.svg)

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

```Makefile
# 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))
```

- Compose core targets
```Makefile

# 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
```

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

```ts
// 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;
  }
}
```
- Include the type definition

```ts 
// title="tsconfig"
"include": [
    ...,
    "typings/*.d.ts"
  ],
```

To log the node server, Next.js provide a way by use [Instrumentation](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation):

- Enable Next.js `Instrumentation` feature

```ts 
// title="next.config.mjs"
const nextConfig = {
    experimental: {
      instrumentationHook: true,
    },
    ...
}
```

- Register helper function when server boost

```ts
// title="instrumentation.ts"
export async function register() {
  logProjectVersion();
}
```

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](#for-example) above.

Cheers !