My deploy script for Statamic

How I deploy Statamic on shared hosting with minimal downtime.

Mike Griffiths

For larger sites I have more thorough tooling, but for small personal projects that I run on inexpensive shared hosting I use a customised script. This is simply a file I drop in a public directory, give an obfuscated name, and then visit when I want to make a deployment.

Let's take a look...

<?php

set_time_limit ( 60 * 5 ); // 5min

$allowed_ips = ['your.ip.address.here'];

// Get client IP
function get_ip_address(){
    foreach (array('HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR') as $key){
        if (array_key_exists($key, $_SERVER) === true){
            foreach (explode(',', $_SERVER[$key]) as $ip){
                $ip = trim($ip); // just to be safe

                if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false){
                    return $ip;
                }
            }
        }
    }
}

function call_exec($cmd) {
    $out = [];
    exec($cmd, $out);
    foreach($out as $line) {
        echo $line."\n";
    }
}


$ip = get_ip_address();

if(in_array($ip, $allowed_ips)) {
    // Check the md5
    $md5 = $_GET['q'] ?? '';
    if($md5 == 'somerandomstring') {
        echo '<pre>';
        echo '<span style="color: blue;">Running git pull...</span>' . "\n";
        call_exec('cd .. ; git pull'); echo "\n\n";
        
        echo '<span style="color: blue;">Running composer install...</span>' . "\n";
        putenv("COMPOSER_HOME=/opt/cpanel/composer/bin/");
        call_exec('cd .. ; php /opt/cpanel/composer/bin/composer install 2>&1'); echo "\n\n";
        
        echo '<span style="color: blue;">Running migrations...</span>' . "\n";
        call_exec('php /home4/***/artisan migrate --force'); echo "\n\n";
        
        echo '<span style="color: blue;">Running npm install...</span>' . "\n";
        call_exec('/opt/alt/alt-nodejs12/root/usr/bin/npm install --scripts-prepend-node-path'); echo "\n\n";

        echo '<span style="color: blue;">Running npm run production...</span>' . "\n";
        call_exec('/opt/alt/alt-nodejs12/root/usr/bin/npm run production --scripts-prepend-node-path'); echo "\n\n";

        echo '<span style="color: green;">Deployment complete</span>';
    }
}

I'll run through this line by line so you can see what's going on. Some obvious stuff at the top though; settimelimit ( 60 * 5 ); // 5min this ensures the script can run for 5minutes. Adjust as you see fit, but none of this should take more than a couple of minutes tops.

$allowed_ips = ['your.ip.address.here']; Here we set an array of allowed IP addresses. Just for extra security I only allow deployments from my office IP.

function get_ip_address(){
    foreach (array('HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR') as $key){
        if (array_key_exists($key, $_SERVER) === true){
            foreach (explode(',', $_SERVER[$key]) as $ip){
                $ip = trim($ip); // just to be safe

                if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false){
                    return $ip;
                }
            }
        }
    }
}

This block gets the IP address of the client requesting the script... in other words, it checks for my IP. How you get the client IP differs depending on your set up. In simple set ups it's just $_SERVER['REMOTEADDR'], but that's not the case when requests are proxied, or sat behind a firewall. This function makes it way more reliable, and also runs filter_var to ensure someone isn't sending non-IP-formatted headers.

function call_exec($cmd) {
    $out = [];
    exec($cmd, $out);
    foreach($out as $line) {
        echo $line."\n";
    }
}

This little helper function runs a command on the server and outputs the command's output to the browser.

$ip = get_ip_address();

if(in_array($ip, $allowed_ips)) {
    // Check the md5
    $md5 = $_GET['q'] ?? '';
    if($md5 == 'somerandomstring') {
        echo '<pre>';
        echo '<span style="color: blue;">Running git pull...</span>' . "\n";
        call_exec('cd .. ; git pull'); echo "\n\n";
        
        echo '<span style="color: blue;">Running composer install...</span>' . "\n";
        putenv("COMPOSER_HOME=/opt/cpanel/composer/bin/");
        call_exec('cd .. ; php /opt/cpanel/composer/bin/composer install 2>&1'); echo "\n\n";
        
        echo '<span style="color: blue;">Running migrations...</span>' . "\n";
        call_exec('php /home4/***/artisan migrate --force'); echo "\n\n";
        
        echo '<span style="color: blue;">Running npm install...</span>' . "\n";
        call_exec('/opt/alt/alt-nodejs12/root/usr/bin/npm install --scripts-prepend-node-path'); echo "\n\n";

        echo '<span style="color: blue;">Running npm run production...</span>' . "\n";
        call_exec('/opt/alt/alt-nodejs12/root/usr/bin/npm run production --scripts-prepend-node-path'); echo "\n\n";

        echo '<span style="color: green;">Deployment complete</span>';
    }
}

This block calls the get_ip_address() method and checks it against our allowed IPs. We then have an additional security check where a query parameter of q must be added in order for the commands to actually run. In the above example nothing will happen if you visit the script alone, the commands will only run if you visit /script.php?q=somerandomstring. You'll obviously want to adjust somerandomstring to something a little more secure and unique.

Then we run the commands, let's run through each.

cd ..; git pull

The script is sat in the public folder, so we need to go up a directory and run git pull to get the latest files. You'll need to ensure that git is set up properly and you're able to pull files from the repo. I use GitHub for my repos, and then add the account add a deployment source to the project, this means the server only has read access on just that single repo.

putenv("COMPOSER_HOME=/opt/cpanel/composer/bin/");
call_exec('cd .. ; php /opt/cpanel/composer/bin/composer install 2>&1');

Here we set an environment variable for composer's home, this allows composer to run more reliably across environments. You'll need to update this to where composer lives on your system.

We then cd out of public directory again and run composer install.

php /home4/***/artisan migrate --force

Nice and simple, run migrate. No need to cd here as we're using absolute paths for the artisan file. Just make sure you replace the path with your path.

/opt/alt/alt-nodejs12/root/usr/bin/npm install --scripts-prepend-node-path'); echo "\n\n";

This runs npm install, which brings your vendor folder inline with your package-lock.json file. Adding --scripts-prepend-node-path fixes some issues on stateless command prompts like this, as it makes all chained commands use a full absolute path.

/opt/alt/alt-nodejs12/root/usr/bin/npm run production --scripts-prepend-node-path

Last but not least, run npm run production, which builds your assets.

The last two commands are only needed if you intend to build your assets on your server. This is by far the most intensive task, and unless you're changing your design isn't needed. Some people opt to build these locally and add them to the repo - if you work in a team on the project you'll end up annoying each other pretty quickly with that approach as every merge will have conflicts for your compiled files. In larger projects you'll probably want to run those files as part of your CI/CD and deploy from there.