Deploy a Next.js app to dokku & S3

Posted on: Sunday, 27 December 2020
← Back to the Blog

This post outlines how to release your Next.js app with CI/CD (GitHub actions), and covers how to:

  • Package a Next.js application using Docker
  • Publish the docker image to the GitHub Container Registry
  • Publish static assets to S3 (and distribute with CloudFront)
  • Deploy the docker image to dokku

Motivation

  • Builds should never happen on the runtime dokku server as it affects runtime performance (front-end builds using webpack etc can take up a LOT of RAM and CPU)
  • Deployments should be continuous and automated
  • The runtime Node.js server should not be concerned with serving immutable static assets

Next.js Config

In file next.config.js:

const isProd = process.env.NODE_ENV === 'production';
module.exports = {
  assetPrefix: isProd ? process.env.ASSET_PREFIX || 'https://assets.example.com' : '';
  generateBuildId: () => {
    return process.env.APP_VERSION || `${new Date().getTime()}`;
  },
};

Creating the Dockerfile

The following Dockerfile shows how to use multi-stage builds to package a Next.js app.

Please update where appropriate (at least EMAIL, GITHUB_USER, REPO_NAME & DESCRIPTION).

FROM node:14.15.3-alpine as base
ARG APP_VERSION
ARG ASSET_PREFIX
ENV APP_VERSION $APP_VERSION
ENV ASSET_PREFIX $ASSET_PREFIX


FROM base AS builder

WORKDIR /app

RUN apk update && apk add curl
RUN curl -sf https://gobinaries.com/tj/node-prune | sh

ENV NPM_CONFIG_LOGLEVEL warn
ENV NPM_CONFIG_FUND false
ENV NPM_CONFIG_AUDIT false
ENV CI true

COPY package.json package-lock.json ./

RUN npm ci
COPY . .

RUN NODE_ENV=production npm run build
RUN npm prune --production
RUN node-prune node_modules


FROM base

LABEL maintainer=EMAIL
LABEL org.opencontainers.image.source https://github.com/GITHUB_USER/REPO_NAME
LABEL org.label-schema.name="REPO_NAME"
LABEL org.label-schema.description="DESCRIPTION"
LABEL org.label-schema.vcs-url="https://github.com/GITHUB_USER/REPO_NAME"
LABEL org.label-schema.usage="README.md"
LABEL org.label-schema.vendor="GITHUB_USER"

ENV NPM_CONFIG_LOGLEVEL warn
ENV NODE_ENV production
ENV PORT 3000
ENV APP_HOME /app

RUN mkdir -p $APP_HOME && chown -R node:node $APP_HOME
WORKDIR $APP_HOME

COPY --from=builder --chown=node:node $APP_HOME/package.json $APP_HOME/package.json
COPY --from=builder --chown=node:node $APP_HOME/node_modules $APP_HOME/node_modules
COPY --from=builder --chown=node:node $APP_HOME/.next $APP_HOME/.next
COPY --from=builder --chown=node:node $APP_HOME/next.config.js $APP_HOME/next.config.js
COPY --from=builder --chown=node:node $APP_HOME/public $APP_HOME/public

EXPOSE 3000

USER node

CMD ["npm", "start"]

Notes:

  • Multi-stage builds are used to reduce the final size of the container.
  • A base image is used to share environment variables between build & runtime stages.
  • npm prune --production and node-prune are used to reduce the final size of node_modules/
  • No process manager is used (eg pm2) as by default Dokku will automatically restart containers that exit with a non-zero, so there's no need for an extra process manager.
  • npm is used to start the process as it handles termination signals (eg SIGINT) for us. If your app handles these signals itself, then you'll need to start the process with node.

I'm not really happy with the final image size (+-300mb) but there's not much I can do about that it's mostly due to app dependencies within node_modules.

Create & Deploy the dokku App

First build the app image locally, setting ASSET_PREFIX=/ to serve assets from the app instead of S3.

docker build -t ghcr.io/GITHUB_USER/DOKKU_APP_NAME:latest --build-arg ASSET_PREFIX=/ .

Run the app locally to test everything works as expected:

docker run --publish 3000:3000 ghcr.io/GITHUB_USER/DOKKU_APP_NAME:latest

Publish the image to GitHub container registry:

echo $CR_PAT | docker login ghcr.io -u GITHUB_USER --password-stdin
docker build -t ghcr.io/GITHUB_USER/DOKKU_APP_NAME:latest .
docker push ghcr.io/GITHUB_USER/DOKKU_APP_NAME:latest

On your dokku server:

# create the app
dokku apps:create DOKKU_APP_NAME
dokku proxy:ports-add DOKKU_APP_NAME http:80:3000
dokku proxy:ports-remove DOKKU_APP_NAME http:3000:3000

