Zero downtime CI/CD with GitHub Actions

N|Solid

An important part of any project is how to deploy it in a simple, effective and hopefully quick manner. As part of development for Linquito, on a relatively small side-project budget, we needed a cheap, elegant & reliable solution. As a nice-to-have, we also ideally wanted zero downtime deployments.

In this article we will be exploring how Linquito build a robust CI/CD process using GitHub Actions and Deployer.

Prerequisites

  • A GitHub account.
  • A project you wish to launch a VPS/EC2 container or equivalent.
  • SSH access to your VPS/EC2 and basic UNIX knowledge.
  • Understanding how to write/read YAML (basic knowledge is sufficient).
  • Basic knowledge of Git and GitHub.
  • Basic understanding of the Deployer library.

GitHub Actions

After evaluating several solutions, GitHub actions seemed like the clear winner to get Linquito off the ground. With GitHub Actions we can have the deployment, testing and project integration in one place, and tracked in our codebase! Best of all, GitHub Actions fits the cheap criteria as you’re billed per minute and it includes an attractive 2,000 free minutes per month per repository (at the time of writing). Other key benefits include:

  • Your runner supports multiple environments including: Linux, Windows, macOS, ARM, and other containers.
  • Concurrency - multiple jobs can run simultaneously (however you can prevent jobs running concurrently if required).
  • Multiple language support including Node.js, PHP, Python, Java, Ruby, Go, Rust etc.
  • Live logs; watch your actions run and their logs in realtime!
  • Easily manage secrets (although modifying existing secrets at the time of writing isn’t supported - you have to replace existing secret’s values so keep that in mind).
  • 2,000 free minutes per month with the ability to pay for additional minutes or run the GitHub runners locally/hosted for free!

Deployer

Deployer is a great tool written in PHP that can be used to deploy your application to your server(s). It supports many applications such as Laravel, Symfony and more. One of the greatest benefits with Deployer is it carries out a lot of the SSH commands for you in addition to sorting out permissions, so if your UNIX/Linux skills aren’t advanced or you still struggle with Linux permissions this can be a real time saver.

Lastly, it supports zero-downtime deployments. This means we can deploy our application without it ever being taken offline, when the app is built it is seamlessly deployed by switching a symbolic link on the server. Likewise, if the deployment fails it automatically rolls back to the previous deployment so users shouldn’t be affected!

Note that Deployer is great for deploying your application onto a single (or multiple) EC2/VPS/Server instances. However, it may not be the best tool for more advanced projects making use of Docker, Kubernetes or other container orchestration services.

The plan

For Linquito we have two environments; staging and production (this is typical of many projects). For our GitFlow we decided on adopting GitHub Flow. GitHub Flow is a nice, simple GitFlow where essentially features and bug fixes are branched off master and merged back into master upon completion (and satisfying testing, QA etc.). Therefore in our instance the master branch is our single source of truth for our code base. This is the same for both our backend and frontend codebases (Linquito is a headless API based app with a Vue frontend and Laravel backend).

Testing on pull requests

The first part is to test the application using GitHub actions. For this we will need to create a new workflow. A workflow on GitHub Actions is defined as “custom automated processes that you can set up in your repository to build, test, package, release, or deploy any project on Github”.

Workflows are defined in YAML files and located in the root of your repository inside a .github/ folder. So let’s create a new workflow .github/tests.yml.

Workflows are event based, in this instance we want it to react on pull requests, but only to the master branch. We can set this up with the following:

name: linquito-testing # The name of the workflow that appears on GitHub
on:
  pull_request:
    branches:
      - master # The branch the PR should merge into to initiate this action
# Note you can add additional branches here by adding more to the list

jobs:
	linquito-unit-tests:
	# This is where we define the steps to run in our job, currently empty

The above is a simple boilerplate YAML file that will run the linquito-unit-tests job every time there is a pull request into the master branch. Now let’s populate the steps required to run our tests!

name: linquito-testing # The name of the workflow that appears on GitHub
on:
  pull_request:
    branches:
      - master # The branch the PR should merge into to initiate this action
# Note you can add additional branches here by adding more to the list

