Deploying Rails

Categories: deployment


Initial Server Setup

Creating a deploy user

While logged in as root create a deployment user.

# Create a new user
adduser deploy
# Add user to sudo group
usermod -aG sudo deploy

Next upload your SSH Key to both root and deploy. The simplest way to do this is via ssh-copy-id as its available through most package managers or installed by default.

ssh-copy-id root@server_ip
ssh-copy-id deploy@server_ip

Confirm you can SSH into the server with your new user.

ssh deploy@server_ip

From this point forward, we will be using the deploy user to configure the server.

Disabling SSH access via Root

Edit sshd_config

sudo nano /etc/ssh/sshd_config

Set PermitRootLogin to no

PermitRootLogin no

Save your configuration and reload SSH

sudo service ssh restart

Locking down ports with UFW

sudo ufw allow OpenSSH
sudo ufw allow http
sudo ufw allow https
sudo ufw allow 25/tcp # if your server will support SMTP

Enable UFW

sudo ufw enable

Confirm your settings

sudo ufw status

Creating a Swapfile

Create a Swap File

# A good rule of thumb is to create a swapfile that is double your current memory.*
# E.g. for 1GB of memory, create a 2GB swapfile*
sudo fallocate -l 2G /swapfile

Make the swap file only accessible to root.

sudo chmod 600 /swapfile

Mark the file as a swap file.

sudo mkswap /swapfile

Tell the system to start using our new swap file,

sudo swapon /swapfile

Verify that the swap is now available

sudo swapon --show

Make the swapfile persist after reboots

echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

Server Dependencies

Add new repositories for NodeJS, Yarn and Redis

# Nodejs
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -

# Yarn
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list

# Redis (Optional)
sudo add-apt-repository ppa:chris-lea/redis-server

Refresh APT packages

sudo apt update

Install dependencies

# Remove redis-server and redis-tools if you are not using redis
sudo apt install git-core curl zlib1g-dev build-essential libssl-dev libreadline-dev libyaml-dev libsqlite3-dev sqlite3 \
libxml2-dev libxslt1-dev libcurl4-openssl-dev software-properties-common libffi-dev dirmngr gnupg apt-transport-https \
ca-certificates nodejs yarn redis-server redis-tools

Ruby

# Clone rbenv repo
git clone https://github.com/rbenv/rbenv.git ~/.rbenv

# Add rbenv to $PATH
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc

# Add rbenv init to .bashrc
echo 'eval "$(rbenv init -)"' >> ~/.bashrc

# Add ruby-build plugin to rbenv
mkdir -p "$(rbenv root)"/plugins
git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build

# Install your ruby version
rbenv install 2.7.0

# Make your ruby version the default global version
rbenv global 2.7.0

# Skip installing gem documentation
echo "gem: --no-document" > ~/.gemrc

# Bundler
gem install bundler

PostgreSQL

Create the file /etc/apt/sources.list.d/pgdg.list and add a line for the repository

sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ bionic-pgdg main" >> /etc/apt/sources.list.d/pgdg.list'

Import the repository signing key, and update APT packages list

wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt update

Install PostgreSQL

sudo apt install postgresql postgresql-contrib

Create a new user and database

# Switch to postgres user
sudo su - postgres

# Create user
# This should match database.yml
createuser -P deploy

# Create database
# This should match database.yml
createdb -O deploy myapp

# Exit postgres user
exit

Nginx

Install Nginx

sudo apt install nginx

Allow Nginx via UFW

sudo ufw allow 'Nginx FULL'

Systemd

Systemd is going to be used to manage our Puma server. This will allow us to manage Puma with Capistrano by sending start, stop, restart.
Another benefit is that Systemd will try to restart our puma server if it goes down and will start automatically on a server reboot.

Create the following file: sudo nano /lib/systemd/system/puma.service and add the following configuration

[Unit]
Description=Puma HTTP Server
After=network.target

[Service]
# Foreground process (do not use --daemon in ExecStart or config.rb)
Type=simple

