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
Rails, version 6.0.3.2
RVM
Nginx
Puma, version 4.3.7
Capistrano, version 3.14.1
NodeJS, version 12.19.0
There is a great article from the team at Scout that breaks down the differences between the three, but the gist is:
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.
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.
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.
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 variablesThis 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:
root
for security reasons)'my-rails-app'
instead of 'my rails app'
).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
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
.
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;
}
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.
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.
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:
database.yml
is mis-configuredIf 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
.
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.
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!
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.
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.
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! 🎉