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:
- The public version tag
- The env file loaded for the build
- The git commit hash code upon the build
The tag should be logged in a consistent
manner:
- Printed in the terminal output, during build a docker image
- Printed in the backend log file, once the node service launched
- 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",
...
After Build Phase
-
package.json
version field will be auto increased.
"version": "1.1.0",
- Output from shell:
# docker image
x-app-production v1.1.0 xxx Less than a second ago 152MB
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
- Frontend
// 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
# 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:
make
- 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
For Staging Deployment
make <command> NODE_ENV=staging
For Development Deployment
make <command> NODE_ENV=development
For Local Development
make dev
How it works
Make
a demo
As Wikipedia:
- Make is a build automation tool that
builds executable programs
, is also adependency-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: prerequisites
<TAB> recipe
- target: a
command name
or a file/ directory that needs to be built. - prerequisites:
other targets
or files that need to be builtbefore the target can be built
. - receipt:
preceded by a tab
, a series of any number ofshell commands
that are executed to build the target
Let's hands on a hello world
demo:
Firstly, create a Makefile under a directory:
touch Makefile
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)"
- 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
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
- 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:
- Shell context
- Docker build context
- 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)
Accepted by ARG
in its own context:
# 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:
# title="./Dockerfile"
...
elif [ -f package-lock.json ]; then npm run build; \
...
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",
}
-
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 allownode
pass the variable to frontend during the build process, which means public.
Illustration
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
andARG
- 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))
- 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
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;
}
}
- Include the type definition
// title="tsconfig"
"include": [
...,
"typings/*.d.ts"
],
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,
},
...
}
- Register helper function when server boost
// 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 above.
Cheers !
Top comments (1)
Nice work!