sajad torkamani

Why automate?

Let’s suppose you have a static HTML website built with Jekyll and you deploy this on an Ubuntu 20.04 server. If you manually deploy the website, the flow for every release will probably look something like this:

  1. Push changes to remote Git repo (e.g., git push origin master)
  2. SSH into server (e.g., ssh sajad@123.44.55.66)
  3. cd into project folder and run git pull to pull latest updates
  4. Install dependencies (e.g., bundle install)
  5. Run a build task to build static HTML pages, CSS and JS files ( e.g., bundle exec jekyll build).

That is tedious. Let’s see how you can automate those steps so that the flow becomes:

  1. Push changes to remote Git repo (e.g., git push origin master)
  2. Run deploy command (e.g., bundle exec cap deploy production)

That’s what Capistrano tries to do. It lets you encapsulate a bunch of steps (SSH into server, git pull, install dependencies, etc.) into a single command. Let’s take a look.

Install capistrano gem

Add to Gemfile.

gem 'capistrano', '~> 3.16', require: false

Execute:

bundle install

Capify project

bundle exec cap install

This should create the following files:

├── Capfile
├── config
│   ├── deploy
│   │   ├── production.rb
│   │   └── staging.rb
│   └── deploy.rb
└── lib
    └── capistrano
            └── tasks

Let’s assume you only have a production server so go ahead and delete the config/deploy/staging.rb file.

Configure config/deploy.rb

The config/deploy.rb file is used to set global configuration that is shared between multiple environments (e.g., staging, ci, production, etc). Edit config/deploy.rb so it looks like this:

# frozen_string_literal: true

lock '~> 3.16.0'

set :application, 'whatever'
set :repo_url, '<git-repo>'
set :keep_releases, 5

namespace :app do
  desc 'ls'
  task :ls do
    on roles(:app) do
      execute :ls, '-la'
    end
  end

  desc 'printenv'
  task :printenv do
    on roles(:app) do
      execute :printenv
    end
  end
end

We’ll use the custom app:ls task to test that Capistrano can successfully SSH into our servers.

Configure config/deploy/production.rb

In Capistrano, a server represents a single server (e.g., EC2 instance or Digital Ocean droplet). Each server can have one or more roles such as webapp, or db. For the example Jekyll website, you can use a single app role.

Edit config/deploy/production.rb:

# frozen_string_literal: true

server '<ipv4-address>', 'user': '<ssh-user>', roles: %w[app]

set :deploy_to, '<deploy-path-on-server>'
set :branch, 'master'

Before you proceed further, you’ll want to ensure you’ve configured SSH access to your server so that running ssh <ssh-user>@<ipv4-address> logs you into the server.

Test Capistrano configuration

Run the following command:

bundle exec cap production app:ls

You should see the list of files and directories in the home directory of the default deploy user in the production server. If you get any errors, it’s probably because your SSH access isn’t configured correctly..

Test deploy

Run the following command to run a dry run (simulation):

bundle exec cap production deploy --dry-run

If this is successful, go ahead and deploy for real:

bundle exec cap production deploy

(Optional) Setup rbenv

If you’re not using rbenv, skip to the next section.

Add the following to your Gemfile:

gem 'capistrano-rbenv', '~> 2.2'

Execute:

bundle install

Add the following to your Capfile:

require 'capistrano/rbenv'

Edit config/deploy.rb:

set :rbenv_type, :user # or :system, or :fullstaq (for Fullstaq Ruby), depends on your rbenv setup

# If setting from file:
set :rbenv_ruby, File.read('.ruby-version').strip
# in case you set it explicitly:
# set :rbenv_ruby, '3.1.0'
# 
set :rbenv_prefix, "RBENV_ROOT=#{fetch(:rbenv_path)} RBENV_VERSION=#{fetch(:rbenv_ruby)} #{fetch(:rbenv_path)}/bin/rbenv exec"
set :rbenv_map_bins, %w{rake gem bundle ruby rails}
set :rbenv_roles, :all # default value

Setup bundler

Add the following to your Gemfile:

gem 'capistrano-bundler', '~> 2.0'

Execute:

bundle install

Add the following to your Capfile:

require 'capistrano/bundler'

Edit config/deploy.rb so that the .bundle directory is configured as a persistent directory:

append :linked_dirs, '.bundle'

Check that capistrano/bundler has been configured properly:

 bundle exec cap -T | grep bundler

You should see a bunch of tasks listed:

cap bundler:clean                  # Remove unused gems installed by bundler
cap bundler:config                 # Configure the Bundler environment for the release so that subsequent
cap bundler:install                # Install the current Bundler environment
cap bundler:map_bins               # Maps all binaries to use `bundle exec` by default

Execute a dry run to ensure your deployment is still working:

bundle exec cap production deploy --dry-run

You should see bundler tasks (e.g., bundler:install) in the deploy log.

Add custom task

Let’s add a custom app:build task that runs bundle exec jekyll build. Edit config/deploy.rb:

# frozen_string_literal: true

# config valid for current version and patch releases of Capistrano
lock '~> 3.16.0'

set :application, 'website'
set :repo_url, 'git@github.com:sajadtorkamani/website.git'
set :keep_releases, 5

# Default value for linked_dirs is []
append :linked_dirs, '.bundle'

set :rbenv_type, :user # or :system, or :fullstaq (for Fullstaq Ruby), depends on your rbenv setup
set :rbenv_ruby, File.read('.ruby-version').strip
set :rbenv_prefix, "RBENV_ROOT=#{fetch(:rbenv_path)} RBENV_VERSION=#{fetch(:rbenv_ruby)} #{fetch(:rbenv_path)}/bin/rbenv exec"
set :rbenv_map_bins, %w{rake gem bundle ruby rails}
set :rbenv_roles, :all # default value

namespace :app do
  desc 'ls'
  task :ls do
    on roles(:app) do
      execute :ls, '-la'
    end
  end

  desc 'printenv'
  task :printenv do
    on roles(:app) do
      execute :printenv
    end
  end

  desc 'Build Jekyll website'
  task :build do
    on roles(:app) do
      within release_path do
        execute :bundle, 'exec', 'jekyll', 'build'
      end
    end
  end
end

after 'bundler:install', 'app:build'

Notice the new app:build task that will run bundle exec jekyll build. We’ve also configured this task to run after bundler:install.

Configure Jekyll

Next, edit config/deploy/production.rb to set the JEKYLL_ENV environment variable:

# frozen_string_literal: true
# frozen_string_literal: true

server '<ipv4-address>', 'user': '<ssh-user>', roles: %w[app]

set :deploy_to, '<deploy-path-on-server>'
set :branch, 'master'

set :default_env, {
  'JEKYLL_ENV' => 'production'
}

You’ll also want to setup the .jekyll-cache directory as a shared directory. A shared directory will be reused between different releases so that each release symlinks to the same shared directory.

Edit config/deploy.rb and update the append :linked_dirs line:

append :linked_dirs, '.bundle', '.jekyll-cache'

Run a dry run to make sure the app:build task is configured to execute after bundle:install:

bundle exec cap production deploy --dry-run

Deploy

Finally, you’re ready to deploy:

bundle exec cap production deploy 

(Optional) Quick deployment

Create a bin/deploy bash script:

touch bin/deploy

Make it executable:

chmod +x bin/deploy

Add the following:

#!/usr/bin/env bash

function deploy {
  bundle exec cap production deploy
}

function notify {
  notify-send "Deployment successful" # Assumes notify-send is available 
}

deploy && notify

Now, you can deploy and get notified of deployment all with a single command:

./bin/deploy

Sources