jobs:
	linquito-unit-tests:
	runs-on: ubuntu-latest # This is the name of the runner the job will run on
	
	services:
	# Here we specify services we want to run, in this case we need MySQL 5.7
		mysql: 
      image: mysql:5.7
      env:
        MYSQL_ROOT_PASSWORD: "topsecretpasswordhere"
        MYSQL_DATABASE: your_testing_database_name
      ports:
        - 33306:3306
      options: >-
        --health-cmd="mysqladmin ping"
        --health-interval=10s
        --health-timeout=5s
        --health-retries=3
	# Steps are the sequence of tasks to be executed as part of the job
  steps:
    # This will check out your repo under $GITHUB_WORKSPACE so your job can access it
    - uses: actions/checkout@v2
    - uses: shivammathur/setup-php@v2
      with:
        php-version: '7.4' # Here we can target a specific version of PHP and composer
        tools: composer:v1

    - name: Copy .env
      run: php -r "file_exists('.env') || copy('.env.example', '.env');"
    - name: Install Dependencies
      run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
    - name: Generate key
      run: php artisan key:generate
    - name: Directory Permissions
      run: chmod -R 777 storage bootstrap/cache
    - name: Execute Tests
      run: |
        php artisan config:clear
        php artisan migrate --seed --no-interaction
        vendor/bin/phpunit
      env:
        DB_CONNECTION: mysql
        DB_DATABASE: your_testing_database_name
        DB_HOST: 127.0.0.1
        DB_USERNAME: root
        DB_PASSWORD: topsecretpasswordhere
        DB_PORT: 33306

Now when we create new pull requests our action will run. You can setup your repository to block merging until all actions are successful to prevent broken code hitting production. N|Solid

Automatic deployment to staging

Next on our list is the ability to deploy our updated codebase onto our staging server after the unit tests have successfully completed. For this we can create a separate GitHub action workflow .github/workflows/staging.yml.

For the following workflow, we are using GitHub secrets which look like ${{ secrets.SECRET_NAME }}. Note that need to set up your SSH_ID_RSA_PRIVATE and SSH_STAGING_KNOWN_HOSTS secrets on GitHub in order for Deployer to be able to SSH onto your server.

name: linquito-deploy-staging

# Runs when a new PR is merged to the master branch
on:
  push:
    branches:
      - master

jobs:
  linquito-deploy-staging:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '7.4'
          tools: composer:v1
      - name: Install Dependencies
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
      - name: Setup Deployer # Here we setup deployer as we need this to deploy
        uses: atymic/deployer-php-action@master
        with:
          ssh-private-key: ${{ secrets.SSH_ID_RSA_PRIVATE }}
          ssh-known-hosts: ${{ secrets.SSH_STAGING_KNOWN_HOSTS }}
      - name: Deploy codebase
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        run: dep deploy staging --tag=${{ env.GITHUB_REF }} -vvv

Next, we need to create our Deployer php configuration file. You can see from above dep deploy staging . Staging is a stage in the defined hosts so that Deployer knows what server(s) to target and deploy to. This is defined by the hosts section in your deploy.php file. We only have two servers for Linquito (at the time of writing) if you were to implement this for your application you will need to change your server IPs and the deploy_path and domain_path to fit your needs.

// Hosts
host('staging-api.linquito.com')
    ->hostname('255.255.255.254')
    ->stage('staging')
    ->user('ubuntu')
    ->set('deploy_path', '/var/www/staging-api.linquito.com')
    ->set('domain_path', 'staging-api.linquito.com');

host('production-api.linquito.com')
    ->hostname('255.255.255.255')
    ->stage('production')
    ->user('ubuntu')
    ->set('deploy_path', '/var/www/production-api.linquito.com')
    ->set('domain_path', 'production-api.linquito.com');

In our deploy.php we are also using the Slack recipe so that we get a confirmation message every-time we successfully deploy. If you wish to use this you will need to set your slack_webhook otherwise you can simply remove the Slack recipe and any other Slack tasks.

The deploy.php file template you can use should look like the following, you can adjust it to your site’s needs but this should work with a typical Laravel application:

<?php

namespace Deployer;

require 'recipe/laravel.php';
require 'recipe/rsync.php';
require 'recipe/slack.php';

set('application', 'Linquito');
set('ssh_multiplexing', true);
set('keep_releases', 3); // Number of releases to keep on the server

set('slack_webhook', 'https://hooks.slack.com/services/your-slack-webhook');

set('rsync_src', function () {
    return __DIR__;
});

