2021-07-25
A couple weeks ago, I went to deploy a new Rails 6 app to Digital Ocean.
Okay, it's now been a couple months since I started writing this post. I've been
moving to Brooklyn and working on a number of projects. Anyway...
I hadn't deployed a fresh Rails app in some time, and I didn't want to follow the old notes I had taken, since I figured they were probably out of date and I could do it better.
I worked through it, and after sorting out some hiccups with Redis and Action Cable, I got everything working. As loathe as I am to revisit any DevOps-related experience, I took some notes, and I'm going to try to replicate the process here for you (and for future me).
I don't assert that the process detailed in this post is the best way to do things. I don't promise that it's top-notch secure or that it's even one of the best ways to do it. It's just how I did it this time, for a small personal project.
Additionally, I wrote this post in small bits over the last two months, and it might not be the most concise or flow-y, so apologies for that as well. If there's anything I can clarify or expand on, please don't hesitate to email me.
This post assumes you're deploying a standard Rails 6 app to Digital Ocean, and that you'll be using Postgres, and Redis for Action Cable.
In your Gemfile
, make sure redis
is uncommented. It should be included in a new Rails app, but it will
be commented out. You want to make sure to uncomment it. Something like this:
gem "redis", "~> 4.0"
Make sure to run bundle install
after this, and after any time you alter your Gemfile
for the remainder of this
process.
Also, you should tweak your production environment config to include the correct hosts. You'll want lines like these:
config.action_cable.allowed_request_origins = [ "https://myapp.com" ]
config.hosts << "myapp.com"
config.hosts << "localhost"
I created a new droplet with the following settings:
I also added a domain name to DO and pointed it at to the new droplet. For
the purposes of this post, let's call it myapp.com
.
The 1-click droplet comes with nginx and puma. I popped open a new file to
create a site record in nginx, called /etc/nginx/sites-available/myapp.com
.
server {
root /home/rails/myapp/current/public;
server_name myapp.com;
index index.htm index.html;
location ~ /.well-known {
allow all;
}
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /cable {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade "websocket";
proxy_set_header Connection "Upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# needed to allow serving of assets and other public files
location ~ ^/(assets|packs|graphs)/ {
gzip_static on;
expires 1y;
add_header Cache-Control public;
add_header Last-Modified "";
add_header ETag "";
}
}
Then I enabled the site:
ln -s /etc/nginx/sites-available/myapp.com /etc/nginx/sites-enabled/myapp.com
Then I set up a certificate (certbot is included with the 1-click):
sudo certbot --nginx -d myapp.com
Then I added any environment variables that my app uses -- such as for the database -- to /etc/environment
. I also
added RAILS_ENV=production
to that file, for funsies. This isn't the only way you can set environment variables
for your app (and indeed it might not even be a good way), but it's how I did it for this deploy. Here's what the
lines I added to /etc/environment
look like:
MYAPP_DATABASE_NAME="postgres"
MYAPP_DATABASE_USERNAME="rails"
MYAPP_DATABASE_PASSWORD="yourpasswordgoeshere"
RAILS_ENV="production"
RAILS_SERVE_STATIC_FILES=true
The database-related variables are referenced in my config/database.yml
, like this:
production:
<<: *default
database: <%= ENV["MYAPP_DATABASE_NAME"] %>
username: <%= ENV["MYAPP_DATABASE_USERNAME"] %>
password: <%= ENV["MYAPP_DATABASE_PASSWORD"] %>
The setting of RAILS_SERVE_STATIC_FILES
was needed for the way I did things for this deploy, though there are other
ways to serve static assets.
Next, make sure redis is installed:
sudo apt install redis-server
It's possible that you might need to tweak configuration in
/etc/redis/redis.conf
. I see in my server's bash history that I did open that
file, but looking through it, I don't see anything that sparks a memory of
having changed it. So I may have just opened it while debugging. Sorry I can't
be sure on this, it's been a couple month since deploy and I don't have
anything in my notes on this one. If you run into problems with redis, make
sure to consider this file for possible solutions.
Then, you can configure sudoers to allow the rails
user to restart a service (which we're going to
configure later) without requiring a password. In /etc/sudoers.d/rails
:
rails ALL=NOPASSWD: /bin/systemctl restart rails.service
Finally, the Digital Ocean 1-click install comes with RVM. As your rails
user (or whatever your deploy user is),
install and use
the desired Ruby version:
rvm install 2.7.2
rvm use 2.7.2
Back on my local machine, in my project's root directory, I installed
Capistrano. This involved adding the following lines to the group :development
block of my Gemfile
:
gem "capistrano", require: false
gem "capistrano-rails", require: false
gem "capistrano-bundler", require: false
gem "capistrano-rvm", require: false
gem "capistrano3-puma", require: false
gem "ed25519", ">= 1.2", "< 2.0", require: false # Needed for cap ed25519 support
gem "bcrypt_pbkdf", ">= 1.0", "< 2.0", require: false # Needed for cap ed25519 support
After adding those lines, you need to actually install the gems and generate the starter files:
bundle install
bundle exec cap install
My Capfile
ended up looking like this:
# Load DSL and set up stages
require "capistrano/setup"
# Include default deployment tasks
require "capistrano/deploy"
# Load the SCM plugin appropriate to your project:
#
# require "capistrano/scm/hg"
# install_plugin Capistrano::SCM::Hg
# or
# require "capistrano/scm/svn"
# install_plugin Capistrano::SCM::Svn
# or
require "capistrano/scm/git"
install_plugin Capistrano::SCM::Git
# Include tasks from other gems included in your Gemfile
#
# For documentation on these, see for example:
#
# https://github.com/capistrano/rvm
# https://github.com/capistrano/rbenv
# https://github.com/capistrano/chruby
# https://github.com/capistrano/bundler
# https://github.com/capistrano/rails
# https://github.com/capistrano/passenger
#
require "capistrano/rvm"
# require "capistrano/rbenv"
# require "capistrano/chruby"
require "capistrano/bundler"
require "capistrano/rails/assets"
require "capistrano/rails/migrations"
# require "capistrano/passenger"
# Load custom tasks from `lib/capistrano/tasks` if you have any defined
Dir.glob("lib/capistrano/tasks/*.rake").each { |r| import r }
require "capistrano/honeybadger"
You'll also notice that I set up Honeybadger for this app, though that's totally optional and not required for this deploy to succeed. Simply comment out or delete that line if you don't set up Honeybadger.
Following Capfile
setup, I configured my prod server in config/deploy/production.rb
. The following is the only
uncommented line in the file:
server "myapp.com", user: "rails", roles: %w{app db web}
My final deploy configuration (config/deploy.rb
) looked something like this:
# config valid for current version and patch releases of Capistrano
lock "~> 3.16.0"
set :application, "myapp"
set :user, "rails"
# The below is for private repos. If you have a public repo, you don't need the environment variable etc., you can
# just set this to your public repo URL.
set :repo_url, "https://#{ENV['GITHUB_OAUTH']}:@github.com/dtgay/myapp.git"
set :branch, "main"
set :deploy_to, "/home/#{fetch :user}/#{fetch :application}"
append :linked_dirs, "log", "tmp/pids", "tmp/cache", "tmp/sockets", "vendor/bundle", ".bundle",
"public/system", "public/uploads", ".bundle"
append :linked_files, "config/database.yml", "config/master.key"
set :keep_assets, 2
set :keep_releases, 5
set :conditionally_migrate, true
set :rvm_custom_path, "/usr/share/rvm" # Needed for DO one-click Rails install setup
# upload master.key and database.yml
namespace :deploy do
namespace :check do
before :linked_files, :copy_linked_files_if_needed do
on roles(:app), in: :sequence, wait: 10 do
%w{master.key database.yml}.each do |config_filename|
unless test("[ -f #{shared_path}/config/#{config_filename} ]")
upload! "config/#{config_filename}", "#{shared_path}/config/#{config_filename}"
end
end
end
end
end
end
Next, I added a task to restart the entire Rails service after a deploy. I did this because some documentation I was reading during deployment suggested it and implied that it was needed. But honestly, I'm not actually sure if it's needed, and it seems a bit heavy-handed (and makes the app unavailable, it seems, for a brief moment on deploy). Nevertheless, I'm including it here because it's what I did for this deploy.
So, in lib/capistrano/tasks/deploy_restart.rake
:
namespace :deploy do
desc "Restart Rails Service"
task :restart do
on roles(:app) do
execute "sudo /bin/systemctl restart rails.service"
end
end
end
after "deploy:finishing", "deploy:restart"
At this point, you can attempt a deploy. If your deploy.rb
is configured like mine, you'll need to make sure
the GITHUB_OAUTH
environment variable is set to a valid token on your local machine, or the
deploy won't work. The reason this was needed for me is because I was deploying a private repo. If you're deploying
a public repo, your repo_url
line in deploy.rb
can be simpler (it can just list your public repo, without the
GITHUB_OAUTH
stuff.
To attempt the deploy, run this on your local machine in the app's root directory:
cap production deploy --trace
The deploy may or may not fail to entirely complete due to the DB not being set up. Docs I read during my deploy suggested that it should fail, but it didn't fail for me. Either way, at this point hopefully your code has at least been put on the server, even if the entire process (DB setup, etc) didn't complete.
As the Rails user, switch into the latest (and probably, only) deploy of your app:
cd myapp/current
At this point, you can make sure that your DB is set up and seeded:
bundle exec rails db:setup && bundle exec rails db:seed
Next, you can tweak the previously-mentioned rails service.
This service should be located at /etc/systemd/system/rails.service
. In that file, you should
make sure the following two lines are set:
WorkingDirectory=/home/rails/myapp/current
ExecStart=/bin/bash -lc 'RAILS_ENV=production bundle exec puma'
Then, reload the service:
systemctl daemon-reload
systemctl restart rails.service
At this point, a deploy will hopefully succeed. Back on your local machine, in the app root directory:
cap production deploy --trace
--trace
is an optional argument that will give you more detailed output during the deploy, and will hopefully
assist you in better understanding the deploy and debugging any issues.
Questions, comments, or tips for me? See a mistake in this post? Send me an email.
For this post specifically, I'll say again please don't hesitate to email me with questions or other feedback. As I said earlier, I wrote it over two months, and therefore much of it was authored long after the deploy was completed. I'd be unsurprised if some step or bit of info is missing.