Zero Downtime Laravel Deployments with Github Actions

December 19, 2019

A few months ago I wrote a post on setting up zero downtime continuous deployment with Gitlab's free CI offering. Now that Github actions is out of beta I've moved most of my CI/CD pipelines over.

In my experience Github Actions is a bit faster, but the it's not as user friendly in terms of actually building the pipelines. The definite advantage for me is simply the fact it's built into Github, meaning I only need to use a single service. The free tier is pretty generous as well.

Just want to see the code and configuration? The example repo for this post is here. I've tried to make it pretty generic, so you should be able to copy/paste the workflow & deploy.php into your own project with minimal changes.

Setting up Github Actions

For the purposes of this post, I'm going to start from scratch with a fresh Laravel installation and guide you through the process of setting up the actions which we'll use for testing, building and deploying our code to production.

Github actions is configured using yaml files, placed in .github/workflows. They do provide an editor, but in my experience it's much easier to create and edit the files manually. To create a new workflow you just need to create a new yaml file inside the aforementioned folder (for example, deploy.yml).

Inside this file, you can define your build stages, their dependencies, caching and many other features. For our application, we'll just use a simple three step build (github calls these jobs).

Scaffolding the workflow

In .github/workflows, create a new workflow, deploy.yml. We'll need to add some basic configuration to instruct actions to run our workflow on commits to the repo, and define a name. Under the jobs sections, we'll need to define each job of the build/deploy process. For the moment, we'll just set out the basic scaffold and we'll populate it as set up the three jobs.

name: CI-CD # Give your workflow a name

on: push # Run the workflow for each push event (i.e commit)

jobs:
  build-js:
    name: Build Js/Css
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
        # Build JS
  test-php:
      name: Test/Lint PHP
      runs-on: ubuntu-latest
      steps:
      - uses: actions/checkout@v1
        # Test and Lint PHP code
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    # Note that this needs both of the other steps to have completed successfully
    needs: [build-js, test-php] 
    steps:
    - uses: actions/checkout@v1
      # Deploy Code 

Building Javascript and CSS assets

Laravel comes bundled with Mix, which provides an easy interface to webpack to build and compile your front end assets (Javascript and CSS).

It's worth noting here that we are using the upload-artifact action. This is required so that our deployment job can deploy the compiled files, as each job operates on a fresh version of the source code.

jobs:
  build-js:
    name: Build Js/Css
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1 # Download the source code
      - name: Yarn Build
        run: |
          yarn install
          yarn prod
          cat public/mix-manifest.json # See asset versions in log
      - name: Upload build files
        uses: actions/upload-artifact@v1
        with:
          name: assets
          path: public

Running PHP linting and tests

Most applications will have some sort of automated linting and (hopefully) tests. Ensuring that the code is valid and works before deploying to production is a good idea, for obvious reasons. If the CI build finds issues such as a syntax error or failing test, it won't proceed to deployment.

In our action, we'll define a test-php job. Laravel has some sample tests built in, which we'll run. You could also run limiting and static analysis tools in this stage. You can use the setup-php action to use a specific PHP version, or add additional extensions.

test-php:
  name: Test/Lint PHP
  runs-on: ubuntu-latest
  needs: build-js
  steps:
    - uses: actions/checkout@v1
    - name: Setup PHP
      uses: shivammathur/setup-php@v1
      with:
        php-version: 7.3 # Use your PHP version
        extensions: mbstring, bcmath # Setup any required extensions for tests
    - name: Composer install
      run: composer install
    - name: Run Tests
      run: ./vendor/bin/phpunit

Deploying the code

Now that we've made sure that our code works & have built our assets, we can move on to the actual deployment.

Head over to your repository settings, and select the Secrets option in the sidebar.

My biggest gripe with Github actions is the secret management. For some reason, you can't edit secrets, so to update anything you need to delete and then re-create the entire secret. Hopefully they this fix this at some point.

We'll need to add a private key with access to the server so that deployer can SSH in. It's a good idea to generate a new key specifically for deployment (ideally on a specific deployment user with minimal permissions). You'll want to add it under the variable SSH_PRIVATE_KEY.

