concrete5 DevOps Demystified using Deployer

It’s a challenge that every web developer has faced: you’ve got a site running just how you like it, but it’s running on code and a database hosted on your local machine. You need to get it to you hosting provider.

In the old days - which I, as old in internet-years, had the good fortune to experience firsthand - we would dump the database and ftp all the files, assets and database files up to the server manually, then use ssh to connect to the live server and import the database. This was simple and easy to learn, but it has significant drawbacks:

  • It’s slow: websites these days are many megabytes, even when zipped. And god help you if you FTP without zipping the files first.
  • It’s error prone: why repeat something over and over that is the same every time? You’re more likely to make a mistake, leaving you with a broken site.
  • It’s destructive: if something goes wrong there’s no easy way to roll back. Yes you backup the live website directly before rolling out the new code - but do you really want to have a bunch of directories named “html_bak” lying around?

Worst of all, a more cumbersome process means more friction, which means you won’t want to make changes to your websites and web applications? This is by far the worst aspect: crummy devops leads to stagnation.

It gets worse

If your site is a concrete5 site, you may have even more things you’ll need to do in order to roll out new code. Every time you roll out new code you may need to:

  • Run the core update program
  • Run custom command line scripts that are a part of your update.
  • Update packages.
  • Update core database entities.
  • Rebuild minified Javascript or CSS files that your theme depends on.

Modern web development is complex. Who has time for all that? There’s gotta be a better way!

There is.


Deployer is an MIT-licensed library for deploying PHP applications, also written in PHP. It’s quick, easy to install, and - most importantly - it’s easy to customize.

Do you have a concrete5 site checked into source control like Bitbucket or Github somewhere? You can deploy that site to a server easily using Deployer. It could be as easy as running

dep deploy production

from the root of your project/repository.

With one command I’ve deployed code from my git repository to my live site, while ensuring that old code can easily be rolled back. No messing around with rsync or FTP.

The Promise

How do we get there?

First, we install and configure Deployer. Installing Deployer is easy. You can learn more on the docs page:

When you get done, you should be able to run


From your command line.

Next, run

dep init

This will create a deploy.php file in your root. This is called the “recipe” for your site. Each deployer recipe contains configuration in a simple syntax to define your environments, including support for staging and production environments. Additionally, a recipe defines tasks that are available to deployer to deploy your application. Most recipes extend a common recipe, which defines tasks that most deployments will like to make, and ensures that your deploy.php can be pretty clean and contain just configuration that is unique to your hosting setup.

(Additionally, Deployer ships with a number of boilerplate recipes for common platforms like Drupal and Wordpress that you can extend. Unfortunately, there is currently no default shipping recipe for concrete5 – which we would love to change. Pull requests, anyone?)

The most common task is deploy, which runs a number of sub-tasks to prepare the hosting environment for a new release, checking out code from source control, running PHP’s composer binary, and more.

Deployer also makes it really easy to create your own tasks. For example, in my own configuration I’ve created tasks that will pull down the current version of the database found on my live website, and refresh my local stage with that content.

Sample Deployer Configuration


Here’s how easy it is to define a host block in your deploy.php file:

    ->set('deploy_path', '~/app');

This defines your “production” host, accessible at, with some parameters. You can get fancy with this, as well:

    ->addSshOption('UserKnownHostsFile', '/dev/null')
    ->addSshOption('StrictHostKeyChecking', 'no');

One thing deployer really excels at is readable configuration. This is accomplished by using PHP functions in the Deployer namespace, and having those functions return objects to enable chaining. The same elegant approach is true with tasks, which use closures. Here, I’ve created a Deployer task to run the concrete5 console “Clear Cache” command:

