Skip to main content

Professional deployment of websites using Capistrano - Part 4

Combining Capistrano and Drush for deploying Drupal powered websites

WarningPlease note that since Capistrano 3's release in October 2013 this Capistrano 2 based tutorial series has been superseded by an updated Capistrano 3 tutorial series.
4. Combining Capistrano and Drush for deploying Drupal powered websites

This final instalment builds on what we've run through previously to demonstrate how you can tailor a Capistrano deploy script to fit your application. We've chosen a Drupal website as an example because:

  • Drupal is very popular.
  • There doesn't seem to be much by way of comprehensive 'how-tos' for combining Capistrano and Drupal.
  • We like using Drupal and so we already have a custom deploy script up our sleeves to write about.

For the purpose of this tutorial we're assuming you're already familiar with how to use Drush. For those of you who aren't, Drush is short for 'Drupal Shell', basically it's a tool for running functionality you'd normally run through the administration front end of a Drupal site on the Unix command line. If you're responsible for running a Drupal site and aren't using Drush yet I strongly recommend you look into adopting it.

As a starting point let's look at the Capistrano deploy script we finished with at the end of Part 3:

set :application, "example"

set :stages, %w(production staging)
set :default_stage, "staging" 
require 'capistrano/ext/multistage' 

set :scm, :subversion 
set :repository_root, "svn+ssh://svn.zodiacmedia.co.uk/example" 
set :deploy_via, :export 

#server "staging.zodiacmedia.co.uk", :app, :web, :db 
#set :deploy_to, "/var/www/staging.example.com"
 
set :user, "deploy" 
default_run_options[:pty] = true 
ssh_options[:keys] = [File.join(ENV["HOME"], ".ssh", "id_rsa")] 
ssh_options[:port] = 22 
set :use_sudo, false 

set :keep_releases, 5 
after "deploy:update", "deploy:cleanup" 

set(:tag) { Capistrano::CLI.ui.ask("Enter SVN tag to deploy (or type 'trunk' to deploy from trunk): ") } 
set(:repository) { (tag == "trunk") ? "#{repository_root}/trunk" : "#{repository_root}/tags/#{tag}" }

Writing a tailored Cap deploy script is all about using Capistrano hooks. The following line is an example of a Capistrano hook in action:

after "deploy:update", "deploy:cleanup"

What we're telling Capistrano to do here is run the task deploy:cleanup after the task deploy:update has run. In this syntax deploy is a Ruby namespace and cleanup and update are tasks within this namespace. You can see all of the tasks in the deploy namespace within this file from Capistrano's source code.

Now let's cut to the chase, if 'Example.com' were a Drupal powered site we'd use the following deploy script:

set :application, "example"

set :stages, %w(production staging)
set :default_stage, "staging"
require 'capistrano/ext/multistage'

set :scm, :subversion
set :repository_root, "svn+ssh://svn.zodiacmedia.co.uk/example"
set :deploy_via, :export

set :user, "deploy"
default_run_options[:pty] = true
ssh_options[:keys] = [File.join(ENV["HOME"], ".ssh", "id_rsa")]
ssh_options[:port] = 22
set :use_sudo, false

set :keep_releases, 5

set :drupal_file_public_path, "sites/default/files"
set :drupal_file_private_path, "sites/default/files/private"
set :drupal_file_temporary_path, "../../shared/tmp"

after "deploy:setup",
"drupal:prepare_shared_paths", "drupal:prepare_database_backup_path"

before "deploy:update_code",
"deploy:repository_location"
"drupal:cache_clear"

before "deploy:finalize_update",
"drupal:rsync_shared_files"
after "deploy:finalize_update",
"drupal:set_permissions_for_runtime", "drupal:create_site_settings_file", "drupal:backupdb"

after "deploy:update", "drupal:updatedb", "deploy:cleanup"

namespace :deploy do
    desc "Prompt user for repository location to deploy from"
    task :repository_location, :except => { :no_release => true } do
        set :tag, Capistrano::CLI.ui.ask("Enter SVN tag to deploy (or type 'trunk' to deploy from trunk): ")
        set :tag, "trunk" if eval("#{:tag.to_s}.empty?")
        set :repository, (tag == "trunk") ? "#{repository_root}/trunk" : "#{repository_root}/tags/#{tag}"
    end
    
    desc "Overwrite equivalent native namespace from Capistrano"
    namespace :web do
        task :disable, :except => { :no_release => true } do
            drupal.site_offline
        end

        task :enable, :except => { :no_release => true } do
            drupal.site_online
        end
    end
end

