September 18th, 2018

Deploying a Rails App on Ubuntu 18.04 LTS with Capistrano, Nginx, and Puma


In this tutorial, I’ll be explaining how to deploy a Ruby on Rails project. The stack I’ll be using is:

  • Ubuntu 18.04 LTS
  • Ruby, version 2.3.1
    • Newer versions should work as well.
  • Rails, version 4.2.5.1
    • Again, newer versions should work fine.
  • RVM
  • Nginx
  • Puma
  • Capistrano
  • NodeJS, version 8.12.0 LTS
    • Used for compiling assets. Any higher version should be fine.

A small aside: I'm writing this article because of the difficulty finding a tutorial that uses this specific stack with Puma on Ubuntu 18.04.

Two popular alternatives to Puma are Unicorn and Passenger.

There is a great article from the team at Scout that breaks down the differences between the three, but the gist is:

  • Puma handles users with slower connections better
  • Puma is multi-threaded
  • Puma give you all this and more for free

Prerequisites

This tutorial assumes you have already setup a web server with Ubuntu 18.04 with Nginx, RVM, Ruby, and Rails.

Digital Ocean has a great One-Click LEMP stack that can be deployed in just a few clicks. They also have a great tutorial for installing RVM, Ruby, and Rails.

If you enjoy this tutorial and don't have a Digital Ocean account already, consider using this referral link. You get $10 and I get $10. It's a win-win.

Heads Up!

If you use a MySQL database with Rails (rails new PROJECT_NAME -d mysql), you'll need to run sudo apt-get install libmysqlclient-dev on your Production machine or you'll run into bundle errors.

With all that out of the way, let’s begin.


Setup SSH keys

This deploy process pulls from a repository to keep code in order. In this particular case, we’re using GitHub. To access GitHub smoothly, we’re going to setup SSH keys. This allows us to deploy securely without having to type our password each time.

Terminal: Production
ssh -T git@github.com

It’ll prompt you if you approve the fingerprint, then tell you that permissions are denied. This is because we don’t have the publickey setup yet.

First, generate a new SSH key:

Terminal: Production
ssh-keygen -t rsa 

Follow the default prompts, leaving the password blank. You should generate a new SSH key at ~/.ssh/id_rsa.pub.

Follow these instructions to add the newly created key to your GitHub project.

Now if you re-run the ssh -T git@github.com command, you should see it succeed.


Adding Capistrano to your project

Capistrano is used to automate deployments. Once setup, rolling out deployments is painless and easy with a single terminal command. The hardest part is just setting it up.

To begin, let’s add Capistrano to our project. We’ll do that by adding the gem to our Gemfile.

Gemfile
group :development do
    gem 'capistrano',         require: false
    gem 'capistrano-rvm',     require: false
    gem 'capistrano-rails',   require: false
    gem 'capistrano-bundler', require: false
    gem 'capistrano3-puma',   require: false
end

gem 'puma'

Then install the newly added gems with:

Terminal: Development
bundle

Next, let’s use Capistrano to create the necessary config files.

Terminal: Development
cap install

This creates:

  • Capfile, used to load some pre-defined scripts (selecting the right version of Ruby, pre-compiling assets, installing new gems, and more).
  • config/deploy.rb, which holds configuration and environment variables

This also creates a config/deploy directory, but we don’t use it in this tutorial. They hold config files that are for environment-specific configurations (staging and deployment).

First, let’s update our Capfile. Replace the contents with the following:

Capfile
# Load DSL and Setup Up Stages
require 'capistrano/setup'
require 'capistrano/deploy'

require 'capistrano/rails'
require 'capistrano/bundler'
require 'capistrano/rvm'
require 'capistrano/puma'
install_plugin Capistrano::Puma

# Loads custom tasks from `lib/capistrano/tasks' if you have any defined.
Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r }

Next, replace the contents of config/deploy.rb with the following, changing the parts where appropriate. Specifically:

  • Production Server IP
  • GitHub SSH address
  • Production user (avoid using root for security reasons)
  • App Name
    • This is specific to your deployment and not your project. It can be anything as long as it’s consistent throughout. Just make sure to avoid spaces ('my-rails-app' instead of 'my rails app').
config/deploy.rb
# Change these
server '[PRODUCTION_IP]', port: 22, roles: [:web, :app, :db], primary: true

set :repo_url,        '[YOUR GIT SSH ADDRESS: git@example.com:username/appname.git]'
set :application,     '[APP_NAME]'
set :user,            '[USER_NAME]'
set :puma_threads,    [4, 16]
set :puma_workers,    0

# Don't change these unless you know what you're doing
set :pty,             true
set :use_sudo,        false
set :stage,           :production
set :deploy_via,      :remote_cache
set :deploy_to,       "/home/#{fetch(:user)}/apps/#{fetch(:application)}"
set :puma_bind,       "unix://#{shared_path}/tmp/sockets/#{fetch(:application)}-puma.sock"
set :puma_state,      "#{shared_path}/tmp/pids/puma.state"
set :puma_pid,        "#{shared_path}/tmp/pids/puma.pid"
set :puma_access_log, "#{release_path}/log/puma.error.log"
set :puma_error_log,  "#{release_path}/log/puma.access.log"
set :ssh_options,     { forward_agent: true, user: fetch(:user), keys: %w(~/.ssh/id_rsa.pub) }
set :puma_preload_app, true
set :puma_worker_timeout, nil
set :puma_init_active_record, true  # Change to false when not using ActiveRecord

