Dockerize and deploy a NextJS app with Github Actions

Dockerize and deploy a NextJS app with Github Actions


Introduction

Deploying a modern web application can be a complex and time-consuming process, but with the right tools and strategies, it can be streamlined and automated. In this article, we'll explore the process of Dockerizing a Next.js application and deploying it using GitHub Actions, a powerful continuous integration and continuous deployment (CI/CD) platform. Next.js is a popular React framework that simplifies the development and deployment of server-rendered React applications. By Dockerizing your Next.js app, you can ensure consistent and reliable deployments across different environments, from development to production. GitHub Actions, on the other hand, provides a flexible and scalable platform for automating your build, test, and deployment workflows, making it an ideal choice for managing the CI/CD process.

Table of Contents

What is NextJS

A framework based on React

If NextJS is an alien term for you, read along!

Next.js provides a powerful set of features and tools for building high-performance, server-rendered React applications. Some of the key benefits and uses of Next.js include: Based on the search results provided, here is a brief overview of Next.js and its key benefits and use cases:

  1. Server-Side Rendering (SSR): Next.js enables server-side rendering, which improves the initial load time of web pages and enhances the overall user experience. This is particularly beneficial for SEO, as search engines can more effectively crawl and index the content.1 2

  2. Automatic Code Splitting: Next.js automatically splits the code into smaller, optimized chunks, improving the initial load time and overall performance of the application. This helps create fast-loading pages. 3

  3. File-Based Routing: Next.js uses a file-based routing system, which simplifies the process of managing application routes and navigation. This makes it easier for developers to build and maintain complex web applications.2

  4. Seamless Error Handling: Next.js comes with built-in error handling capabilities, allowing developers to create custom error pages and provide better user experiences when errors occur. 1

  5. Serverless Functionality: Next.js supports serverless functions, enabling developers to handle backend tasks and API calls without the need for a separate server. This simplifies the deployment and scaling of the application. 2 4

Why Dockerize applications?

Containerization and orchestration compose the key indurstry practices for deploying real-time production-grade applications.

The Docker and Kubernetes platform has emerged as a game-changing technology, revolutionizing the way organizations approach application delivery.

Why? 🤔...

1. Unparalleled Portability and Consistency

Containerization packages your application, dependencies, and runtime environment into a single, portable unit. This ensures your app runs consistently across different environments, from development to production, eliminating the "it works on my machine" problem.

2. Optimized Efficiency and Resource Utilization

Containers are lightweight and share the host operating system, requiring fewer resources than virtual machines. This enables higher server consolidation and utilization, leading to significant cost savings.

3. Agile Deployment and Scalability

Containers can be created and deployed much faster than virtual machines, allowing for more responsive and dynamic application scaling to meet changing demands.

4. Robust Fault Isolation and Security

Each container runs in an isolated environment, limiting the impact of issues or vulnerabilities to the specific container. This improves the overall security and reliability of the application infrastructure.

5. Seamless Integration with DevOps Workflows

Containerization aligns perfectly with modern DevOps practices, enabling automated build, test, and deployment pipelines. This streamlines the development and deployment processes, accelerating software delivery.

DevOps and Continuous Integration and development (CICD)

A cultural and professional movement

The DevOps approach encourages continuous feedback, rapid iteration, and a relentless focus on improving the overall software delivery lifecycle. By aligning the goals and workflows of development and operations, DevOps enables organizations to respond more quickly to changing market demands, reduce the risk of deployment failures, and ultimately deliver better software faster.

Embracing the CI/CD Paradigm

Complementing the DevOps culture, Continuous Integration (CI) and Continuous Deployment (CD) are a set of practices and tools that automate the software development, testing, and deployment processes. CI/CD is all about establishing a rapid feedback loop, where code changes are regularly integrated, tested, and deployed to production with minimal manual intervention.


Hands On Session 🧑🏾‍💻

This guide assumes that you have a NextJS app built and tested. For this tutorial, I will use a simple NextJS application I have built.

1. Dockerize the web application

Dockerfile
FROM node:18
WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

EXPOSE 3000

CMD ["sh", "-c", "npm run start"]

❗ Now, this is not an optimized Docker Image. In order to build a production-ready Docker image, refer Optimizing Docker Images for Production

Now, for building the image locally, execute the following command:

docker build -t talk-with-data:latest .

and for spinninng a container up:

docker run -it --rm -p 80:3000 talk-with-data:latest

📄 Note the mapping of port 80 on the host machine to port 3000 in the container. This will forward http traffic to the container allowing users to access the application through the host machine's network.

2. Docker Compose

What is Docker Compose? Docker Compose is a tool for defining and running multi-container applications. It allows you to define the services that make up your application in a single YAML configuration file, called a "Compose file". This file specifies how each container in your application should be configured, including the Docker image to use, environment variables, ports, and more.

