Zero Downtime Craft CMS Deployments with Buddy.works

Continuous deployment of a website or application with a service like buddy.works greatly reduces the chance of mistakes that can occur when launching or updating a site.

In this blogpost I'll go over our config at Marbles for how we deploy our Craft CMS websites with Buddy. With some creative thinking, this method will also work on Envoyer, Deploybot or any other continuous delivery service.

Prior reading

Before configuring your own website to run on Buddy I suggest getting up to speed with the concept of Pipelines.

Craft config

First things first, there's a few config settings that need to be set to have a continuous deployment workflow without errors or inconvenience.

You need to give your Craft site a unique ID and override the session location, this is to make sure that sessions and logins are not lost between deployments. It's very annoying to lose access to the backend every time a deployment gets run.

* [
    'appId' => 'marbles',
    'overridePhpSessionLocation' => true,
]

The next option should only be enabled on a staging or live environment to prevent updates. You don't want to have an update Craft instance with updated database changes being overwritten by a deployment that wasn't updated yet.

[
    'allowAutoUpdates' => false,
]

Server configuration

Before deploying you need to have a few folders set up in the location where you want to deploy your files. In our example we're deploying to our working directory ~/httpdocs so we have to make sure these folders exist:

~/httpdocs/storage
~/httpdocs/deploy-cache
~/httpdocs/releases
This deployment flow is based on the default “Atomic Deployment” template of Buddy

Buddy

Buddy can be configured by using either the GUI or a buddy.yml file. I'll go over the settings in the GUI here but know that these can be just as easily configured using the config file. Buddy even allows you to switch at any time and keep your pipelines intact.

Defining a pipeline

It's possible to define multiple pipelines for each project and even start one pipeline from an action within another. For this example I've just defined one pipeline, but we often have a pipeline for each environment (staging, acceptance, live...)

Actions

Inside each pipeline you can define a set of actions that need to be run.

The pipeline actions

Yarn

This is a slightly modified version of the default “Run Webpack” action that Buddy provides, what we've changed here is the Docker image (with one from Yarnpkg) and the basic commands. This runs our Laravel Mix script to compile our assets.

The default “Run Webpack” action reconfigured

Upload files

This action is pretty straight forward, it uploads all the files from the pipeline filesystem to a deploy-cache folder on the server. Actions like these are best set up through the GUI or the Yaml Helper.

I've set up a few environment variables in the pipeline settings to make this configuration easier to copy to other projects.

This action will take a long time the first time it runs as it has to copy all your project's files to your server, every deployment after that though it only copies the changed files. 

The “Transfer files” action

Activate release

This is the most complicated part of our pipeline, we're essentially doing three things: 

  • Creating the release folder for the current revision
  • Creating and symlinking the persistence folders 
  • Linking the latest release to a current link in the working directory.

Below the image is the full code with extra comments to clarify what we're doing

Running the necessary SSH commands
# If there's no releases directory, create it
if [ ! -d "releases" ]; then
    echo "Creating: releases/" && mkdir -p releases; 
fi

# If there's a directory for the current release and we've asked to refresh the current release, then we remove it 
if [ -d "releases/${execution.to_revision.revision}" ] 
&& [ "${execution.refresh}" = "true" ]; then 
	echo "Removing: releases/${execution.to_revision.revision}" 
    && rm -rf releases/${execution.to_revision.revision}; 
fi

# If we don't have a folder for the current release then we create it from the current deploy-cache
if [ ! -d "releases/${execution.to_revision.revision}" ]; then
	echo "Creating: releases/${execution.to_revision.revision}" 
    && cp -dR deploy-cache releases/${execution.to_revision.revision}; 
fi

# Create persistence folders if they don't exist yet
echo "Creating: persistence directories"
mkdir -p ~/httpdocs/storage/assets
mkdir -p ~/httpdocs/storage/imager
mkdir -p ~/httpdocs/storage/craft

# Symlink to persistence folders
echo "Symlinking: persistence directories"
ln -nfs ~/httpdocs/storage/assets ~/httpdocs/releases/${execution.to_revision.revision}/public/assets
ln -nfs ~/httpdocs/storage/imager ~/httpdocs/releases/${execution.to_revision.revision}/public/imager
ln -nfs ~/httpdocs/storage/craft ~/httpdocs/releases/${execution.to_revision.revision}/craft/storage

# Finally we symlink to the current release
echo "Linking current to revision: ${execution.to_revision.revision}"
rm -f current
ln -s releases/${execution.to_revision.revision} current

# As a last step we remove the latest 5 releases
echo "Removing old releases"
cd releases && ls -t | tail -n +6 | xargs rm -rf

Further reading

Most of this setup was done with the help of the following resources:

If you have any questions or comments, or you find that something in this blogpost doesn't work (anymore), don't hesitate to contact me!

MENU