For this site, I have a monorepo containing both code for my Node.js server and Next.js frontend. You can see the code here. I want to use GitHub actions to set-up basic CI/CD for my site.

Here's what I want to do:

  1. Checkout the repository to the runner VM
  2. Copy the built client code files and Node.js server code to the webserver (rsync should suffice)
  3. Restart the Next.js and Node.js servers to apply changes to the actual site

Step 2 is best performed with rsync. I've set-up systemd services for the Next.js server and Node.js server, so restarting those for step 3 will be simple enough. Later on I'd also like to add some end-to-end testing (with something like Cypress), for now a simple build and deploy will suffice.

Here is the workflow I settled down with.

I'll go through it step by step.

name: Deploy to webserver

on:
  push:
    branches:
      - main
  workflow_dispatch:

My main workflow is triggered on push to main or on manual activation. I've separated the workflow into two steps; build and deploy.

  build:
    runs-on: ubuntu-latest

    steps:
      - name: git checkout
        uses: actions/checkout@v5.0.0
      
      - name: setup Node.js
        uses: actions/setup-node@v6.0.0
        with:
          node-version: '20.9.0'
          cache: 'npm'
          cache-dependency-path: |
            client/package-lock.json
            server/package-lock.json

We start with checking out the code and setting up Node.js in the client and server paths. I'm using GitHub's hosted Ubuntu runner, while I am running my webserver on Debian 12. This should not be an issue, as I am using platform agnostic code/modules. Should I end up using a build that is platform specific, I may have to install my own runner or build a custom Debian docker container.

We continue with installing the client and server dependencies with npm. We use npm ci to perform a clean install from package-lock.json. We then perform the client build.

      - name: install client dependencies
        working-directory: ./client
        run: npm ci

      - name: install server dependencies
        working-directory: ./server
        run: npm ci

      - name: build Next.js
        working-directory: ./client
        run: npm run build
        
      - name: Prepare artifact directory
        run: |
          mkdir -p artifact
          cp -r client artifact/
          cp -r server artifact/
          cp package.json artifact/ 2>/dev/null || true
          ls -la artifact/client/.next/
      
      - name: Upload artifact
        uses: actions/upload-artifact@v5.0.0
        with:
          name: blog-build
          path: artifact/
          retention-days: 1
          include-hidden-files: true

Note that I manually set include-hidden-files to true, in order to copy the client code's .next directory post-build. By default, this is now false to avoid accidentally copying sensitive dot files. My friend Claude was not aware of this :^) Because I want to use these build files on my remote server, and because I separated the build and deploy into separate steps, we need to upload the files to a GitHub artifact. In the deploy job, we will download this artifact to the runner.

Speaking of the deploy job:

deploy:
    runs-on: ubuntu-latest
    needs: build
    
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v5.0.0
        with:
          name: blog-build
          path: ./build
      
      - name: rsync deployments
        uses: burnett01/rsync-deployments@7.1.0
        with:
          switches: -avvzr --delete --exclude='.git' --exclude='node_modules' --exclude='.env' --exclude='.env.local' --exclude='**/.next/cache/'
          path: ./build/
          remote_path: ${{ secrets.REMOTE_PATH }}
          remote_host: ${{ secrets.SERVER_IP }}
          remote_user: ${{ secrets.SERVER_USER }}
          remote_key: ${{ secrets.SSH_PRIVATE_KEY }}
          remote_port: ${{ secrets.SSH_PORT }}

We download the artifact we just uploaded, and rsync those files. I'm using burnett01's rsync deployments, which utilizes a lightweight alpine container with rsync installed. It makes defining the input arguments very straightforward. As you can see above, I define most of remote parameters in GitHub secrets.

Finally, over ssh, I install the npm dependencies for my client and server code and restart the corresponding systemd services.

      - name: install client dependenceies
        uses: appleboy/ssh-action@v1.2.3
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            source ~/.nvm/nvm.sh
            cd ${{ secrets.REMOTE_PATH }}/client
            npm ci --omit=dev

      - name: install server dependenceies
        uses: appleboy/ssh-action@v1.2.3
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            source ~/.nvm/nvm.sh
            cd ${{ secrets.REMOTE_PATH }}/server
            npm ci --omit=dev

      - name: Restart services
        uses: appleboy/ssh-action@v1.2.3
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            sudo systemctl restart blog-frontend.service
            sudo systemctl restart blog-api.service
            sudo systemctl status blog-frontend --no-pager
            sudo systemctl status blog-api --no-pager

One issue with the last step I observed is that the service could fail after the status command runs, thus the job would exit with success while the webserver was in a failed state. This happened to me while I didn't realize the .next folder was not being uploaded to the artifact. Maybe a simple way to avoid this would be to just sleep for a few seconds. Any thoughts from the readers? There's no way for you to tell me, because I haven't implemented comments. I guess you could send me an email. Man, I need to find a job.

With this all in place, my website will be built and deployed to my webserver automatically. This will be a great assist in rolling out features quickly and incrementally. As I touched before, I'd like to add some tests steps/a separate test job. The Cypress GitHub action should be of use in that regard, so keep an eye out for that, eventually.

Until next time - Div