NextJS is a JavaScript framework created by vercel. It lets you build serverless API, server-side rendering and static web applications using React. Vercel provides the out of box CI/CD integration with GitHub, GitLab, and BitHub. But sometimes, we want to host our NextJS application on other platforms than vercel, like AWS, GCP, DigitalOcean or Azure. In this blog, we will see how we can build our NextJS application using GitHub Workflow and Docker.
Setup NextJS Application
NextJS recommends using create-next-app
, which sets up everything automatically for you. To create a project, run:
npx create-next-app
# or
yarn create next-app
After the installation is completed, follow the instructions to start the development server. Try editing pages/index.js
and see the result on your browser.
For more information on how to use create-next-app
, you can review the documentation
Setup Dockerfile
We will package our NextJS application in Docker image. The reason for using Docker is we won't need to install any additional packages like nodejs, pm2 etc when we want to run our NextJS server. Docker will bundle everything up and give us the image that we can run anywhere. Below is the sample Dockerfile for our NextJS application.
FROM node:lts-alpine
ENV NODE_ENV production
ENV NPM_CONFIG_LOGLEVEL warn
RUN mkdir /home/node/app/ && chown -R node:node /home/node/app
WORKDIR /home/node/app
COPY package.json package.json
COPY package-lock.json package-lock.json
USER node
RUN npm install --production
COPY --chown=node:node .next .next
COPY --chown=node:node public public
EXPOSE 3000
CMD npm start
Now, let's see what is happening in above Dockerfile step-by-step.
- We are using node:lts-alpine as the base image.
- Setting environment variable as
production
. - Setting up an
app
folder withnode
user as owner. - Copying package.json and package-lock.json into the image.
- Running
npm install production
to install only production dependencies. - Copying
.next
andpublic
folder into the container. This is a very interesting step. Why are we copying the folders and not building the the application usingnext build
command? We will discuss this in detail below. - Exposing port 3000, so that our application can be accessible out of the container.
- Finally, running
npm start
command to start our NextJS application server.
We can see, we are not making any changes in the Dockerfile. It's easy to understand and straightforward. The interesting part is we are copying .next
and public
folder into the container, instead of building inside the container.
Here is the detailed explanation:
- In NextJS application, we may need to use NEXT_PUBLIC environment variables. NEXT_PUBLIC variables are required for the build time process. (eg. firebase web client)
- If we use a firebase web client, then we need to provide a few required variables like firebase api_key, app_id, auth_domain.
- We write these variables in
.env
or.env.local
file when developing our application locally. But we DO NOT, SHOULD NOT and MUST NOT push this file on VCS systems like git. - So when we build our application locally, it will use these variables from the
.env
and process gets completed without any error. But when we build our application in Docker usingRUN next build
command, our build command will fail because we are not providing these variables in the docker image. - If we want to build our NextJS application inside docker build process, we need to use
--build-args
in docker build command to pass the build-time variables. There are 2 ways to do this.- We use ci secret variables and pass them into the docker build command
- We create a
.env
file, encode tis using base64, pass it as ci secret variable, decode it using base64 inside docker file and then build the docker image.
- This will become very difficult to pass and maintain if our public variables list grows in the future.
- So to not complicate the build process, we will build our application outside the docker image using ci job and then copy the
.next
,public
folders into a docker image. - To pass environment variables in ci, there are 2 ways.
- Pass the environment variables as secrets
- Pass the base64 encoded of
.env
file, decode it inside ci process, write the file at the root of our project folder, same as local development and build our application.
GtiHub Workflow
A workflow is a configurable automated process made up of one or more jobs. We will configure the workflow with YAML file. You can read more here.
As discussed above, we will use GitHub workflow jobs to build our NextJS application. Below is the workflow file, we will be using the same. Save this file at PROJECT_ROOT_FOLDER/.github/workflows/main.yml
, so that GitHub can read the yaml file and setup actions accordingly.
Note: To be able to see the actions in the UI, you need to have the same file available in master
or main
branch.
Workflow file:
name: Build & Publish
on:
push:
branches:
- "**" # all branches
- "!dependabot/**" # exclude dependbot branches
workflow_dispatch: # Manually run the workflow
jobs:
next-build:
if: ${{ github.event_name == 'workflow_dispatch' }} # Run only if triggered manually
runs-on: ubuntu-latest
container: node:lts # Use node LTS container version, same as Dockerfile base image
steps:
- name: Checkout
uses: actions/checkout@v2 # Checkout the code
- run: npm ci #install dependencies
- run: npm run build
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{secrets.NEXT_PUBLIC_FIREBASE_API_KEY}}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{secrets.NEXT_PUBLIC_FIREBASE_APP_ID}}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN}}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID}}
NEXT_PUBLIC_SENTRY_DSN: ${{secrets.NEXT_PUBLIC_SENTRY_DSN}}
- name: Upload Next build # Upload the artifact
uses: actions/upload-artifact@v2
with:
name: build
path: |
.next
public
retention-days: 7 # artifact retention duration, can be upto 30 days
docker-push:
needs: next-build # Job depends on next-build(above) job
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Download next build # Download the above uploaded artifact
uses: actions/download-artifact@v2
with:
name: build
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
- name: Build and Push Docker Images
run: |
export CURRENT_BRANCH=${GITHUB_REF#refs/heads/}
export TAG=$([[ $CURRENT_BRANCH == "main" ]] && echo "latest" || echo $CURRENT_BRANCH)
export GITHUB_REF_IMAGE=ghcr.io/$GITHUB_REPOSITORY:$GITHUB_SHA
export GITHUB_BRANCH_IMAGE=ghcr.io/$GITHUB_REPOSITORY:$TAG
docker build -t $GCR_IMAGE -t $GITHUB_REF_IMAGE -t $GITHUB_BRANCH_IMAGE .
echo "Pushing Image to GitHub Container Registry"
docker push $GITHUB_REF_IMAGE
docker push $GITHUB_BRANCH_IMAGE
Now, let's discuss what is happening in yaml file.
- We need to pass the condition on which event we want to trigger our workflow. In our case, we want it on the push event. It can multiple as well like
[push, pull_request]
. You can read more here. - We can define the branches, we want this workflow to run to watch. ! means want to exclude these branches.
-
workflow_dispatch
to manually run the build process. If we don't write this, our workflow will run every-time we push to any branch of our repository. You can read more here. - We have divided our build process into 2 jobs.
- next-build:
- In this job, we are using
node:lts
as the base image, this has to be the same as Dockerfile base image - We are keeping this job manual, as we don't want this job to run everytime we push the code. So we add
if: ${{ github.event_name == 'workflow_dispatch' }}
condition in step. - In
env
section, we are exporting environment variables from secrets. So we need to add these variables in GitHub project secrets. Read more here on how to do it. - In next step, action will checkout the code, run
npm ci
to install dependencies andnpm run build
to build the NextJS application using exported environment variables. - Finally, after a successful build, CI job will use
actions/upload-artifact@v2
action to upload our build folder as an artifact on GitHub with 7 days of retention time, so that ci job can download the same folder in docker-build job and use it to build the image. In the build folder, we are including.next
andpublic
folder..next
folder is generated by the build process and we use public folder for assets like, svgs, images etc. So we want to keep that folder as well. - You can see the folder in action detail as below.
- In this job, we are using
- docker-push: To build our docker image
- This job depends on
needs:next-build
, which means we will only see this job after a successfulnext-build
job. If we don't write this, then our both the job will parallel and this job will fail because it won't be able to downloadbuild
artifact.next build
will upload the artifact then only, ci job and we will be able to access it. So we need to write this, it will create a sequential job, instead of parallel. - CI job will checkout the code, download the build artifact folder using
actions/download-artifact@v2
and extract it as well. - We want to keep our docker image to be hosted on GitHub packages, for that, we will use
docker/login-action@v1
action to login into the GitHub Container Registry server using username and password. We need to passCR_PAT
as well in repository secrets same as NEXT_PUBLIC vars. We can add here other registries as well like GCR, AWS ECR etc. - Next, ci job will get the
CURRENT_BRANCH
and tag our docker build accordingly. Here, we are creating 2 tags, one is with branch name likedev
,qa
,uat
,main
and another is with commit SHA. - after that, the job will start building our docker images and push it to GitHub packages after a successful build. Here, we can push it to other registries as well like GCR, AWS ECR etc.
- finally, this job will exit and our workflow will be successfully passed.
- This job depends on
- next-build:
To run the job, we have to navigate to repo actions and you will see the workflow with Build & Push
on the left sidebar. Click on that link and you will be able to see the screen as below.
With this, We will be able to build and package our NextJS application. You will see the action screen below the screenshot.
Thank you for reading. Have a great day!
Help Links:
- https://nextjs.org/docs/api-reference/create-next-app
- https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
- https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows
- https://docs.github.com/en/free-pro-team@latest/actions/reference/encrypted-secrets
Top comments (7)
Hi Yash, thanks for sharing this post. I tried to follow your steps, but getting error on "push" step in github workflow, any idea?
Hey,
Probably because docker build -t is not tagging the image properly. Try to run
docker images
after docker build command. It will print all the docker images we build with name.I resolved it, was hillarious but docker wasn't recognizing "-" and caps in my repository name, changing repo name to lowercase and replacing "-" with "_" solved the problem; But I'm now facing another issue, I'm only able to pass "NEXT_PUBLIC" env variables, but some libs like "next-auth" only accept some hardcoded variables like "NEXTAUTH_URL", is there anyway to solve this as well?
Awesome tutorial! Could you do the same with Gitlab CI?
Why are you saving the artifact if
docker build
will not use it and build an image itself?Because "next-build" will store ".next", "public" folder in CI step, and in dockerfile, we are copying ".next", "public" folders from host to container.
So we are using these folders in docker build step
Are you open to new job opportunity?