## Defaults:
# set :scm,           :git
# set :branch,        :master
# set :format,        :pretty
# set :log_level,     :debug
# set :keep_releases, 5

## Linked Files & Directories (Default None):
# set :linked_files, %w{config/database.yml}
# set :linked_dirs,  %w{bin log tmp/pids tmp/cache tmp/sockets vendor/bundle public/system}

namespace :puma do
  desc 'Create Directories for Puma Pids and Socket'
  task :make_dirs do
    on roles(:app) do
      execute "mkdir #{shared_path}/tmp/sockets -p"
      execute "mkdir #{shared_path}/tmp/pids -p"
    end
  end

  before :start, :make_dirs
end

namespace :deploy do
  desc "Make sure local git is in sync with remote."
  task :check_revision do
    on roles(:app) do
      unless `git rev-parse HEAD` == `git rev-parse origin/master`
        puts "WARNING: HEAD is not the same as origin/master"
        puts "Run `git push` to sync changes."
        exit
      end
    end
  end

  desc 'Initial Deploy'
  task :initial do
    on roles(:app) do
      before 'deploy:restart', 'puma:start'
      invoke 'deploy'
    end
  end

  desc 'Restart application'
  task :restart do
    on roles(:app), in: :sequence, wait: 5 do
      invoke 'puma:restart'
    end
  end

  before :starting,     :check_revision
  after  :finishing,    :compile_assets
  after  :finishing,    :cleanup
  after  :finishing,    :restart
end

# ps aux | grep puma    # Get puma pid
# kill -s SIGUSR2 pid   # Restart puma
# kill -s SIGTERM pid   # Stop puma

Now we’ll setup the Nginx config files. This will allow us to communicate with Puma.

Why use two web servers?

Nginx is a web sevrver, where Puma is an application server. Nginx intercepts the incoming web traffic, and passes it to Puma which works with our Rails project. A more detailed explanation can be found here and here.

Create a new file config/nginx.conf and populate it with the following:

config/nginx.conf
upstream puma {
  server unix:///home/[USER_NAME]/apps/[APP_NAME]/shared/tmp/sockets/[APP_NAME]-puma.sock;
}

server {
  listen 80 default_server deferred;

  # If you're planning on using SSL (which you should), you can also go ahead and fill out the following server_name variable:
  # server_name example.com;

  # Don't forget to update these, too
  root /home/[USER_NAME]/apps/[APP_NAME]/current/public;
  access_log /home/[USER_NAME]/apps/[APP_NAME]/current/log/nginx.access.log;
  error_log /home/[USER_NAME]/apps/[APP_NAME]/current/log/nginx.error.log info;

  location ^~ /assets/ {
    gzip_static on;
    expires max;
    add_header Cache-Control public;
  }

  try_files $uri/index.html $uri @puma;
  location @puma {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_redirect off;

    proxy_pass http://puma;
  }

  error_page 500 502 503 504 /500.html;
  client_max_body_size 10M;
  keepalive_timeout 10;
}

You’re all set with configuration! Let’s make your first deployment.


Make your first deployment

As I mentioned earlier, we’ll be deploying not from your local dev environment, but from the project’s repository. So to begin, commit your project to your GitHub repo (the same one we specified earlier). Then, on your developer environment’s terminal:

Terminal: Development
cap production deploy:initial

The initial option will create and configure the Production server for deployments. As the name implies, it only needs to be included this first time.

This will take a few minutes to run. If you run into any issues, it’s typically because:

  • Missing dependencies to install gems
    • Check the error messages. Oftentimes they specify the missing dependency.
  • database.yml is mis-configured

If everything is deploying properly, let’s finish setting up Nginx.


Configure Nginx

Finally, we just need to tell Nginx to use our nginx.conf file we created earlier. On the Production machine, swap the config files by:

Terminal: Production
sudo rm /etc/nginx/sites-enabled/default # Remove old config file
sudo ln -nfs "/home/[USER_NAME]/apps/[APP_NAME]/current/config/nginx.conf" "/etc/nginx/sites-enabled/[APP_NAME]" # Create a symlink to our new one

Test to make sure the new config file is working:

Terminal: Production
nginx -t

If there’s an error, double-check your nginx.conf file. Otherwise, restart Nginx to have the new config file take effect:

Terminal: Production
sudo service nginx restart

Refresh your browser and you should see your new site!

Heads Up!

If you get a "An unhandled lowlevel error occurred. The application logs may have details." message, it's most likely due to not setting the SECRET_KEY_BASE.

You can set fix this by running rake secret and copying the alphanumeric string into the Production option config/secrets.yml. More info here.

This might not be best practice, but for whatever reason I always have issues setting this global variable, so I just manually hard-code it.


Normal deployments

You’re all done! Now, anytime you want to push an update, all you have to do is commit your changes to your repo, then run:

Terminal: Development
cap production deploy


🎉 Happy deployments! 🎉