Home /

CI/CD With Github Actions: Zero Downtime Deployments

July 10, 2023
By: Colin Rabyniuk

Quick, 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