CI/CD With Github Actions: Zero Downtime Deployments
July 10, 2023Quick, easy deployments are a staple of any advanced workflow. With GitHub Actions, we can automate our deployments from a push to our repository, ensuring our code is deployed as soon as it is pushed.
Doing so requires a quick understanding of your project architecture as well as of the system to which you're deploying. In this example we have a monorepo containing a Nuxt app with a Laravel backend, all of which gets hosted on Digital Ocean VPS
Here is what the project looks like.
/app-root
- client -> nuxt app
- .output -> deploy artifact generated by the build command.
- api -> standard laravel install
I like to use a separate deployment strategy for each package in the repo. If only the client has changes, we don't need to deploy the API along with it.
To set this up in Github Actions, we'll create two yaml files called web.yaml and api.yaml. These are our Action configuration files and will live in a new directory of .github/workflows. They're a set of instructions on how Github should interact with our codebase before and after depoying.
/app-root
- .github
- workflows
- client.yaml
- api.yaml
- web
- .output
- api
Let's look at the client.yaml first. The first thing any Action needs is a name, and instructions on when the action should run.
In ours this looks like this:
name: Nuxt CI/CD
on:
push:
branches:
- main
paths:
- 'web/**'
- '.github/workflows/web.yaml'
This says run this action when the main branch is pushed to Github, but only when files in the web directory are changed, or when the web.yaml itself has been changed. This is the heart of how our separate deployment startegy will work.
If there's only one app in your repo, or you're doing a unified deployment across all packages, you can exclude the paths options.
The next thing in the yaml file will be to define the specific jobs that make up the action. In our case, we want to 1) build the app and 2) deploy it to our server. Each job is made of a individual steps, like installing dependencies, running tests, etc.
Lets look at the build job:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install Dependencies
working-directory: ./web
run: npm ci
- name: Run Tests
working-directory: ./web
run: npm run test
- name: Build App
working-directory: ./web
run: |
npm run build
tar -czf .output.tar.gz .output ecosystem.config.js
- name: Archive artifact
uses: actions/upload-artifact@v2
with:
name: app
path: ./web/.output.tar.gz
if-no-files-found: error
The biggest thing that helped me understand writing github actions is that you are just writing low-level instructions for how to interact with your code. From the ground level of spinning up a Linux instance (runs-on: ubuntu-latest) to running the actual build command.
The first step is to checkout the code from the repo. This clones the repo in the and automatically navigates into the root directory.
With the option working-directory, we give instructions to navigate to the directory where our Nuxt App is stored. We run our clean-install command, npm ci, and if nothing bad happens, we move down the chain, again setting the working-directory option before running our build script and compressing the build directory. I want to bundle in the pm2 configuration which I'll need on the server as well.
The build dir gets compressed using tar because I want to quickly create an Artifact which I can pass along "Deploy" job. This step uses a premade mini-action which is super helpful. It took me a minute to understand how to configure this, but it got there in the end.
Let's move on to the deploy step:
deploy:
runs-on: ubuntu-latest
needs: build
env:
ROOT_DIR: /var/www/app-root
APP_DIR: /var/www/app-root/web
RELEASE_DIR: /var/www/app-root/web/releases/${{ github.sha }}
steps:
- name: Download artifact
uses: actions/download-artifact@v2
with:
name: app
- name: Upload via SCP
uses: appleboy/scp-action@v0.1.4
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: '*'
target: /var/www/app-root/web/releases/${{ github.sha }}
- name: Symlink and Reload PM2
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
envs: ROOT_DIR,RELEASE_DIR,APP_DIR
script: |
export ROOT_DIR=$ROOT_DIR
export RELEASE_DIR=$RELEASE_DIR
tar -xzf $RELEASE_DIR/.output.tar.gz -C $RELEASE_DIR/
rm -rf /var/www/app-root/web/current
ln -s $RELEASE_DIR /var/www/app-root/web/current
cd /var/www/app-root/web/current
pm2 reload ecosystem.config.js --env production
This option is where the fun happens, and will rely on your understanding of the VPS you're using.
I've used the needs option on this step to say this "This step needs the Build step." If the build step fails, don't run the deploy step. I've also set up some env variables that I'll use throughout the process.
The SERVER_HOST, SERVER_USER, and SSH_PRIVATE_KEY can be set up in your repo settings. The host is the IP or domain of your VPS.
To understand the structure of the Deploy step, let's diagram out the finished system on the sever.
/var/www/app-root
- web
- current
- releases
- eb234091 -> github sha,
- api
- current
- releases
- eb234091
The current directory is what our Nginx Reverse Proxy will be pointing to. And so, to ensure a minimal downtime as possible, we are going to deploy our codebase to the web/releases directory, where the latest changes will be stored in a directory named for the commit SHA.
After the eb234091 directory is ready, we will symlink that directory to the current dir and then restart our PM2 instance.
To bring that back down to our Deploy job, we will first download the artifact we created at the end of the build step. Here the name option must match the name option provided in the last job.
- name: Download artifact
uses: actions/download-artifact@v2
with:
name: app -> must match the upload artifact name
We are then going to use the premade SCP step to upload our compressed directory to the server.
- name: Upload via SCP
uses: appleboy/scp-action@v0.1.4
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: '*'
target: /var/www/app-root/web/releases/${{ github.sha }}
The target is where we want our files to be transfered to, including the directory name, which in our case is the github commit SHA.
The final step is to prepare the code for release.
- name: Symlink and Reload PM2
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
envs: ROOT_DIR,RELEASE_DIR,APP_DIR
script: |
export ROOT_DIR=$ROOT_DIR
export RELEASE_DIR=$RELEASE_DIR
tar -xzf $RELEASE_DIR/.output.tar.gz -C $RELEASE_DIR/
rm -rf /var/www/app-root/web/current
ln -s $RELEASE_DIR /var/www/app-root/web/current
cd /var/www/app-root/web/current
pm2 reload ecosystem.config.js --env production
In this step, we SSH into the server, and execute the specific commands we need to get the app ready.
We first uncompress the .output directory. We break symlink to the existing /current deployment, and then immediately symlink the latest release to /current. Finally we tell PM2 to reload the app instance, and bob is your uncle.
The thing to note in this step in the envs option and the first lines of the script setting those variables on the VPS terminal. AFAIK, this is the only way to pass in env variables defined in the job specifically to the SSH-ACTION script option.
For reference, the whole file is here in a gist: nuxt-monorepo-github-action.yaml