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:
- Checkout the repository to the runner VM
- Copy the built client code files and Node.js server code to the webserver (rsync should suffice)
- 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