Why use Docker Compose?

1. Simplifies multi-container application management.

2. Ensures consistent environments:

  • The Compose file defines the exact configuration for each service, ensuring that your application will run the same way across different environments (e.g., development, staging, production).
  • This helps prevent issues caused by differences in runtime environments.

3. Promotes reusability and collaboration:

  • The Compose file acts as a single source of truth for your application, making it easier to share and collaborate on projects.
  • Other developers can quickly set up the same application environment by using the Compose file, without needing to manually configure each service.

4. Simplifies testing and CI/CD:

  • Docker Compose integrates well with continuous integration and continuous deployment (CI/CD) workflows, allowing you to easily test and deploy your multi-container applications.
  • The ability to start and stop the entire application stack with a single command simplifies testing and debugging.
docker-compose.yml
version: '3'

services:
  frontend:
    image: talk-with-data:latest
    ports:
      - '80:3000'

Since, we have a single service, the benefits of Docker Compose isn't obvious here, but, when containerizing multiple applications and connecting them to a common network, one can witness the pros of Docker Compose.

3. Digital Ocean & Container Registry (DOCR)

Now, that we have the container up and running locally, let's setup the deployment platform and configure CICD so that changes to the app are automatically deployed to the platform, which in this case, is Digital Ocean.

1. Create a Digital Ocean Account.

CICD
Figure 2: Create an account on Digital Ocean

2. Create a new project.

CICD
Figure 3: Create a project

3. Create a Droplet (server) in the new project.

CICD
Figure 4: Create a droplet

Select the Docker OS from Marketplace. Choose a plan type which suits your requirements. I went with the Basic plan.

CICD
Figure 5: Configure Droplet

Configure SSH Key(s):

CICD
Figure 6: Configure SSH Key(s)

4. SSH into the droplet

CICD
Figure 7: SSH into Droplet from your local system

5. Install required packages and configure credentials

CICD
Figure 8: Install Necessary Packages

For security purposes, Snaps run in complete isolation and need to be granted permission to interact with your system’s resources. Some doctl commands require additional permissions:

CICD
Figure 9: Configure doctl plugs

Obtain a personal access token:

CICD
Figure 10: Create a Personal Access Token

Create a file .config in the root directory. Why ? Reference: 5

mkdir ./config

Initialize doctl with the personal access token:

CICD
Figure 11: Initialize doctl

6. Append CASignatureAlgorithms to sshd_config

Execute echo "CASignatureAlgorithms +ssh-rsa" | sudo tee -a /etc/ssh/sshd_config

This is required since we are using a key of type - RSA for accessing the server through SSH. 6

7. Create a Container registry in Digital Ocean

Create a registry to host Docker images. Alternatively, consider using Docker Hub. Digital Ocean, permits 1 free private repository with 500mb storage.

I have created a registry called twd.

CICD
Figure 12: Create a Container Registry

8. Upload local Docker Images to Digital Ocean Container Registry

doctl registry login. Ensure that you have doctl installed on your local machine too. 7

execute docker build -t registry.digitalocean.com/twd/talk-with-data:latest .

alternatively, configure a new tag for the existing image

docker tag talk-with-data registry.digitalocean.com/<my-registry>/<my-image>

Push the image to the container registry docker push registry.digitalocean.com/<my-registry>/<my-image>

9. Spin containers up in the Digital Ocean Droplet

Now, that the Docker imagefor the NextJS app is available on Digital Ocean, let's utilize the image to spin a container up.

docker run -it --rm -p 80:3000 registry.digitalocean.com/twd/talk-with-data:latest

Finally let's configure some additional firewall rules so that the application is accessible through the internet. SSH into your droplet and execute the following commands:

Digital_Ocean_Firewall
sudo ufw status #Check the status of the firewall
sudo ufw enable #enable the firewall if it isn't active
sudo ufw allow http
sudo ufw allow https #Enable HTTP and HTTPS traffic
sudo ufw allow ‘Nginx Full’ #We will also be enabling reverse proxying through Nginx, in the next article.

When you access the Firewall settings through the Digital Ocean droplet's console, the Firewall settings should look like this:

CICD
Figure 13: Inbound Firewall Settings
CICD
Figure 14: Outbound Firewall Settings

There you go! You should have a docker container running successfully on your Digital Ocean droplet.

4. Github Actions

Well, that was interesting! Now, whenever you make changes to the app, you wouldn't want to manually deploy these changes to the Digital Ocean droplet, or any server for that matter. This is where CICD comes into the picture.

Let's create a CICD workflow with Github actions!

1. Create a Github Actions workflows

Navigate to the .github directory under your root directory. Create a directory titled Workflows. In workflows create a yaml file. I have named mine build-and-deploy.yml.

build-and-deply.yml
name: Continuous Integration and Deployment