# Preferably configure a non-privileged user
User=deploy
Group=deploy

# The path to the your app  root directory.
WorkingDirectory=/home/deploy/myapp/current

ExecStart=/bin/bash -lc 'exec /home/deploy/.rbenv/shims/bundle exec puma -C /home/deploy/myapp/shared/puma.rb'
ExecReload=/bin/bash -lc 'exec /home/deploy/.rbenv/shims/bundle exec pumactl -S /home/deploy/myapp/shared/tmp/pids/puma.state -F /home/deploy/myapp/shared/puma.rb restart'
ExecStop=/bin/bash -lc 'exec /home/deploy/.rbenv/shims/bundle exec pumactl -S /home/deploy/myapp/shared/tmp/pids/puma.state -F /home/deploy/myapp/shared/puma.rb stop'

Restart=always

[Install]
WantedBy=multi-user.target

Enable systemd puma service

sudo systemctl enable puma.service

Capistrano

Add capistrano to your Gemfile

# Gemfile

gem 'capistrano', '~> 3.12.0', require: false
gem 'capistrano-rbenv', require: false
gem 'capistrano-rails', require: false
gem 'capistrano-bundler', require: false

Generate capistrano files

bundle exec cap install STAGES=production

Update Capfile

# Capfile

require "capistrano/setup"
require "capistrano/deploy"

require "capistrano/scm/git"
install_plugin Capistrano::SCM::Git

require "capistrano/rbenv"
require "capistrano/bundler"
require "capistrano/rails/assets"
require "capistrano/rails/migrations"

Dir.glob("lib/capistrano/tasks/*.rake").each { |r| import r }

Update config/deploy.rb with the following

# config/deploy.rb

require File.expand_path("./environment", __dir__)

lock "~> 3.12.0"

set :rbenv_type, :user
set :rbenv_ruby, '2.7.0' # ruby version installed on your server

set :application, "myapp"
set :repo_url, "git@github.com:username/myapp.git" 
set :deploy_to, "/home/deploy/#{fetch :application}" 
set :format, :airbrussh
set :format_options, command_output: true, log_file: "log/capistrano.log", color: :auto, truncate: :auto
set :pty, false

# Add additional files that should be symlinked. For example files that are generally not checked into your repo
append :linked_files, 'config/database.yml'

# Remove 'public/packs', 'node_modules' if not using Webpacker
append :linked_dirs, 'log', 'tmp/pids', 'tmp/cache', 'tmp/sockets', '.bundle', 'public/system', 'public/uploads', 'public/packs', 'node_modules'

set :keep_releases, 5

# Remove if not using Webpacker
set :assets_prefix, 'packs'

Update config/deploy/production.rb with the following

# config/deploy/production.rb

server(server_ip, user: 'deploy', roles: %w{app db web})

set(:nginx_config_name, 'myapp_production')

Create lib/capistrano/tasks/puma.rake

namespace :puma do
  task :start do
    on roles(:app) do
      execute :sudo, :systemctl, :start, :puma
    end
  end

  task :stop do
    on roles(:app) do
      execute :sudo, :systemctl, :stop, :puma
    end
  end

  task :restart do
    on roles(:app) do
      execute :sudo, :systemctl, :restart, :puma
    end
  end
end

after 'deploy:finished', 'puma:restart'

Create lib/capistrano/tasks/deploy.rake

namespace :deploy do
  desc 'Upload application configuration files.'
  task :app_config do
    on roles(:app) do
      execute "mkdir #{shared_path}/config/credentials -p"
      upload! StringIO.new(File.read('config/database.yml')), "#{shared_path}/config/database.yml", via: :scp
    end
  end

  desc "Upload nginx configuration"
  task :nginx_config do
    on roles :web do
      upload! StringIO.new(File.read("config/puma/nginx.conf")),
        "/tmp/#{fetch(:nginx_config_name)}"
      execute :sudo, :mv,
        "/tmp/#{fetch(:nginx_config_name)} /etc/nginx/sites-available/#{fetch(:nginx_config_name)}"
      execute :sudo, :ln, "-fs",
        "/etc/nginx/sites-available/#{fetch(:nginx_config_name)} /etc/nginx/sites-enabled/#{fetch(:nginx_config_name)}"
    end
  end

  desc 'Upload puma config.'
  task :puma_config do
    on roles(:app) do
      upload! StringIO.new(File.read('config/puma/production.rb')), "#{shared_path}/puma.rb", via: :scp
    end
  end