# deploy the app
echo $CR_PAT | docker login ghcr.io -u GITHUB_USER --password-stdin
docker pull ghcr.io/GITHUB_USER/DOKKU_APP_NAME:latest
docker tag ghcr.io/GITHUB_USER/DOKKU_APP_NAME:latest dokku/DOKKU_APP_NAME:latest
dokku tags:deploy DOKKU_APP_NAME latest

# add custom domain and TLS
dokku domains:add DOKKU_APP_NAME example.com
dokku letsencrypt DOKKU_APP_NAME

Set up S3 & CloudFront

An S3 bucket is required to host all static assets for all app versions.

Refer to Set up CloudFront & S3 to set this up.

Continuous deployment with GitHub Actions

While this process works, I don't recommend it any more and suggest reading Deploy a dokku App With a Remote Docker Image to view alternative approaches without having to use a root user in CI.

We'll use GitHub Actions to:

  1. Build the docker image and publish it to the container registry
  2. Extract the static assets from the docker image and publish them to S3
  3. Deploy the app to dokku using GitHub Actions

Set up Remote Dokku access

You'll need a SSH keypair created (without a passphrase) to allow the GitHub Action runner to SSH to the dokku server.

On your dokku server:

ssh-keygen -N "" -f /root/.ssh/githubactions

Add the githubactions public key to authorized_keys:

cat /root/.ssh/githubactions.pub >> /root/.ssh/authorized_keys

Now copy the private key which you'll use in the next step:

cat /root/.ssh/githubactions

Repo Secrets

You'll need to set the following secrets in your Github repo:

  • AWS_ACCESS_KEY_ID
  • AWS_S3_BUCKET
  • AWS_SECRET_ACCESS_KEY
  • CR_PAT (GitHub Container Registry Personal Access Token)
  • DOKKU_HOST
  • DOKKU_SSH_PRIVATE_KEY

Workflows

Release drafter

I use release drafter to automatically draft new releases and bump versions and I find it really useful. I suggest setting it up on your project.

Publish

The following workflow file demonstrates how to release the app when creating a new GitHub Release. Place this file in location .github/workflows/publish.yml:

name: Publish
on:
  release:
    types: [published]

jobs:
  publish-docker:
    name: Publish docker image
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v2
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v1
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1
      - name: Cache Docker layers
        uses: actions/cache@v2
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-
      - 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 image
        uses: docker/build-push-action@v2
        with:
          context: .
          file: ./Dockerfile
          push: true
          platforms: linux/amd64
          tags: ghcr.io/${{ github.repository_owner }}/app:latest
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache
          build-args: |
            APP_VERSION=${{ github.event.release.tag_name }}
  publish-s3:
    name: Publish to S3
    needs: [publish-docker]
    runs-on: ubuntu-20.04
    steps:
      - name: Login to GitHub Container Registry
        uses: docker/login-action@v1
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.CR_PAT }}
      - name: Copy static files from docker image
        run: |
          docker pull ghcr.io/${{ github.repository_owner }}/app:latest
          docker run -i --name helper ghcr.io/${{ github.repository_owner }}/app:latest true
          docker cp helper:/app/.next .
          docker rm helper
      - name: Sync static assets to S3
        uses: jakejarvis/s3-sync-action@master
        with:
          args: '--cache-control public,max-age=31536000,immutable --size-only'
        env:
          AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: 'us-east-1'
          SOURCE_DIR: '.next/static'
          DEST_DIR: '_next/static'
  deploy:
    name: Deploy app
    needs: [publish-docker, publish-s3]
    runs-on: ubuntu-20.04
    steps:
      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DOKKU_SSH_PRIVATE_KEY }}" | tr -d '\r' > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan ${{ secrets.DOKKU_HOST }} >> ~/.ssh/known_hosts
      - name: Deploy app
        run: |
          ssh root@${{ secrets.DOKKU_HOST }} "\\
            docker pull ghcr.io/${{ github.repository_owner }}/app:latest && \\
            docker tag ghcr.io/${{ github.repository_owner }}/app:latest dokku/app:latest && \\
            dokku config:set --no-restart app APP_VERSION=\"${{ github.event.release.tag_name }}\" && \\
            dokku tags:deploy app latest && \\
            dokku cleanup"

Notes:

  • Static assets are copied out of the docker image as the file hashes don't match when building in different OS environments (even when setting the Next.js build ID).
  • APP_VERSION is used as the build ID and is set from the GitHub Release tag value

Release Workflow

  • Add new features with pull requests
  • Release drafter will drafter new releases based on merged pull requests, as well as bump the next release version
  • When happy to release, simply publish the latest draft release
  • This will trigger the publish workflow and the app will be deployed using the release tag version

Comments

No comments

Add a new comment

Markdown supported