Dockerizing a Next.js Application with GitHub Actions

Dockerizing a Next.js Application with GitHub Actions

In this article, we'll explore how to Dockerize a Next.js application and automate its deployment using GitHub Actions, thereby simplifying the deployment workflow and enhancing development productivity.

Prerequisites

Before we dive into Dockerizing our Next.js application and setting up GitHub Actions for deployment, ensure you have the following prerequisites:

  1. A Next.js project.
  2. Docker installed on your local machine.
  3. A GitHub repository for your Next.js project.

Setting up Docker

Docker allows you to package your application and its dependencies into a container, ensuring consistency across different environments. Start by creating a Dockerfile in the root of your Next.js project:

docker
Copy code
FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi


# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN \
  if [ -f yarn.lock ]; then yarn run build; \
  elif [ -f package-lock.json ]; then npm run build; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
  else echo "Lockfile not found." && exit 1; \
  fi

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]

This Dockerfile is the default Dockerfile provided by Vercel to set up a Node.js environment, install dependencies, build the Next.js application, and exposing port 3000.

You have to ensure you are using output: "standalone" in your next.config.js.

javascript
Copy code
const nextConfig = {
  output: "standalone",
}

The standalone mode in Next.js builds a self-contained application that includes all necessary files, libraries, and dependencies required to run the application. This contrasts with the default mode ("experimental-serverless-trace"), which generates smaller bundles but relies on additional runtime steps.

Before proceeding further, it's crucial to test our Dockerized Next.js application locally to ensure everything functions as expected. Open a terminal in the project directory and execute the following commands:

bash
Copy code
# Build the Docker image
docker build -t my-nextjs-app .

# Run the Docker container
docker run -p 3000:3000 my-nextjs-app

Visit http://localhost:3000 in your web browser to verify that your Next.js application is running within the Docker container.

Setting up GitHub Actions for Continuous Deployment:

GitHub Actions automate the CI/CD pipeline directly from your GitHub repository. Basically the pipeline looks like this:

  • Code Commit & Push: Developer writes code and pushes changes to a GitHub repository. This triggers the GitHub Actions workflow, defined in .github/workflows/docker-ci.yml.
yaml
Copy code
name: Build and Deploy Next.js

on:
  push:
    branches:
      - main # Triggers when code is pushed to the main branch

Once the workflow is triggered, the following steps occur:

  • Checkout Repository: Pulls the latest code from GitHub.
    yaml
    Copy code
    jobs:
    build:
      runs-on: ubuntu-latest
    
      steps:
        - name: Checkout repository
          uses: actions/checkout@v3
  • Install Dependencies & Build Next.js: Installs project dependencies and builds the Next.js app in standalone mode.
    yaml
    Copy code
    - name: Install dependencies
          run: npm install
    
        - name: Build Next.js app
          run: npm run build
  • Run Tests (optional but highly recommended): Ensures the application works before deployment.
    yaml
    Copy code
    - name: Run tests
          run: npm run test
  • Build and Tag Docker Image: Creates a Docker image with the Next.js app.
    yaml
    Copy code
    - name: Build Docker Image
          run: docker build -t myapp:latest .
  • Push Docker Image to Registry: Authenticates and pushes the image to Docker Hub or GitHub Container Registry.
    yaml
    Copy code
    - name: Log in to Docker Hub
          run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
    
        - name: Push

Putting it together: Create a .github/workflows/pipeline.yml file with the following content if you want to publish your docker images to Docker Hub and GitHub. If you just want to use one of them you have to remove the according login step and remove the according tags.

yaml
Copy code
name: Docker Build & Publish

on:
  push:
    branches: [main]

jobs:
  push_to_registries:
    name: Push Docker image to multiple registries
    runs-on: ubuntu-latest
    permissions:
      packages: write
      contents: read
      attestations: write
      id-token: write

    steps:
      - name: Check out repository code 🛎️
        uses: actions/checkout@v4

      - name: Set up Docker Buildx 🚀
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub 🚢
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME}}
          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN}}

      - name: Log in to the Container registry
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
          
      - name: Build and push 🏗️
        uses: docker/build-push-action@v2
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: |
            ${{ secrets.DOCKER_HUB_USERNAME}}/{docker_repository}:${{ github.sha }}
            ${{ secrets.DOCKER_HUB_USERNAME}}/{docker_repository}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest

If you want to publish to Docker Hub you have to store secrets ${{ secrets.DOCKER_USERNAME }} and ${{ secrets.DOCKER_PASSWORD }} in your repository's under settings -> Secrets and variables -> Actions -> Repository secrets.

This workflow will build your container from your GitHub repositiory and push it to your Docker Container registry with two tags:

  • :latest and
  • :{github.sha}

Passing environment variables to the Workflow

In case you need some environment variables you have to adjust the Dockerfile with some additional parameters. To be able to use environment variables which are stored in your repository as secrets you will need to mount and export every environment variable like the following to your npm run build command.

bash
Copy code
RUN --mount=type=secret,id=NEXT_PUBLIC_CMS_URL \
  export NEXT_PUBLIC_CMS_URL=$(cat /run/secrets/NEXT_PUBLIC_CMS_URL) && \
  npm run build

You can have a look at the Dockerfile for my site for a example: personal website Dockerfile.

Also you will need to modify the step Build and push in the workflow like this:

yaml
Copy code
- name: Build and push 🏗️
  uses: docker/build-push-action@v2
  with:
      context: .
      file: ./Dockerfile
      push: true
      tags: |
        ${{ secrets.DOCKER_HUB_USERNAME}}/personal-website:${{ github.sha }}
        ${{ secrets.DOCKER_HUB_USERNAME}}/personal-website:latest
      secrets: |
        "NEXT_PUBLIC_STRAPI_API_URL=${{ secrets.NEXT_PUBLIC_CMS_URL }}"

Conclusion

With this setup, every push to the main branch of your GitHub repository triggers the CI/CD pipeline. Continuous Integration and Continuous Deployment for Dockerized Next.js applications provide a streamlined and efficient development process, ensuring that your application is always in a deployable state. By combining GitHub Actions with Docker, you can automate the deployment process and focus on building and improving your Next.js application.

Table of Contents