Zero Downtime Laravel Deployments with "Laravel Deployer" and "Github Actions"
9 min read

Zero Downtime Laravel Deployments with "Laravel Deployer" and "Github Actions"

Zero Downtime Laravel Deployments with "Laravel Deployer" and "Github Actions"

Github actions can automate your entire workflow. From building your assets to running your testing and deployment strategies. Github Actions is very similar to other services such as TravisCI, CircleCI, GitLab Pipelines etc, but if you already have your projects on GitHub, then it makes sense to get Actions working as well.


This post is sponsored by SnapShooter: SnapShooter offer painless backups for your DigitalOcean droplets. Signup now and get your first droplet backed up for free.


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 building and deploying our code to production. You can also add automated tests and asset building as well, but we'll save that for another post.

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

Github actions is configured with yaml files, placed within your projects root directory under '/.github/workflows'. They do provide an editor, however, for the purposes of this post, we'll create it using our own editor. Let's start by creating a new file called 'deploy.yml' and place this inside the '/.github/workflows' directory.

name: CI-CD

on: push

jobs:

From the code above, you can see there are 3 steps. First is the name of your GitHub Action, this can be whatever you like, but that's the name I chose. Then we have "on" which tells GitHub we want to run this script on every push. It can be a string or an array and it can also be used for pull requests (learn more).

Finally, we have "jobs" which are individual runners or "tasks" and one job can depend on another. This means if a job fails, and the next one depended on that job, then it won't run. For example, before we can test our app, we need to ensure our assets are built and if we need all tests to pass before deployment, then we need to ensure that runs successfully too.

Defining our jobs

Now we can define our jobs and define what opertating system they run on and the steps to run.

jobs:
  build-js:
    name: Build Js/Css
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
  # Remove ^ if not needed
  test-php:
      name: Test/Lint PHP
      runs-on: ubuntu-latest
      needs: build-js
      steps:
      - uses: actions/checkout@v1
  # Remove ^ if not needed
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: test-php
    steps:
    - uses: actions/checkout@v1

From the code above, we have defined our 3 jobs build-js, test-php and deploy. We then gave them a name, specified the OS to run the jobs on and added some steps. The "uses: actions/checkout@v1" is an external action for checking out our codebase from GitHub. Without this, it won't download our code for use.

Building your Javascript and CSS assets

If you don't need to build any JS or CSS assets, then you can skip this and remove the build-js step from the code above. However, if you do, then please follow as shown.

As most of us know, Laravel comes with Mix to help compile our frontend assets (SASS and Javascript). It's worth noting that we are using the upload-artifact action in order to deploy our compiled assets onto the server. Otherwise, our compiled assets will be stuck in memory.

jobs:
  build-js:
    name: Build Js/Css
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: NPM Build
        run: |
          npm install
          npm run prod
          cat public/mix-manifest.json
      - name: Upload build files
        uses: actions/upload-artifact@v1
        with:
          name: assets
          path: public

Running PHP linting and testing your app

If your Laravel project isn't using linting and running tests, you can skip this section and remove the test-php job. Within this next section, we will tell GH Actions which PHP version we're using (I use 7.4, but you should select the version your production machine is on). Next, we run the commands to install composer packages and run our unit tests with PHPUnit.

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.4
        extensions: mbstring, bcmath # Setup any required extensions for tests
    - name: Composer install
      run: composer install
    - name: Run Tests
      run: ./vendor/bin/phpunit

Deploying your app

Now for the moment we've all been waiting for, defining our steps and adding environment variables to hopefully get your app to deploy onto your server.

Within your GitHub project, click on the settings tab and then click on "Secrets". You'll want to add  APP_DEPLOY_DEFAULT,  APP_DEPLOY_PATH,  APP_DEPLOY_USER,  APP_HOST,  SSH_KNOWN_HOSTS and  SSH_PRIVATE_KEY.

For "APP_DEPLOY_DEFAULT" you might want to set it to "first" and then change it to "upload" thereafter. However, if you have deployed your project before, then you should set it to "upload".

Next, we have the "APP_DEPLOY_PATH" which you should set as the location to your app's location within the production server. For example, my project directory is located at /var/www/isitvegan and that's what I've set for my environment variable. However, yours could be very different from mine, so make sure you know it's exact location.

APP_DEPLOY_USER is the user you ssh into the server as. You could create a new user, or you could use the current user. Choose whatever works for you.

APP_HOST is your server's ip address. E.g. "167.172.59.64". Your ip address is usually listed by the hosting company.

To get the value of SSH_KNOWN_HOSTS  you need to run ssh-keyscan rsa -t <server IP> in a terminal. For me that would be ssh-keyscan rsa -t 167.172.59.64 and it will return something like the following...

From your terminal, copy and paste the returned result into a notepad and remove any returns which your terminal may have created. E.g.

