In simple cases, if you use Heroku, application deployment process can be as easy as one shell command. But Heroku does not provide enough scaling and flexibility for more advanced scenarios or more serious load.

If you need to test something and then be able to expand to thousands of requests per second, EC2 from Amazon Web Services is definitely the way to go. It provides you with a virtual system which is totally under your control. You can add additional storage, move storage between servers and increase CPU/memory in almost real-time.

The downside, though, is that you have to setup the whole application infrastructure by yourself: from frontend servers to deployment scripts to security customizations. There is no preferable way of doing one thing or another, so here I’m offering what worked perfectly for me, and what I was not able to find while surfing the Internet for solutions.

Overview

The problem we’re solving is Rails app deployment on a remote Amazon EC2 instance with Capistrano.

I’m considering Capistrano as the core component of the solution. Capistrano is used for application deployment and is pretty much industry standard these days. You can find lots of deployment recipes for it on the Internet. With Capistrano we can deploy Rails applications on remote servers, pre-configure databases, precompile assets and do some other fancy stuff.

RVM is used to install the latest Ruby version on the machine no matter the packaging system used. It allows to keep multiple Ruby/Rails versions and enables easy switching between them.

Bundler is a part of Rails installation. Bundler offers very efficient gem version control and deployment system. It will be used with Capistrano to install the necessary gems on the target machine.

Unicorn is a high-performance Unix-like Rack application server. We will be using it as a backend system here. nginx acts as a frontend, but its configuration is out of scope for the current post.

Directory Structure & Users

The directory structure is important for the two reasons:

  1. It provides a skeleton for services configuration.

  2. It serves us to harden application security.

The user we will be deploying our app under is _ec2-user _(standard Amazon EC2 user). We will also be using sudo to drop current user credentials and start Rails application under _rails _user. _rails _user is not allowed to write in our app’s dir, which is a good additional security measure.

/var/rails
`-- [drwxrwxr-x ec2-user ec2-user]  myapp
    |-- [lrwxrwxrwx ec2-user ec2-user]  current -> /var/rails/myapp/releases/20121007163131
    |-- [drwxrwxr-x ec2-user ec2-user]  releases
    |   |-- [drwxrwxr-x ec2-user ec2-user]  20121007162712
    |   `-- [drwxrwxr-x ec2-user ec2-user]  20121007163131
    `-- [drwxrwxr-x ec2-user ec2-user]  shared
        |-- [drwxrwxr-x ec2-user ec2-user]  assets
        |-- [drwxrwxr-x ec2-user ec2-user]  bundle
        |-- [drwxr-xr-x ec2-user ec2-user]  cached-copy
        |-- [drwxrwxr-x ec2-user rails   ]  log
        |-- [drwxrwxr-x ec2-user rails   ]  pids
        `-- [drwxrwxr-x ec2-user ec2-user]  system

This is the final directory structure after $ cap deploy:setup and $ cap deploy commands have been run. You can see custom permissions on log and pids folders, as we want to allow unicorn (after doing $ sudo -u rails) to be able to write into them.

Unicorn

The unicorn configuration is pretty typical:

# 4 workers is enough for our app
worker_processes 4

# App location
@app = "/var/rails/myapp/current"

# Listen on fs socket for better performance
listen "#{@app}/tmp/sockets/unicorn.sock", :backlog => 64

# Nuke workers after 30 seconds instead of 60 seconds (the default)
timeout 30

# App PID
pid "#{@app}/tmp/pids/unicorn.pid"

# By default, the Unicorn logger will write to stderr.
# Additionally, some applications/frameworks log to stderr or stdout,
# so prevent them from going to /dev/null when daemonized here:
stderr_path "#{@app}/log/unicorn.stderr.log"
stdout_path "#{@app}/log/unicorn.stdout.log"

# To save some memory and improve performance
preload_app true
GC.respond_to?(:copy_on_write_friendly=) and
  GC.copy_on_write_friendly = true

# Force the bundler gemfile environment variable to
# reference the Сapistrano "current" symlink
before_exec do |_|
  ENV["BUNDLE_GEMFILE"] = File.join(@app, 'Gemfile')
end

before_fork do |server, worker|
  # the following is highly recomended for Rails + "preload_app true"
  # as there's no need for the master process to hold a connection
  defined?(ActiveRecord::Base) and
    ActiveRecord::Base.connection.disconnect!
end

after_fork do |server, worker|
  # the following is *required* for Rails + "preload_app true",
  defined?(ActiveRecord::Base) and
    ActiveRecord::Base.establish_connection
end

Gemfile

source 'https://rubygems.org'

...

# Unicorn Web Server
gem 'unicorn'

# Deploy with Capistrano
gem 'capistrano'
# Capistrano RVM integration
gem 'rvm-capistrano'

Capistrano

This is the most interesting part, as we glue all the components together:

# Automatically precompile assets
load "deploy/assets"

# Execute "bundle install" after deploy, but only when really needed
require "bundler/capistrano"

# RVM integration
require "rvm/capistrano"

# Application name
set :application, "myapp"

# Application environment
set :rails_env, :production

