CI/CD with Next.js, GitHub Actions, and Octopus Deploy

CI/CD with Next.js, GitHub Actions, and Octopus Deploy

Phil Stephenson

CI/CD with Next.js, GitHub Actions, and Octopus Deploy

Popular frameworks like Next.js and Create React App support features to bundle your site's assets into files, but deploying those assets somewhere with a web server is up to you. In this post, I'll use GitHub Actions to bundle a Next.js blog and deploy it to AWS S3 using Octopus Deploy.

Our project's source code can be found in our Octopus Sample GitHub repo.

Build and package

Before we can deploy our blog using Octopus, we'll need to package the site and push it to a package repository. Packaging our site is part of building a deployment pipeline, and you can read more about why it's important in the post Packaging Node.js applications.

For simplicity, we'll use Octopus's built in repository. And since our project is already hosted on GitHub, let's set up a GitHub Action that helps us create our package. Our workflow should look something like this:

For each push to our main branch, we will:

  • Checkout the source code.
  • Run npm ci to get our node_modules dependencies (implicit in this step is having node.js set up in our Actions environment).
  • Tag our commit with a new version number.
  • Use next export to generate our static asset files.
  • Bundle those assets into our package.
  • Finally, push our package to the Octopus built-in repository.

Let's start with a GitHub Action template that checks out our source code, runs npm ci, and generates our assets:

// main.yml - https://github.com/OctopusSamples/nextjs-blog/blob/main/.github/workflows/main.yml
on:
  push:
    branches:
      - main
jobs:
  release:
    name: Create Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup Node.js
        uses: actions/setup-node@v2
        run: |
          npm ci
          npm run export

npm run export runs the following npm script in our package.json file:

"scripts": {
  "build": "next build",
  "export": "next build && next export"
},

See next.js documentation for more info about next export.

This is a good start, but now we need some way to create new version numbers for our package.

Tagging with semantic-release

Each package in the Octopus built-in feed requires an ID and a version number, so that the filename use this format ID.version.ext. For example: nextjs-blog.0.1.0.zip. The version numbers must be valid semantic versions. You could just tag your releases manually each time you want to release, but that's time consuming and requires a human. If you want to automate this process, building logic with a home-rolled solution to generate new, valid semantic versions is challenging. Luckily, there's an excellent open source project called semantic-release that does just that.

semantic-release evaluates our commit messages based on some pre-defined convention. Depending on the format of our recent commit messages, the library will generate the next appropriate semantic version after finding the most recent version and updating either the major, minor, or patch version accordingly. The details of this library are out of scope for this post, but definitely check this project out if you've never used it before.

There's even a community contributed GitHub Action for Semantic Release. Let's use this project to generate our new version automatically and tag our commit:

...
  - name: Tag and Create Release
    id: semantic
    uses: cycjimmy/semantic-release-action@v2
    env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Because our primary git branch is named main, we needed a small piece of configuration in our package.json to tell semantic-release to evaluate commits only on pushes to the main branch:

"release": {
  "branches": [
    "main"
  ]
}

Now that we've built and tagged our new release, it's time to create our package and publish it to Octopus.

Pack and publish with octopackjs

If you've ever tried creating packages with plain ol' npm, it can be quite frustrating.

  • The npm pack command exposes very few CLI parameters.
  • You cannot explicitly define the name of the generated package.
  • The version of the package must come from the version key in package.json and cannot be overridden with a CLI parameter.
  • Your package root must contain the package.json file.
  • You cannot easily define the directory structure that ultimately ends up in your package.

It just seems as if npm was designed exclusively for the bundling of your packages for consumption by other npm projects - not for deployment.

octopackjs is an open source project maintained by Octopus that is designed for bundling and pushing your packages to an Octopus Server. After running npm run export, Next.js places our static asset files in a directory named out. Let's write a small node script using octopackjs to package that directory and push it to our Octopus Server:

// publish.js - https://github.com/OctopusSamples/nextjs-blog/blob/main/publish.js
const octo = require('@octopusdeploy/octopackjs');
const octopusUrl = 'https://samples.octopus.app';
octo.pack()
    .appendSubDir('out', true)
    .toFile('.', (err, data) => {
        console.log('Package Saved: ' + data.name);
        octo.push(data.name, {
            host: octopusUrl,
            apikey: 'MY-API-KEY',
            spaceId: 'Spaces-604',
            replace: true
        },
        err => err ? console.error(err.body) : console.log('Package Pushed to ' + octopusUrl));
    });