You'll also need to add your Laravel .env file as DOT_ENV, so it can be deployed along with the code (you should never store secrets in git).

Since every CI build/deployment starts from a fresh slate, the containers ~/.ssh/known_hosts file won't be populated. To ensure there aren't any MITM attacks, we need to our server's SSH fingerprint as a variable, SSH_KNOWN_HOSTS.

You can find you server's host fingerprint by running ssh-keyscan rsa -t <server IP>.

Configuring github actions secrets

Notice that we have defined both of the other jobs in the needs section. Both build-js and test-php should run asynchronously (this has been inconsistent for me, often they run one at a time), and once both complete the deployment will commence.

We've also added a conditional if rule to ensure that only the master branch is deployed (we want tests/linting to run for all branches).

The job first downloads the compiled javascript/css from the build-js and applies that to current working tree.

Before we can deploy, we need to set up ssh (start ssh-agent with the provided private key, update the known_hosts) and install deployer. To make this nice and simple, I've created an action to handle everything for you, but you could run all of the shell commands manually instead.

atymic/deployer-php-action

The job finally runs dep deploy, which uses deployer to carry out the zero downtime deployment (we'll set it up in the next section).

deploy:
  name: Deploy to Production
  runs-on: ubuntu-latest
  needs: [build-js, test-php]
  if: github.ref == 'refs/heads/master'
  steps:
  - uses: actions/checkout@v1
  - name: Download build assets
    uses: actions/download-artifact@v1
    with:
      name: assets
      path: public
  - name: Setup PHP
    uses: shivammathur/setup-php@master
    with:
      php-version: 7.3
      extension-csv: mbstring, bcmath
  - name: Composer install
    run: composer install
  - name: Setup Deployer
    uses: atymic/deployer-php-action@master
    with:
      ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
      ssh-known-hosts: ${{ secrets.SSH_KNOWN_HOSTS }}
  - name: Deploy to Prod
    env:
      DOT_ENV: ${{ secrets.DOT_ENV }}
    run: dep deploy production --tag=${{ env.GITHUB_REF }} -vvv

Putting it all together

Now that we've got all of our jobs configured your workflow should look something like the one below. Feel free to copy and paste this into your own project.

name: CI-CD

on:
  push:
    branches: master

jobs:
  build-js:
    name: Build Js/Css
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Yarn Build
        run: |
          yarn install
          yarn prod
          cat public/mix-manifest.json # See asset versions in log
      - name: Upload build files
        uses: actions/upload-artifact@v1
        with:
          name: assets
          path: public
  test-php:
    name: Test/Lint PHP
    runs-on: ubuntu-latest
    needs: build-js
    steps:
      - uses: actions/checkout@v1
      - name: Setup PHP
        uses: shivammathur/setup-php@master
        with:
          php-version: 7.3 # Use your PHP version
          extension-csv: mbstring, bcmath # Setup any required extensions for tests
      - name: Composer install
        run: composer install
      - name: Run Tests
        run: ./vendor/bin/phpunit
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: [build-js, test-php]
    if: github.ref == 'refs/heads/master'
    steps:
      - uses: actions/checkout@v1
      - name: Download build assets
        uses: actions/download-artifact@v1
        with:
          name: assets
          path: public
      - name: Setup PHP
        uses: shivammathur/setup-php@master
        with:
          php-version: 7.3
          extension-csv: mbstring, bcmath
      - name: Composer install
        run: composer install
      - name: Setup Deployer
        uses: atymic/deployer-php-action@master
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
          ssh-known-hosts: ${{ secrets.SSH_KNOWN_HOSTS }}
      - name: Deploy to Prod
        env:
          DOT_ENV: ${{ secrets.DOT_ENV }}
        run: dep deploy production --tag=${{ env.GITHUB_REF }} -vvv

Setting up Deployer

Now that we've got the CI set up, it's time to install deployer in our project and configure it.

composer require deployer/deployer deployer/recipes 

Once you've got it installed, run ./vendor/bin/dep init and follow the prompts, selecting Laravel as your framework. A deploy.php config file will be generated and placed in the root of your project.

By default, deployer uses GIT for deployments (by SSHing into the server, running git pull and executing your build steps) however since we are running it as part of a CI/CD pipeline (our project has been built and is ready for deployment) we'll use rsync to copy the files directly to the server instead.

Open your deploy.php in a code editor. Copy and paste the code below into your deploy.php above the hosts section.

<?php

namespace Deployer;

// Include the Laravel & rsync recipes
require 'recipe/laravel.php';
require 'recipe/rsync.php';

set('application', 'dep-demo');
set('ssh_multiplexing', true); // Speed up deployment

set('rsync_src', function () {
    return __DIR__; // If your project isn't in the root, you'll need to change this.
});

// Configuring the rsync exclusions. 
// You'll want to exclude anything that you don't want on the production server.  
add('rsync', [
    'exclude' => [
        '.git',
        '/.env',
        '/storage/',
        '/vendor/',
        '/node_modules/',
        '.github',
        'deploy.php',
    ],
]);

// Set up a deployer task to copy secrets to the server. 
// Grabs the dotenv file from the github secret
task('deploy:secrets', function () {
    file_put_contents(__DIR__ . '/.env', getenv('DOT_ENV'));
    upload('.env', get('deploy_path') . '/shared');
});

Next, we need to set up our hosts. In this example, we'll only use a single host but deployer supports as many as required. Copy the code below into your deploy.php, replacing the existing hosts block. You'll need to customise it for your specific server configuration.

// Hosts
host('production.app.com') // Name of the server
    ->hostname('178.128.84.15') // Hostname or IP address
    ->stage('production') // Deployment stage (production, staging, etc)
    ->user('deploy') // SSH user
    ->set('deploy_path', '/var/www'); // Deploy path

Next, we'll set up the steps that deployer will execute as part of our deployment. These can be customised to your needs, for example you could use the artisan:horizon:terminate task to restart your horizon queues. Copy the block below into your deploy.php, replacing the tasks section.

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

desc('Deploy the application');
task('deploy', [
    'deploy:info',
    'deploy:prepare',
    'deploy:lock',
    'deploy:release',
    'rsync', // Deploy code & built assets
    'deploy:secrets', // Deploy secrets
    'deploy:shared',
    'deploy:vendors',
    'deploy:writable',
    'artisan:storage:link', // |
    'artisan:view:cache',   // |
    'artisan:config:cache', // | Laravel specific steps 
    'artisan:optimize',     // |
    'artisan:migrate',      // |
    'deploy:symlink',
    'deploy:unlock',
    'cleanup',
]);

Configuring your Server

We'll also need to make a few changes the nginx or apache configuration files on the server.

You want to set your web server root to deploy_path (set in your deploy.php) + /current/public. For example, in our case this is /var/www/current/public.

You'll also need to make sure that your deploy user has the correct permissions to write to the deployment path. Deployer's deploy:writable task will ensure that the folders have the correct permissions so your web server user can write them.

First deployment 😎

Now that everything's set up, it's time to test our pipelines!

Commit your workflow anddeploy.php as well as your composer json/lock and push! If all goes well, you can head over to the Actions tab on your Github repo and watch your build/deployment progress.

If anything goes wrong, check the CI logs. The logs from github actions can sometimes be a bit opaque, but as long as your yaml if structured correctly it's usually fairly obvious as to what went wrong (missing SSH keys/fingerprints, invalid server config, etc).

If everything went well, you'll have the latest version of your project deployed. Any new commits will be built and deployed without any interruption for you users.

Here's the first deployment of my test repo, and one with a migration.

Deployment

Wrapping up

This post outlines a basic CI/CD pipeline, but there's plenty of improvements and additions that could be made, such as adding staging environments, release notifications, multi server deployment (for load balanced server groups).

I hope you enjoyed this post and it helped you improve your builds & deployments, or migrate existing ones to github actions.

If you have any questions, leave a comment below & i'll do my best to respond to them all.

Further reading

Example Repo (Code + Workflow)

Deployer Documentation

Github Actions Documentation