# 167.172.59.64:22 SSH-2.0-OpenSSH_7.6p1 Ubuntu-4ubuntu0.3
167.172.59.64 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDRWy3n9JWLUrdUTf3XgbWR4okEghrFEOgtDU02FVyG0a6XiGGezeD+jYxyZxXhpfV6RV
D5dQJcVXKD37HdrVFR02QwdkaZOjBAKT3mhut8zCUiCFcqlsasQ5mHjMux7HREws+ZsF8YwRnAJZCgPyQoENsPv2DyIN9y9fw2qRTPbtGoMHM3/TCzvwxpIR
TqEKSdLzqraZwta0V+5rsDUz1plPPbX1vggQRyPXNz+f5nDd281ClUVUp/o6UoYP2zcCGbKiqxFfsMr4Nq9ZmFyigQXmZ8RWyLQIQWvirRWlJCk5RiOQQfys
+pmvm1nklI26K0cVRdo/zmuwIQxhRcHJqj
# 167.172.59.64:22 SSH-2.0-OpenSSH_7.6p1 Ubuntu-4ubuntu0.3
167.172.59.64 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCd1kQXpULhuL/GQwXFu7ry8Pw/Px6/DCa
b3zNHe7ZuRxFbyh9klxizLm6ot0wpx5WFQRMWOZjUfwfTlPG762fw=
# 167.172.59.64:22 SSH-2.0-OpenSSH_7.6p1 Ubuntu-4ubuntu0.3
167.172.59.64 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINex9Wb3sOL/gtt2wT17CnMY7mIYD+qRgfMfzvDC4+zS

Should be edited as follows...

# 167.172.59.64:22 SSH-2.0-OpenSSH_7.6p1 Ubuntu-4ubuntu0.3
167.172.59.64 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDRWy3n9JWLUrdUTf3XgbWR4okEghrFEOgtDU02FVyG0a6XGGezeD+jYxyZxXhpfV6RVD5dQJcVXKD37HdrVFR02QwdkaZOjBAKT3mhut8zCUiCFcqlsasQ5mHjMux7HREws+ZsF8YwRnAJZCgPyQoENsPv2DyIN9y9fw2qRTPbtGoMHM3/TCzvwxpIRTqEKSdLzqraZwta0V+5rsDUz1plPPbX1vggQRyPXNz+f5nDd281ClUVUp/o6UoYP2zcCGbKiqxFfsMr4Nq9ZmFyigQXmZ8RWyLQIQWvirRWlJCk5RiOQQfys+pmvm1nklI26K0cVRdo/zmuwIQxhRcHJqj
# 167.172.59.64:22 SSH-2.0-OpenSSH_7.6p1 Ubuntu-4ubuntu0.3
167.172.59.64 ecdsa-sha2-nistp256AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCd1kQXpULhuL/GQwXFu7ry8Pw/Px6/DCab3zNHe7ZuRxFbyh9klxizLm6ot0wpx5WFQRMWOZjUfwfTlPG762fw=
# 167.172.59.64:22 SSH-2.0-OpenSSH_7.6p1 Ubuntu-4ubuntu0.3
167.172.59.64 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINex9Wb3sOL/gtt2wT17CnMY7mIYD+qRgfMfzvDC4+zS

Otherwise your deployment will fail.

Finally, we need to add SSH_PRIVATE_KEY into an environment variable. You could create a new key pair that your server accepts as another user, or you can use the private key from your personal computer (not recommended).

Now we can get back to our deploy file which will be added to our deploy job.

deploy:
  name: Deploy to production
  runs-on: ubuntu-latest
  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.4
        extensions: 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: Run Deployment
      env:
        DOT_ENV: ${{ secrets.DOT_ENV }}
        APP_HOST: ${{ secrets.APP_HOST }}
        APP_DEPLOY_PATH: ${{ secrets.APP_DEPLOY_PATH }}
        APP_DEPLOY_USER: ${{ secrets.APP_DEPLOY_USER }}
        APP_DEPLOY_DEFAULT: ${{ secrets.APP_DEPLOY_DEFAULT }}
      run: php artisan deploy -vvv

The above will download your assets from a previous job ready to deploy. It will then install all composer packages and use Laravel Deployer to deploy your app.

Putting it all together

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
      - 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.4
          extension-csv: mbstring, bcmath
      - name: Composer install
        run: composer install
      - name: Run Tests
        run: ./vendor/bin/phpunit
  deploy:
    name: Deploy to production
    runs-on: ubuntu-latest
    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.4
          extensions: 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: Run Deployment
        env:
          DOT_ENV: ${{ secrets.DOT_ENV }}
          APP_HOST: ${{ secrets.APP_HOST }}
          APP_DEPLOY_PATH: ${{ secrets.APP_DEPLOY_PATH }}
          APP_DEPLOY_USER: ${{ secrets.APP_DEPLOY_USER }}
          APP_DEPLOY_DEFAULT: ${{ secrets.APP_DEPLOY_DEFAULT }}
        run: php artisan deploy -vvv

Setting up Laravel Deployer

If you haven't done so already, you will need to install Laravel Deployer into your project via composer require lorisleiva/laravel-deployer and then running php artisan deploy:init to initialise deployer and setup your deploy.php config file within /config/deploy.php.

