Deploy a Next.js app to dokku & S3
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 $APP_HOME/package.json $APP_HOME/package.json
COPY $APP_HOME/node_modules $APP_HOME/node_modules
COPY $APP_HOME/.next $APP_HOME/.next
COPY $APP_HOME/next.config.js $APP_HOME/next.config.js
COPY $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 ofnode_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 (egSIGINT
) for us. If your app handles these signals itself, then you'll need to start the process withnode
.
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:
- Build the docker image and publish it to the container registry
- Extract the static assets from the docker image and publish them to S3
- 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: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Sync static assets to S3
run: aws s3 sync .next/static s3://${{ secrets.AWS_S3_BUCKET }}/_next/static --cache-control public,max-age=31536000,immutable --size-only
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
(No comments)