namespace :drupal do
    desc "Include creation of additional folder for backups"
    task :prepare_database_backup_path, :except => { :no_release => true } do
        run "mkdir -p #{deploy_to}/database_backups"
        run "chmod 750 #{deploy_to}/database_backups"
    end

    desc "Include creation of additional Drupal specific shared folders"
    task :prepare_shared_paths, :except => { :no_release => true } do
        run "mkdir -p #{shared_path}/sites/default/files"
        run "mkdir -p #{shared_path}/tmp"
    end

    desc "Synchronise shared files"
    task :rsync_shared_files, :except => { :no_release => true } do
        on_rollback do
            run <<-EOC
                ln -s #{shared_path}/sites/default/files #{current_release}/sites/default/files
            EOC
        end

        run "rsync -av #{latest_release}/sites/default/files/ #{shared_path}/sites/default/files/"
        run "rm -rf  #{latest_release}/sites/default/files"
        run "ln -s #{shared_path}/sites/default/files #{latest_release}/sites/default/files"
    end

    desc "Update file permissions to follow best security practice: https://drupal.org/node/244924"
    task :set_permissions_for_runtime, :except => { :no_release => true } do
        run "find #{latest_release} -type f -exec chmod 640 {} ';'"
        run "find #{latest_release} -type d -exec chmod 2750 {} ';'"
        run "find #{shared_path}/sites/default/files -type f -exec chmod 660 {} ';'"
        run "find #{shared_path}/sites/default/files -type d -exec chmod 2770 {} ';'"
        run "find #{shared_path}/tmp -type d -exec chmod 2770 {} ';'"
    end

    desc "Create site settings.php"
    task :create_site_settings_file, :except => { :no_release => true } do
        run "ln -s #{release_path}/sites/default/#{drupal_settings_file} #{release_path}/sites/default/settings.php"
    end

    desc "Backup the database"
    task :backupdb, :except => { :no_release => true } do
        release_name = Time.now.utc.strftime("%Y%m%d.%H%M%S-") + tag + ".r" + real_revision.to_s
        run "drush -r #{deploy_to}/current sql-dump --gzip --result-file=#{deploy_to}/database_backups/#{release_name}.sql.gz"
    end

    desc "Run Drupal database migrations if required. This applies database updates for modules installed on filesystem but DB updates haven't been run yet."
    task :updatedb, :except => { :no_release => true } do
        run "drush -r #{deploy_to}/current updatedb -y"
    end

    desc "Clear the drupal cache"
    task :cache_clear, :except => { :no_release => true } do
        run "drush -r #{deploy_to}/current cc all"
    end

    desc "Set the site offline"
    task :site_offline, :except => { :no_release => true } do
        on_rollback do
            run "drush -r #{deploy_to}/current vdel -y --exact maintenance_mode"
        end

        run "drush -r #{deploy_to}/current vset --exact maintenance_mode 1"
    end

    desc "Set the site online"
    task :site_online, :except => { :no_release => true } do
        run "drush -r #{deploy_to}/current vdel -y --exact maintenance_mode"
    end

    desc "Set file system variables"
    task :set_file_system_variables, :except => { :no_release => true } do
        run "drush -r #{deploy_to}/current vset --exact file_public_path #{drupal_file_public_path}"
        run "drush -r #{deploy_to}/current vset --exact file_private_path #{drupal_file_private_path}"
        run "drush -r #{deploy_to}/current vset --exact file_temporary_path #{drupal_file_temporary_path}"
    end
end

And the corresponding 'live' and 'staging' multistage deploy scripts are respectively as follows:

server "www.example.com", :app, :web, :db, :primary => true

set :deploy_to, "/var/www/www.example.com"
set :drupal_settings_file, "settings.php.live"
server "staging.zodiacmedia.co.uk", :app, :web, :db, :primary => true

set :deploy_to, "/var/www/staging.example.com"
set :drupal_settings_file, "settings.php.staging"

A lot to digest here! I believe the task names and inline task descriptions mean that the script is accessible to competent developers so I won't go through it line by line because that will be too painful. Salient points include:

  • We define our Drupal website's 'shared paths', (the files folders we want to maintain across releases) at the start of the file. We then incorporate their creation into the deploy:setup Capistrano task and also synchronise files exported from SVN into these locations upon deploy.
  • We're using a symbolic link to create the settings.php config file for the Drupal site so that staging and live have their own unique version of this file stored in SVN.
  • We define what to do on_rollback for some tasks. These lines of code are executed if there is an error in the release process and the task they are defined in has already run.

Happy deploying!