David Gay

Deploy Rails 6 to Digital Ocean with Postgres and Action Cable

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).

Disclaimers

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.

Requirements

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.

App Configuration

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"

The Droplet

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.

Server Configuration

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

Capistrano Configuration

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.

Back on the Server

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

Deploy

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.

Feedback

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.


Go back home