See our documentation for creating API keys for use with Octopus Deploy. Security conscious readers might notice right away that it appears my API key is just hard coded directly into our script which is a big no no! Let's use an environment variable here instead:

octo.push(data.name, {
    ...
    apikey: process.env.OCTOPUS_APIKEY,
    ...

We can inject this environment variable using an encrypted secret in GitHub Actions. First we'll add our OCTOPUS_APIKEY secret to our repository (follow the instructions in the Actions docs):

Encrypted Secret in GitHub repository screenshot

Next, we'll reference our secret in our main.yml GitHub Action template:

...
- if: steps.semantic.outputs.new_release_published == 'true'
name: Publish Package
env:
    OCTOPUS_APIKEY: ${{ secrets.OCTOPUS_APIKEY }}
run: |
    npm ci
    npm run export
    OCTOPUS_APIKEY="$OCTOPUS_APIKEY" node publish.js

Now that we've set up our Action, let's make a commit, push, and watch it go!

GitHub Action screenshot

In this example, we're pushing our package from GitHub Actions to our Octopus Cloud instances at https://samples.octopus.app. If you're running an Octopus Server that is not publicly accessible from github.com, you might instead consider pushing to a third-party package repository (e.g., Artifactory, Nexus) and have your Octopus Server pull from that repository by setting it up as an external feed or use a local GitHub Actions runner as explained in this post.

Deploy

Now that we've set up our continuous integration process, it's time to deploy our website. We'll use Octopus Deploy to upload our package to AWS S3. Conveniently, it already has a built in step template designed for this. For static content sites like the one we've built here, S3 buckets are a great choice because they require very little configuration (no need to install and configure a web server), are inexpensive, and of course you benefit from the reliability of the AWS cloud platform.

AWS S3

Setting up an S3 bucket is pretty simple and there are many tutorials out there to help with that so I won't walk through it here step by step. I do recommend following along with the AWS documentation specifically for Hosting a static website using Amazon S3.

Octopus needs an AWS AccessKey to upload packages to your S3 bucket. It's a good idea (although not mandatory) to set up a separate IAM user with explicit permissions to your new bucket. See this page for help with managing access keys for IAM users.

Save your Access Key ID and Key Secret somewhere safe and accessible. We'll need those two values to set up our AWS account in Octopus Deploy

Octopus Deploy

In the Octopus Accounts section, create a new AWS Account. I like the name of my account to match or reference the name of the AWS IAM user:

New Octopus AWS Account screenshot

Next, let's create a new Octopus project called nextjs-blog:

New Octopus Project nextjs-blog screenshot

To use our AWS Account in our nextjs-blog project, we need to create an AWS Account variable in the variables section of our project:

AWS Account variable screenshot

See the documentation for more information on AWS Account variables in Octopus.

Lastly, let's create the one and only step in our project by adding the Upload a package to an AWS S3 bucket step:

Upload to S3 bucket step template screenshot

Configuring this step is straightforward. You can follow along with our documentation, which explains the step template options and links to more information.

Pay attention to the Package Target options. By default, the step is set up to deploy the entire package file without extracting it. Our asset files are inside the package and we need them extracted and placed at the root of the bucket for S3 to serve them. To accomplish this, follow these steps:

  1. Select the Specific file(s) within the package option.
  2. Click ADD A FILE SELECTION.
  3. Select Multiple Files (as opposed to Single File).

Select Multiple Files option screenshot

The default file pattern **/* will select all files and directories in our package, which is exactly what we want.

  1. Next, enter a Canned Acl. Read more about these here. For my set up, I used bucket-owner-full-control

Finally, create a release, cross your fingers, and deploy!

If all goes well, we will see our website here: http://octopus-nextjs-sample.s3-website-us-west-2.amazonaws.com/

Next.js blog screenshot

Conclusion

Check out the project's source code on GitHub, and you can also see the deploy project set up in our Octopus Deploy Samples space.

If you want to serve your website using an SSL certificate, checkout the AWS CDN product CloudFront.

If you have a much larger Next.js site and it's impractical to generate static assets, Next.js also supports serving your app with a dynamic backend with Node.js. See this great Digital Ocean tutorial for setting up that kind of deployment for your Next.js app.

Thanks for reading, and happy deployments!

Loading...