Zero Downtime Laravel Deployment with Github Actions
Photo by Scott Rodgerson on Unsplash
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 Gitlab 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@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
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.
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 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)
atymic, rufflesaurus, aharen, hermescortesm, dk009dk, alt, astroboy liked this article
Other articles you might like
Laravel Custom Query Builders Over Scopes
Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...
Access Laravel before and after running Pest tests
How to access the Laravel ecosystem by simulating the beforeAll and afterAll methods in a Pest test....
🍣 Sushi — Your Eloquent model driver for other data sources
In Laravel projects, we usually store data in databases, create tables, and run migrations. But not...
The Laravel portal for problem solving, knowledge sharing and community building.
The community