// Here you can specify any files or folders to exclude from being copied over toyour server
add('rsync', [
    'exclude' => [
        '.git',
        '/.env',
        '/storage/',
        '/vendor/',
        '/examples/',
        '/node_modules/',
        '/docker/',
        '.github',
        'deploy.php',
        '/public/storage',
        '/public/packages/',
    ],
]);

// Laravel shared file
set('shared_files', [
    '.env',
]);

// Hosts
host('staging-api.linquito.com')
    ->hostname('255.255.255.254')
    ->stage('staging')
    ->user('ubuntu')
    ->set('deploy_path', '/var/www/staging-api.linquito.com')
    ->set('domain_path', 'staging-api.linquito.com');

host('production-api.linquito.com')
    ->hostname('255.255.255.255')
    ->stage('production')
    ->user('ubuntu')
    ->set('deploy_path', '/var/www/production-api.linquito.com')
    ->set('domain_path', 'production-api.linquito.com');

after('deploy:failed', 'deploy:unlock');

// Custom tasks remove any that are not relevant to your application
desc('Execute artisan horizon:publish');
task('artisan:horizon:publish', function () {
    run('{{bin/php}} {{release_path}}/artisan horizon:publish || true');
});

desc('Execute artisan telescope:publish');
task('artisan:telescope:publish', function () {
    run('{{bin/php}} {{release_path}}/artisan telescope:publish || true');
});

desc('Execute artisan nova:publish');
task('artisan:nova:publish', function () {
    run('{{bin/php}} {{release_path}}/artisan nova:publish || true');
});

desc('Deploy the application');
task('deploy', [
    'deploy:info',
    'deploy:prepare',
    'deploy:lock',
    'deploy:release',
    'rsync',
    'deploy:shared',
    'deploy:vendors',
    'deploy:writable',

    // Laravel steps
    'artisan:storage:link',
    'artisan:view:cache',
    'artisan:horizon:publish',
    'artisan:telescope:publish',
    'artisan:nova:publish',
    'artisan:config:cache',
    'artisan:route:cache',
    'artisan:optimize',
    'artisan:migrate',
    'artisan:db:seed',
    'artisan:horizon:terminate',
    'deploy:symlink',
    'deploy:unlock',

    'cleanup',
]);
after('deploy', 'success');

before('deploy', 'slack:notify');
after('deploy', 'slack:notify:success');
fail('deploy', 'slack:notify:failure');

Now, next every time we merge to master we can see our job execute automatically on the Actions tab for the repository on GitHub. You can also expand and see the full CI logs which can be helpful for debugging if the job fails.

N|Solid

Production deployments

For our production deployments we want to follow the same steps and tasks as for staging but with the difference that we want to target our production servers and only trigger when a new GitHub release is created (as opposed to when we merge to master).

For this we can create an additional workflow .github/workflows/production.yml. The main difference here is we will use production secrets (a different ID_RSA_PRIVATE key, known hosts etc) and we will change the events the workflow reacts to by targeting published releases. The same deploy.php file can be used and the only difference when using Deployer’s dep command is we specify production as the argument instead of staging. This picks up the production tagged hosts in the deploy.php file.

name: linquito-deploy-production
on: # Now we only target releases
  release:
    # This will only run when a new release is created AND published
    types: [published]
jobs:
  linquito-deploy-production:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '7.4'
          tools: composer:v1
      - name: Install Dependencies
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
      - name: Setup Deployer
        uses: atymic/deployer-php-action@master
        with:
          ssh-private-key: ${{ secrets.SSH_ID_RSA_PRIVATE }}
          ssh-known-hosts: ${{ secrets.SSH_PRODUCTION_KNOWN_HOSTS }}
      - name: Deploy codebase
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        run: dep deploy production --tag=${{ env.GITHUB_REF }} -vvv

Now, every-time we create a new release on GitHub for Linquito’s repository. GitHub Actions should automatically run the production workflow to release the updated codebase to our production servers. This typically takes about a minute to deploy!

Summary

This post outlines how we used GitHub Actions and Deployer to create a relatively simple CI/CD pipeline which would be adequate for many smaller/medium sized projects. The benefit to this deployment is it can be relatively fast! For Linquito we typically deploy within 1-2 minutes. You can also use Deployer for multi-server deployment if your site sits behind a load balancer and your scale starts to grow.

Hopefully this will help inspire you to adopt some basic pipelines for your application using GitHub Actions and/or Deployer!

For more info on Deployer you can read the documentation [here] and GitHub Actions documentation [here].