end

Puma and Nginx Configuration

Create config/puma/production.rb and add:

directory '/home/deploy/myapp/current'
rackup "/home/deploy/myapp/current/config.ru"
environment 'production'

tag 'myapp'

pidfile "/home/deploy/myapp/shared/tmp/pids/puma.pid"
state_path "/home/deploy/myapp/shared/tmp/pids/puma.state"
stdout_redirect '/home/deploy/myapp/shared/log/puma_access.log', '/home/deploy/myapp/shared/log/puma_error.log', true

workers 1 # Change this to match the number of CPU cores your server has
threads 5,5 # This is matching the number of the connection pool for the DB. Feel free to tune this value.

bind 'unix:///home/deploy/myapp/shared/tmp/sockets/puma.sock'

restart_command 'bundle exec puma'

preload_app!

before_fork do
  ActiveRecord::Base.connection_pool.disconnect!
end

on_worker_boot do
  ActiveSupport.on_load(:active_record) do
    ActiveRecord::Base.establish_connection
  end
end

Create config/puma/nginx.conf and add:

upstream puma_myapp_production {
  server unix:/home/deploy/myapp/shared/tmp/sockets/puma.sock fail_timeout=0;
}

server {
  server_name www.mydomain.com;
  return 301 https://mydomain.com$request_uri;
}

server {
  listen 80;
  server_name mydomain.com;
  return 301 https://$host$1$request_uri;
}

server {
  listen 443;
  ssl on;
  ssl_certificate path/to/fullchain.pem;
  ssl_certificate_key path/to/privkey.pem;
  include path/to/options-ssl-nginx.conf;
  ssl_dhparam path/to/ssl-dhparams.pem;
  server_name mydomain.com;
  root /home/deploy/myapp/current/public;
  try_files $uri/index.html $uri @puma_myapp_production;

  client_max_body_size 100M;
  keepalive_timeout 10;

  error_page 500 502 504 /500.html;
  error_page 503 @503;

  location @puma_myapp_production {
    proxy_http_version 1.1;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_redirect off;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header X-Forwarded-Proto https;
    proxy_pass http://puma_myapp_production;
    # limit_req zone=one;
    access_log /home/deploy/myapp/shared/log/nginx.access.log;
    error_log /home/deploy/myapp/shared/log/nginx.error.log;
  }

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

  location = /50x.html {
    root html;
  }

  location = /404.html {
    root html;
  }

  location @503 {
    error_page 405 = /system/maintenance.html;
    if (-f $document_root/system/maintenance.html) {
      rewrite ^(.*)$ /system/maintenance.html break;
    }
    rewrite ^(.*)$ /503.html break;
  }

  if ($request_method !~ ^(GET|HEAD|PUT|PATCH|POST|DELETE|OPTIONS)$ ){
    return 405;
  }

  if (-f $document_root/system/maintenance.html) {
    return 503;
  }
}

Deployment

SSH onto your server and create the directory for your application.

mkdir /home/deploy/myapp

Delete the symlink file for the default nginx config

cd /etc/nginx/sites-enabled
rm default

At this point, we are ready to deploy our application.

Upload our application configuration files.

bundle exec cap production deploy:app_config

Upload the Nginx configuration.

bundle exec cap production deploy:nginx_config

Upload the Puma configuration.

bundle exec cap production deploy:puma_config

With the configuration files deployed, we can now deploy our application code

bundle exec cap production deploy