Since we're using environment variables, you can go ahead and click enter until it creates your file. Next we will update our file to use env variables as follows...

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Default deployment strategy
    |--------------------------------------------------------------------------
    |
    | This option defines which deployment strategy to use by default on all
    | of your hosts. Laravel Deployer provides some strategies out-of-box
    | for you to choose from explained in detail in the documentation.
    |
    | Supported: 'basic', 'firstdeploy', 'local', 'pull'.
    |
    */

    'default' => env('APP_DEPLOY_DEFAULT'),

    /*
    |--------------------------------------------------------------------------
    | Custom deployment strategies
    |--------------------------------------------------------------------------
    |
    | Here, you can easily set up new custom strategies as a list of tasks.
    | Any key of this array are supported in the `default` option above.
    | Any key matching Laravel Deployer's strategies overrides them.
    |
    */

    'strategies' => [
        //
    ],

    /*
    |--------------------------------------------------------------------------
    | Hooks
    |--------------------------------------------------------------------------
    |
    | Hooks let you customize your deployments conveniently by pushing tasks
    | into strategic places of your deployment flow. Each of the official
    | strategies invoke hooks in different ways to implement their logic.
    |
    */

    'hooks' => [
        // Right before we start deploying.
        'start' => [
            //
        ],

        // Code and composer vendors are ready but nothing is built.
        'build' => [
            //
        ],

        // Deployment is done but not live yet (before symlink)
        'ready' => [
            'artisan:storage:link',
            'artisan:view:clear',
            'artisan:cache:clear',
            'artisan:config:cache',
            'artisan:migrate',
        ],

        // Deployment is done and live
        'done' => [
            'fpm:reload',
        ],

        // Deployment succeeded.
        'success' => [
            //
        ],

        // Deployment failed.
        'fail' => [
            //
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Deployment options
    |--------------------------------------------------------------------------
    |
    | Options follow a simple key/value structure and are used within tasks
    | to make them more configurable and reusable. You can use options to
    | configure existing tasks or to use within your own custom tasks.
    |
    */

    'options' => [
        'application' => env('APP_NAME', 'Laravel'),
        'repository' => 'git@github.com:michael-brooks-developments-ltd/isitvegan.git',
        'php_fpm_service' => 'php7.4-fpm',
    ],

    /*
    |--------------------------------------------------------------------------
    | Hosts
    |--------------------------------------------------------------------------
    |
    | Here, you can define any domain or subdomain you want to deploy to.
    | You can provide them with roles and stages to filter them during
    | deployment. Read more about how to configure them in the docs.
    |
    */

    'hosts' => [
        env('APP_HOST') => [
            'deploy_path' => env('APP_DEPLOY_PATH'),
            'user' => env('APP_DEPLOY_USER'),
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Localhost
    |--------------------------------------------------------------------------
    |
    | This localhost option give you the ability to deploy directly on your
    | local machine, without needing any SSH connection. You can use the
    | same configurations used by hosts to configure your localhost.
    |
    */

    'localhost' => [
        //
    ],

    /*
    |--------------------------------------------------------------------------
    | Include additional Deployer recipes
    |--------------------------------------------------------------------------
    |
    | Here, you can add any third party recipes to provide additional tasks,
    | options and strategies. Therefore, it also allows you to create and
    | include your own recipes to define more complex deployment flows.
    |
    */

    'include' => [
        //
    ],

    /*
    |--------------------------------------------------------------------------
    | Use a custom Deployer file
    |--------------------------------------------------------------------------
    |
    | If you know what you are doing and want to take complete control over
    | Deployer's file, you can provide its path here. Note that, without
    | this configuration file, the root's deployer file will be used.
    |
    */

    'custom_deployer_file' => false,

];

Be sure to update 'repository' and 'php_fpm_service' to your GitHub url and the correct PHP FPM version which you're using. You can also remove 'php_fpm_service' and 'fpm:reload' if you're not using PHP FPM on your server.

Running your first deployment 🎉

Now everything has been setup, let's push our changes and get the cogs turning.

Be sure to add your newly created files into your GH repo, create a commit message and push to your repo!

Head over to your repo and take a look at the actions tab. Here you'll see a console which displays each step and you can watch as it runs and deploys your code.

If for whatever reason your deploy fails, take a look at the error messages inside the console and it should explain what went wrong. If there's an SSH issue, it could be to do with your private key, or known_hosts secrets.

I had to change the know_hosts secret multiple times before realising there were line breaks. Remember what I said above, you should remove unneeded line breaks before the current comment and after the next comment.

If everything has succeeded then it should look like the image below.

Congratulations, you have successfully added GH Actions to your projects workflow and made your deployment strategy much easier.

Enjoyed this post?

If you enjoyed this blog post, please feel free to sign up as a member using the form below. We will be looking into getting started with Laravel 7 and  creating your first application. You will then later turn the app into an api which will allow you to seperate your frontend from your backend.

Enjoying these posts? Subscribe for more