I'm a big fan of having an automated deployment process. It's really the web development analog to the "one step build process", as described in the Joel Test. In the past I have used various shell scripts to perform this task, but I have recently become a convert to Capistrano (or "cap" for short). With Capistrano, uploading your code to the test server is as simple as typing cap deploy
. When you're ready to launch in production, it's just cap production deploy
.
From capify.org:
Simply put, Capistrano is a tool for automating tasks on one or more remote servers. It executes commands in parallel on all targeted machines, and provides a mechanism for rolling back changes across multiple machines.
In detail, here are the features that got me hooked. There's a lot more that cap can do, and I'll describe some more tricks in part 2 of this post.
- Atomic deployments with error checking. Cap uses a set of symlinked directories, and the links are updated during the final step. It also won't allow a deployment to keep plowing ahead if an intermediate step fails. This makes your deployment atomic; it will either fail or succeed entirely.
- Fast rollback. If something does go wrong, getting back to the previous state is as simple as
cap deploy:rollback
.
- Parallel execution. If you use multiple servers in a load-balanced environment, cap can make managing them easier.
- Multistage deployments. "Stages" are different server instances of your code. You may have different servers for development, content entry, and production. With the Multistage extension, cap can share code for common tasks between these stages.
Step-by-step
First, you'll need to install Capistrano, as well as the multistage extensions contained in capistrano-ext:
gem install capistrano
gem install capistrano-ext
Next, you'll need to add a couple of files to your project (the files' contents are listed at the end of this post). I lay out projects like this:
|-- capfile
|-- config
| |-- deploy
| | |-- development.rb
| | `-- production.rb
| `-- deploy.rb
`-- drupal
`-- index.php
Now edit the paths and project name in config/deploy.rb and config/deploy/development.rb to suit your environment.
You're now ready to run cap deploy:setup
. This task only needs to be run once, prior to the first deployment. It will create the necessary directories on your server, which will ultimately look something like the tree below. (Until your first deployment, there may not be anything in the releases directory, nor a link from current). Also notice the setup task created a local_settings.php file. The utility of this is described in my post on stage-specific settings. The web server document root in this example is /var/www/current.
|-- current -> /var/www/releases/20091023202918
|-- releases
| |-- 20091023191450
| `-- 20091023202918
`-- shared
|-- cached-copy
|-- default
| |-- files
| `-- local_settings.php
`-- system
Finally, run cap deploy
.
cap deploy
will speedily upload a new release of your project, then link it to the current document root if all went well.
Watch for part 2, where I'll cover some extra tasks included in this Capfile, as well as managing multi-site and multi-stage deployments.
config/deploy.rb:
### This file contains project-specific settings ###
# The project name.
set :application, "myproject"
# List the Drupal multi-site folders. Use "default" if no multi-sites are installed.
set :domains, ["default"]
# Set the repository type and location to deploy from.
set :scm, :subversion
set :repository, "https://example.com/trunk/drupal"
# Use a remote cache to speed things up
set :deploy_via, :remote_cache
# Multistage support - see config/deploy/[STAGE].rb for specific configs
set :default_stage, "development"
set :stages, %w(production development)
# Generally don't need sudo for this deploy setup
set :use_sudo, false
config/deploy/development.rb
### This file contains stage-specific settings ###
# Set the deployment directory on the target hosts.
set :deploy_to, "/var/www/#{application}"
# The hostnames to deploy to.
role :web, "devel.example.com"
# Specify one of the web servers to use for database backups or updates.
# This server should also be running Drupal.
role :db, "devel.example.com", :primary => true
# The username on the target system, if different from your local username
# ssh_options[:user] = 'alice'
# The path to drush
set :drush, "cd #{current_path} ; /usr/bin/php /data/lib/php/drush/drush.php"
Capfile
This file does the real work. If you aren't a ruby programmer, don't panic; neither am I (yet). Hopefully, this is general-purpose enough that it will work for you out of the box. Additionally, individual tasks can be overridden in deploy.rb or development.rb – so once you have a Capfile you like the same file can be reused across all your projects.
load 'deploy' if respond_to?(:namespace) # cap2 differentiator
Dir['vendor/plugins/*/recipes/*.rb'].each { |plugin| load(plugin) }
load 'config/deploy.rb'
require 'capistrano/ext/multistage'
namespace :deploy do
# Overwritten to provide flexibility for people who aren't using Rails.
desc "Prepares one or more servers for deployment."
task :setup, :except => { :no_release => true } do
dirs = [deploy_to, releases_path, shared_path]
domains.each do |domain|
dirs += [shared_path + "/#{domain}/files"]
end
dirs += %w(system).map { |d| File.join(shared_path, d) }
run "umask 02 && mkdir -p #{dirs.join(' ')}"
end
desc "Create settings.php in shared/config"
task :after_setup do
configuration = <<-EOF
<?php
$db_url = 'mysql://username:password@localhost/databasename';
$db_prefix = '';
EOF
domains.each do |domain|
put configuration, "#{deploy_to}/#{shared_dir}/#{domain}/local_settings.php"
end
end
desc "link file dirs"
task :after_update_code do
domains.each do |domain|
# link settings file
run "ln -nfs #{deploy_to}/#{shared_dir}/#{domain}/local_settings.php #{release_path}/sites/#{domain}/local_settings.php"
# remove any link or directory that was exported from SCM, and link to remote Drupal filesystem
run "rm -rf #{release_path}/sites/#{domain}/files"
run "ln -nfs #{deploy_to}/#{shared_dir}/#{domain}/files #{release_path}/sites/#{domain}/files"
end
end
# desc '[internal] Touches up the released code.'
task :finalize_update, :except => { :no_release => true } do
run "chmod -R g+w #{release_path}"
end
desc "Flush the Drupal cache system."
task :cacheclear, :roles => :db, :only => { :primary => true } do
domains.each do |domain|
run "#{drush} --uri=#{domain} cache clear"
end
end
namespace :web do
desc "Set Drupal maintainance mode to online."
task :enable do
domains.each do |domain|
php = 'variable_set("site_offline", FALSE)'
run "#{drush} --uri=#{domain} eval '#{php}'"
end
end
desc "Set Drupal maintainance mode to off-line."
task :disable do
domains.each do |domain|
php = 'variable_set("site_offline", TRUE)'
run "#{drush} --uri=#{domain} eval '#{php}'"
end
end
end
after "deploy", "deploy:cacheclear"
after "deploy", "deploy:cleanup"
# Each of the following tasks are Rails specific. They're removed.
task :migrate do
end
task :migrations do
end
task :cold do
end
task :start do
end
task :stop do
end
task :restart do
end
end
desc "Download a backup of the database(s) from the given stage."
task :download_db, :roles => :db, :only => { :primary => true } do
domains.each do |domain|
filename = "#{domain}_#{stage}.sql"
run "#{drush} --uri=#{domain} sql dump --structure-tables-key=common > ~/#{filename}"
download("~/#{filename}", "db/#{filename}", :via=> :scp)
end
end