# Control when the workflow is triggered
on:
  push:
    branches: [main, feature/*]

  workflow_dispatch:
    inputs:
      version:
        description: 'Docker Image Version'
        required: true

#Variables

env:
  # REGISTRY: 'registry.digitalocean.com/talk-with-data'
  REGISTRY: 'registry.digitalocean.com/twd-registry'
  IMAGE_NAME: 'frontend'
  DIRECTORY: 'talk-with-data'
  TASKWEAVER: 'taskweaver'

#Job Descriptions

jobs:
  build-and-push-image:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout the repository
        uses: actions/checkout@v3

      - name: Build Docker Image
        run: docker build -t $(echo $REGISTRY)/$(echo $IMAGE_NAME):$(echo $GITHUB_SHA | head -c7) .

      - name: Install Digital Ocean CLI
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.TWD_DIGITALOCEAN_ACESS_TOKEN}}

      - name: Login to Digital Ocean Container Registry
        run: doctl registry login --expiry-seconds 600

      - name: Remove obsolete images
        run: if [ ! -z "$(doctl registry repository list | grep "$(echo $IMAGE_NAME)")" ]; then doctl registry repository delete-manifest $(echo $IMAGE_NAME) $(doctl registry repository list-tags $(echo $IMAGE_NAME) | grep -o "sha.*") --force; else echo "No repository"; fi

      - name: Push Docker Image to Digital Ocean Container Registry
        run: docker push $(echo $REGISTRY)/$(echo $IMAGE_NAME):$(echo $GITHUB_SHA | head -c7)

  deploy:
    runs-on: ubuntu-latest
    needs: build-and-push-image

    steps:
      - name: Deploy to Digital Ocean Droplet via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{secrets.TWD_HOST}}
          username: ${{secrets.USERNAME}}
          key: ${{secrets.SSH_KEY}}
          envs: IMAGE_NAME, REGISTRY, GITHUB_SHA, DIRECTORY, TASKWEAVER, {{secrets.TWD_DIGITALOCEAN_ACESS_TOKEN}}
          script: |
            echo "Current user: $(whoami)"

            cd "${DIRECTORY}"

            echo "Current directory: $(pwd)"

            docker login -u ${{secrets.TWD_DIGITALOCEAN_ACESS_TOKEN}} -p ${{secrets.TWD_DIGITALOCEAN_ACESS_TOKEN}} registry.digitalocean.com

            echo "Terminating existing Docker containers..."

            docker compose down --remove-orphans

            docker stop $(docker ps -q)

            docker rm -f $(docker ps -aq)

            doctl auth init --access-token ${{secrets.TWD_DIGITALOCEAN_ACESS_TOKEN}}

            export TAG_TASKWEAVER=$(doctl registry repository list-tags $(echo ${TASKWEAVER}) --format Tag | tail -n +2)

            export TAG_FRONTEND=$(echo ${GITHUB_SHA} | head -c7)

            echo "::set-output name=Tag-Frontend::$TAG_FRONTEND"

            echo "::set-output name=Tag-Taskweaver::$TAG_TASKWEAVER"

            # docker run -d --name $(echo $IMAGE_NAME)-$(echo $TAG_FRONTEND) -p 3000:3000 $(echo $REGISTRY)/$(echo $IMAGE_NAME):$(echo $TAG_FRONTEND)

            # docker run -d --name $(echo $TASKWEAVER)-$(echo $TAG_TASKWEAVER) -p 8000:8000 $(echo $REGISTRY)/$(echo $TASKWEAVER):$(echo $TAG_TASKWEAVER)

            docker compose up -d

            docker container prune -f

Let's also create the required Secrets on Github. Navigate to your Github repository. Then navigate to Settings -> Secrets & Variables -> Actions.

Configure variables for:

  1. Host (Droplet's public Ipv4 Address)
  2. Username (Usually root)
  3. Digital Ocean Personal Access token
  4. SSH Private Key obtained whilst creating a SSH key for the droplet. Ensure that you copy the entire key, including the "begin" and "end" parts.
CICD
Figure 15: Github Secrets

2. Push the workflow

CICD
Figure 16: CICD Pipeline

3. Verify that the pipeline works as expected

CICD
Figure 17: Successfull Deployment

References:

Footnotes

  1. https://www.intuz.com/blog/5-reasons-why-you-should-use-next.js-for-your-front-end-development 2

  2. https://ralabs.org/blog/why-to-use-next-js-6-key-benefits/ 2 3

  3. https://dev.to/richkurtzman/advantages-and-disadvantages-of-nextjs-5hg6

  4. https://pagepro.co/blog/pros-and-cons-of-nextjs/

  5. https://github.com/digitalocean/doctl/issues/591

  6. https://github.com/appleboy/ssh-action

  7. https://docs.digitalocean.com/reference/doctl/how-to/install/