# Deploy username and sudo username
set :user, "ec2-user"
set :user_rails, "rails"

# App Domain
set :domain, "myapp.com"

# We don't want to use sudo (root) - for security reasons
set :use_sudo, false

# Target ruby version
set :rvm_ruby_string, '1.9.3'

# System-wide RVM installation
set :rvm_type, :system

# We use sudo (root) for system-wide RVM installation
set :rvm_install_with_sudo, true

# git is our SCM
set :scm, :git

# Use github repository
set :repository, "git://github.com/myuser/myapp.git"

# master is our default git branch
set :branch, "master"

# Deploy via github
set :deploy_via, :remote_cache
set :deploy_to, "/var/rails/#{application}"

# We have all components of the app on the same server
server domain, :app, :web, :db, :primary => true

# Install RVM and Ruby before deploy
before "deploy:setup", "rvm:install_rvm"
before "deploy:setup", "rvm:install_ruby"

# Apply default RVM version for the current account
after "deploy:setup", "deploy:set_rvm_version"

# Fix log/ and pids/ permissions
after "deploy:setup", "deploy:fix_setup_permissions"

# Fix permissions
before "deploy:start", "deploy:fix_permissions"
after "deploy:restart", "deploy:fix_permissions"
after "assets:precompile", "deploy:fix_permissions"

# Clean-up old releases
after "deploy:restart", "deploy:cleanup"

# Unicorn config
set :unicorn_config, "#{current_path}/config/unicorn.conf.rb"
set :unicorn_binary, "bash -c 'source /etc/profile.d/rvm.sh && bundle exec unicorn_rails -c #{unicorn_config} -E #{rails_env} -D'"
set :unicorn_pid, "#{current_path}/tmp/pids/unicorn.pid"
set :su_rails, "sudo -u #{user_rails}"

namespace :deploy do
  task :start, :roles => :app, :except => { :no_release => true } do
    # Start unicorn server using sudo (rails)
    run "cd #{current_path} && #{su_rails} #{unicorn_binary}"
  end

  task :stop, :roles => :app, :except => { :no_release => true } do
    run "if [ -f #{unicorn_pid} ]; then #{su_rails} kill `cat #{unicorn_pid}`; fi"
  end

  task :graceful_stop, :roles => :app, :except => { :no_release => true } do
    run "if [ -f #{unicorn_pid} ]; then #{su_rails} kill -s QUIT `cat #{unicorn_pid}`; fi"
  end

  task :reload, :roles => :app, :except => { :no_release => true } do
    run "if [ -f #{unicorn_pid} ]; then #{su_rails} kill -s USR2 `cat #{unicorn_pid}`; fi"
  end

  task :restart, :roles => :app, :except => { :no_release => true } do
    stop
    start
  end

  task :set_rvm_version, :roles => :app, :except => { :no_release => true } do
    run "source /etc/profile.d/rvm.sh && rvm use #{rvm_ruby_string} --default"
  end

  task :fix_setup_permissions, :roles => :app, :except => { :no_release => true } do
    run "#{sudo} chgrp #{user_rails} #{shared_path}/log"
    run "#{sudo} chgrp #{user_rails} #{shared_path}/pids"
  end

  task :fix_permissions, :roles => :app, :except => { :no_release => true } do
    # To prevent access errors while moving/deleting
    run "#{sudo} chmod 775 #{current_path}/log"
    run "#{sudo} find #{current_path}/log/ -type f -exec chmod 664 {} ;"
    run "#{sudo} find #{current_path}/log/ -exec chown #{user}:#{user_rails} {} ;"
    run "#{sudo} find #{current_path}/tmp/ -type f -exec chmod 664 {} ;"
    run "#{sudo} find #{current_path}/tmp/ -type d -exec chmod 775 {} ;"
    run "#{sudo} find #{current_path}/tmp/ -exec chown #{user}:#{user_rails} {} ;"
  end

  # Precompile assets only when needed
  namespace :assets do
    task :precompile, :roles => :web, :except => { :no_release => true } do
      # If this is our first deploy - don't check for the previous version
      if remote_file_exists?(current_path)
        from = source.next_revision(current_revision)
        if capture("cd #{latest_release} && #{source.local.log(from)} vendor/assets/ app/assets/ | wc -l").to_i > 0
          run %Q{cd #{latest_release} && #{rake} RAILS_ENV=#{rails_env} #{asset_env} assets:precompile}
        else
          logger.info "Skipping asset pre-compilation because there were no asset changes"
        end
      else
        run %Q{cd #{latest_release} && #{rake} RAILS_ENV=#{rails_env} #{asset_env} assets:precompile}
      end
    end
  end
end

# Helper function
def remote_file_exists?(full_path)
  'true' ==  capture("if [ -e #{full_path} ]; then echo 'true'; fi").strip
end

The End

That’s pretty much it. I always believed that reading code is the best way to understand what the author wanted to say. So here you will find mostly real-world examples and scenarios, with little or no additional description. If you don’t like this format or finding a hard time understanding what I’m writing about, please feel free to give your feedback below.

Good luck and happy coding! ;)