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 on : push jobs : build-js : name : Build Js/Css runs-on : ubuntu - latest steps : - uses : actions/checkout@v1 test-php : name : Test/Lint PHP runs-on : ubuntu - latest steps : - uses : actions/checkout@v1 deploy : name : Deploy to Production runs-on : ubuntu - latest needs : [ build - js , test - php ] steps : - uses : actions/checkout@v1

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 - 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 extensions : mbstring , bcmath - 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> .

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 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 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 ; require 'recipe/laravel.php' ; require 'recipe/rsync.php' ; set ( 'application' , 'dep-demo' ) ; set ( 'ssh_multiplexing' , true ) ; set ( 'rsync_src' , function ( ) { return __DIR__ ; } ) ; add ( 'rsync' , [ 'exclude' = > [ '.git' , '/.env' , '/storage/' , '/vendor/' , '/node_modules/' , '.github' , 'deploy.php' , ] , ] ) ; 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.

host ( 'production.app.com' ) - > hostname ( '178.128.84.15' ) - > stage ( 'production' ) - > user ( 'deploy' ) - > set ( 'deploy_path' , '/var/www' ) ;

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' ) ; desc ( 'Deploy the application' ) ; task ( 'deploy' , [ 'deploy:info' , 'deploy:prepare' , 'deploy:lock' , 'deploy:release' , 'rsync' , 'deploy:secrets' , 'deploy:shared' , 'deploy:vendors' , 'deploy:writable' , 'artisan:storage:link' , 'artisan:view:cache' , 'artisan:config:cache' , '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 and deploy.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.

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