Automatic deployment on shared server using GitHub webhooks

If you, like me, have a few spare time projects, chances are you don't own or rent a dedicated server for your web hosting. I use Loopia (a Swedish web hosting provider) for my hosting purposes. I use their web hotel service, so I have very little control over file system paths, php modules and such.

On a dedicated server, using GitHub webhooks is pretty straightforward. When your server gets notified of a push or a closed merge request, you can do a simple git clone to get a fresh full copy of the branch you are using for your deploys. On a shared system, without access to the git command-line tools, it gets a little tricker.

I have developed a php-based solution that works for me. My branch and merge setup looks something like this:

feature-xOne branch per featuremasterMain development branchdevAuto-publish to a dev environmentvnextAuto-publish for demonstration purposeswwwCurrent running version, stableStarted work on new navigationBug fixNew artwork addedBug fixesBug fixes mergedBug fixNew navigation doneTest of new navigationCustomer demoBug fixLive deployment

Some development is performed in the master branch, and larger features are developed in branches of their own. Whenever a feature makes enough progress to be visible or usable (or is completed), or a bug is fixed, I merge to the dev branch. Every now and then, I'm not the only coder making changes. When other coders are done with a feature or a bug-fix, they create a pull request that I approve to perform the merge.

The dev branch is where we test everything internally. We can do experiments, move stuff around, temporarily remove features or add wild and crazy stuff. When the dev branch is good enough for showing to people, we merge to the vnext branch, which is always a little more stable and feels more "done". This is where customers can check out future features and have their say in stuff.

After a couple of rounds of pushing to vnext, it's time to go live. This is done by merging to the www branch.

Continuous Integration and Deployment

Every time something gets pushed into the non-master branches, GitHub posts a message to my webhook handler. The handler reads the message payload to find out what files are changes and what branch is the target. Using this information, it downloads the correct source files from and copies to the correct directory of the shared web server file system.

// We are only interested in PUSH events for now
$eventName = @$_SERVER['HTTP_X_GITHUB_EVENT'];
if ($eventName != 'push') {
    exit("This is not a PUSH event. Aborting...");

// Read and parse the payload
$jsonencodedInput = file_get_contents("php:\/\/input");
$inputData = json_decode($jsonencodedInput);

// What branch is this?
$branchRef = $inputData->ref;

// If I'm interested in the branch, copy all changes, otherwise quit
if ($branchRef == 'refs/heads/dev') {
    copyChanges('/WEB-HOTEL-ROOT/', 'dev', $inputData);
} else if ($branchRef == 'refs/heads/vnext') {
    copyChanges('/WEB-HOTEL-ROOT/', 'vnext', $inputData);
} else if ($branchRef == 'refs/heads/www') {
    copyChanges('/WEB-HOTEL-ROOT/', 'www', $inputData);
} else {
    exit("I'm not interested in the $branchRef branch. Aborting...");

The code above is simple enough. Depending on the type of event, and on the name of the branch, the script either exits immediately with a nice error message (that you can read in your GitHub repository's webhook settings page), or calls the copyChanges function, shown below.

// Copy changes
function copyChanges($rootFolder, $branchName, $inputData) {
    // Check all commits involved in this push for changes that I'm interested in
    $interestingChanges = extractInterestingChangesFromCommits($inputData->commits);
    $changedPaths = array_keys($interestingChanges);

    // No interesting changes? Quit!
    if (count($changedPaths) == 0) {
        exit("No interesting changes. Goodbye!");

    foreach ($changedPaths as $localPath) {
        $fullPath = $rootFolder . $localPath;
        $changeType = $interestingChanges[$localPath];

        if ($changeType == 'delete') {
            // Deleted file - delete it!
        } else {
            // Added or modified file - download it!
            $url = "$branchName/$localPath";
            $fileContents = file_get_contents($url);
            if ($fileContents !== false) {
                file_put_contents($fullPath, $fileContents);

Actually, the code I use contains some more error checking. It also recursively creates new directories if a file wants to be put in a directory that does not yet exist.

// Extract interesting changes
function extractInterestingChangesFromCommits($commits) {
    // This function returns an array where
    //     the keys are local file paths, and
    //     the values are the type of change
    // Something like this:
    // [
    //     'path/file.1' => 'add',
    //     'path/file.2' => 'change',
    //     'path/file.3' => 'delete'
    // ]

    $result = [];

    foreach ($commits as $commit) {
        foreach ($commit->added as $added) {
            $result[$added] = 'add';
        foreach ($commit->modified as $modified) {
            $result[$modified] = 'change';
        foreach ($commit->deleted as $deleted) {
            $result[$deleted] = 'delete';

    return $result;

That's about it for now. The script has been running and handling deployments for my spare-time projects for a while now, and I feel confident about it. I'll make some more touchups to this script, and then I'll put it on GitHub for you to star. Check in for a link in a few days.