desc('Clear Cache');
task('deploy:clear_cache', function () {
    run('cd {{release_path}}/web && ./concrete/bin/concrete5 c5:clear-cache');
after('deploy:symlink', 'deploy:clear_cache');

whenever I type

dep deploy:clear-cache production

Additionally, I’ve also got that command automatically running any time after the core deploy:symlink command runs.

concrete5 and Composer

Another huge part of this setup is using concrete5 with Composer, the PHP library for package management. As of version 8, concrete5 is fully installable via Composer, and if you’re doing any kind of custom work with concrete5 it is absolutely how I recommend installing it. You can get more details [in the project README](( I’ll also be writing an in-depth article on the subject soon.

This setup relies on installing concrete5 with Composer. Simply specify which concrete5 version you want in your project’s composer.json file, and deploying via Deployer will make sure that you always have the proper version. You can even update concrete5 via composer locally, and deploy that change to your server using Deployer.

My Sample Configuration

As I mentioned before, I’m actively using Deployer to deploy to It works great. I’ve tailed my deploy.php file to work well with concrete5, which means it will be more verbose than using with some other platforms that actively ship with a Deployer recipe. (Seriously, let’s get a pull request to Deployer on GitHub that adds this functionality!)

namespace Deployer;

require 'recipe/common.php';

// Configuration

set('repository', '');
set('git_tty', true); // [Optional] Allocate tty for git on first deployment
set('shared_files', []);
set('shared_dirs', [
set('allow_anonymous_stats', false);
set('local_path', '/path/to/my/local/project'); // This is so stupid. This is the path to deploy.php. You'd think we could get this information from deployer.
set('cli_path', 'web/concrete/bin/concrete5'); // Relative from local path.
// Hosts

host('') // put your real IP here.
    ->set('deploy_path', '/server/path/to/webroot/');

set('db_local', 'database');
set('db_user_local', 'dbuser');
set('db_password_local', 'dbpassword');
set('db_remote', 'remotedatabase');
set('db_user_remote', 'remotedbuser');
set('db_password_remote', 'remotedbpassword');
set('files_to_mirror_dir', 'web/application/files');

// Tasks
desc('Regenerate Doctrine ORM Proxies');
task('deploy:regenerate_proxies', function () {
    run('cd {{release_path}}/web && ./concrete/bin/concrete5 orm:generate:proxies');
after('deploy:vendors', 'deploy:regenerate_proxies');

desc('Clear Cache');
task('deploy:clear_cache', function () {
    run('cd {{release_path}}/web && ./concrete/bin/concrete5 c5:clear-cache');
after('deploy:symlink', 'deploy:clear_cache');

desc('Restart PHP-FPM service');
task('php-fpm:restart', function () {
    run('sudo systemctl restart php7.0-fpm.service');
after('deploy:symlink', 'php-fpm:restart');

desc('Deploy your project');
task('deploy', [

//  deploy fails automatically unlock.
after('deploy:failed', 'deploy:unlock');

// Pull functionality
task('pull:backup_db', function () {
    $db_remote = get('db_remote');
    $db_user_remote = get('db_user_remote');
    $db_password_remote = get('db_password_remote');
    $db_remote_file = $db_remote . '_' . date('YmdHis') . '.sql';
    $local_path = get('local_path');

    set('db_remote_file', $db_remote_file);
    writeln('Creating a new database dump (approx. 10s) at ' . $db_remote_file);
    run('mysqldump -u ' . $db_user_remote . ' -p' . $db_password_remote . ' ' . $db_remote . ' > ~/' . $db_remote_file);

    writeln( 'Getting database dump (approx. 10s) ' . $db_remote_file );
    download($db_remote_file, $local_path);

task('pull:refresh_db', function () {
    $db_local = get('db_local');
    $db_user_local = get('db_user_local');
    $db_password_local = get('db_password_local');
    $db_remote_file = get('db_remote_file');
    $local_path = get('local_path');
    $db_local_file = $local_path . '/' . $db_remote_file;
    if (!$db_remote_file || !is_file($db_local_file)) {
        throw new \Exception('You cannot refresh the database unless you pull it in the same task. Unable to locate .sql file in ' . $local_path);

    writeln( 'Refreshing database locally (approx. 10s) ' . $db_remote_file );

    runLocally('mysql -u ' . $db_user_local . ' -p' . $db_password_local . ' ' . $db_local . ' < ' . $db_local_file);


task('pull:refresh_files', function() {
    $dir = get('files_to_mirror_dir');
    runLocally("sudo rm -rf {{local_path}}/{$dir}/cache");
    $sharedPath = "{{deploy_path}}/shared/{$dir}";
    // Trim off the last segment because it gets added
    $dir = dirname($dir);
    download($sharedPath, $dir);

task('pull:cleanup', function () {
    $db_remote_file = get('db_remote_file', false);
    $local_path = get('local_path');
    if ($db_remote_file) {
        $db_local_file = $local_path . '/' . $db_remote_file;
        if (is_file($db_local_file)) {
            writeln('Deleting local database file ' . $db_local_file);
            runLocally('rm ' . $db_local_file);
        run('rm ' . $db_remote_file);

    $dir = get('files_to_mirror_dir');
    runLocally("sudo rm -rf {{local_path}}/{$dir}/cache");
    $path = "{{local_path}}/{$dir}";
    runLocally('chmod -R 777 ' . $path);

task('pull', [

There’s a lot going on here, so let’s break it down. Basically the first half of this file is configuration, with a lot of usernames, passwords, and paths. (I’ve replaced the usernames and passwords with fakes, obviously.) Then, we have some custom tasks defined, some of which are grouped into the “deploy” task. When we need to deploy, the following tasks are run in order:

  • deploy:prepare
  • deploy:lock
  • deploy:release
  • deploy:update_code
  • deploy:shared
  • deploy:writable
  • deploy:vendors
  • deploy:clear_paths
  • deploy:symlink
  • deploy:unlock
  • cleanup
  • success

These tasks are all defined within the common Deployer recipe file. Then, my own custom tasks are hooked in to run after some of these tasks, using the after() method chained to the task() method. This ensures, for example, that my concrete5 cache is cleared at the proper time in every Deploy.

Additionally, I have some handy tasks defined here that aren’t specifically things I run every time I deploy. With just one command I can backup my database, refresh my local database, refresh my local files, and cleanup after the operations, using

dep pull production

All of these details of these tasks can be seen in this configuration file.

What’s Next

Hopefully this will get you started with integrated Deployer or a similar tool in your own concrete5 workflow. It’s made a huge difference to me, and made it fun and easy to actually work on my own sites, knowing that getting my changes live isn’t going to be a chore. It would be great to get a real concrete5 recipe shipping with Deployer, but in the meantime I hope this will help concrete5 developers modernize their workflow.

Loading Conversation