November 10th, 2020

Deploying a Rails App on Ubuntu 20.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 20.04 LTS
  • Ruby, version 2.5.3
    • Newer versions should work as well. Just make sure to use the same version as your Production machine.
  • Rails, version 6.0.3.2
    • Again, newer versions should work fine.
  • RVM
  • Nginx
  • Puma, version 4.3.7
    • Newer versions (5+) worked for me, but pay attention to the comments in the ‘Capfile’
  • Capistrano, version 3.14.1
    • Newer versions should work.
  • NodeJS, version 12.19.0
    • Used for compiling assets. Any higher version should be fine.

Why Puma

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

Two popular alternatives to Puma are Unicorn and Passenger.

Prerequisites

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

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

If you enjoy this tutorial and don't have a Digital Ocean account already, consider using this referral link. You get $100 and I get $25. 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.

However, for this example, I'll be using PostgreSQL: rails new PROJECT_NAME -d postgresql

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. You can run this command to easily print it out:

Terminal: Production
cat ~/.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
# The Puma gem may already exist in your Gemfile. If not, add it
gem 'puma'

# If you already have a development group, you can add this into it
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

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/scm/git"
install_plugin Capistrano::SCM::Git

require 'capistrano/puma'
install_plugin Capistrano::Puma

# Depending on your server, you may need a different plugin
# For a Digital Ocean deploy, 'Daemon' will work
# Documentation: https://github.com/seuros/capistrano-puma
# From the documentation: "If you using puma daemonized (not supported in Puma 5+)""
install_plugin Capistrano::Puma::Daemon

# 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').
  • Branch name (master, main, etc)
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]'

# If using Digital Ocean's Ruby on Rails Marketplace framework, your username is 'rails'
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.access.log"
set :puma_error_log,  "#{release_path}/log/puma.error.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,        :main
# 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 'deploy:starting', 'puma:make_dirs'
end

namespace :deploy do
  desc "Make sure local git is in sync with remote."
  task :check_revision do
    on roles(:app) do

      # Update this to your branch name: master, main, etc. Here it's main
      unless `git rev-parse HEAD` == `git rev-parse origin/main`
        puts "WARNING: HEAD is not the same as origin/main"
        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

Error: Net::SSH::AuthenticationFailed

A reader reported receiving the error Net::SSH::AuthenticationFailed. Their solution was to edit the line set :ssh_options above and remove the .pub extension so that it's simply ...%w(~/.ssh/id_rsa) }.

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;
}

Don't Forget the Database!

If using PostgreSQL, don't forget to create the production database at via createdb PROJECT_NAME_production (the specific name can be found in /config/database.yml).

You can list all databases by running psql -l

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


Setup Production variables

One final thing to setup prior to our first deploy is our Secret Key and Database credentials. For our Secret Key, run this:

Terminal: Development
rake secret

Copy down the alphanumeric string and navigate over to your Production server. Next, append the following line in /etc/environment with your favorite editor:

Terminal - Production: /etc/environment
# Other variables up here (most likely just PATH)
# ...
SECRET_KEY_BASE=LONG_ALPHANUMERIC_STRING_FROM_RAKE_SECRET_COMMAND
DATABASE_NAME_PASSWORD=DATABASE_PASSWORD # The specific name can be found in /config/database.yml

Save and you’re ready to deploy.


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 you do have issues, scroll down a little as I’ve updated with a few issues and solutions I’ve encountered.

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

Error: rvm stdout: bash: /home/rails/.rvm/bin/rvm: No such file or directory

If you receive the following error:

SSHKit::Command::Failed: rvm exit status: 127
rvm stdout: bash: /home/rails/.rvm/bin/rvm: No such file or directory
rvm stderr: Nothing written

Then run the following commands on your Production machine in your SSH user's home directory:
mkdir .rvm && mkdir .rvm/bin
ln -s /usr/share/rvm/bin/rvm .rvm/bin/rvm

Also ensure you're running the same Ruby version on your Production server as you are locally via:
rvm install --default ruby-X.X.X

If you change Ruby versions, you'll probably have to install Bundler as well. Try to redeploy and the error should provide which version.


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!


Error: "We're sorry, but something went wrong."

Recently, I've been having issues with puma:restart. For some reason, it won't actually restart Puma, but it will start it if it's not already running. This results in an odd deploy issue where every odd deploy works, and every even deploy fails. Below I explain how to fix.

"We're sorry, but something went wrong."

Oh bother.

To test if it’s actually due to Puma not starting, run the following command locally and if that fixes it, it’s probably a Puma issue:

Terminal: Development
cap production puma:start

If that fixes it, let’s implement a solution so you don’t have to manually start Puma each time.

The fix I’ve found is to manually recreate the puma:restart command by using puma:stop/puma:start.

To do that, create a a new .rake file under lib/capistrano/tasks named something descriptive such as “overwrite_restart” and add the following:

lib/capistrano/tasks/overwrite_restart.rake
namespace :puma do
    Rake::Task[:restart].clear_actions

    desc "Overwritten puma:restart task from lib/capistrano/tasks/overwrite_restart.rake"
    task :restart do
        puts "Overwriting puma:restart to ensure that puma is running. Effectively, we are just starting Puma."
        puts "A solution to this should be found."
        puts "Taken from https://stackoverflow.com/questions/44763777/capistrano-pumarestart-not-working-but-pumastart-does"
        invoke 'puma:stop'
        invoke 'puma:start'
    end
end

Save the file, and now when you re-run the deploy script, Puma should start. To help with debugging, you can also manually run cap production puma:start (or :stop/:restart) from the command line to test.


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! 🎉