How to Deploy a Rails 7 Application with Capistrano, Nginx, Puma, Postgresql, LetsEncrypt on Ubuntu 20.04 / Amazon Linux 2

What are we going to do

So whenever I start a new project and want to deploy it to production, I need to research from scratch how to setup a Amazon Linux 2 (Ubuntu Server) including Firewall, how to setup Capistrano, get NGINX to work with Puma etc. That’s why I summarize everything I do to get a Project deployed.

Create the Rails project

Make sure you have Postgresql installed and running locally.

rails -v
# Rails 7.0.3
rails new mysite --database=postgresql
rake db:setup
rails db:migrate
rails s
# rails s -b 0.0.0.0

Now you should be able to visit http://localhost:3000 in your browser.

Server Setup

Ubuntu 20.04 (hetzner cloud) or Amazon Linux 2 (AWS ec2 instance).

SSH Config

I always make sure to select my public key when I create the server so that one is already entered in the ~/.ssh/authorized_keys file for the root user. 

Otherwise we can generate a new key like below:

# https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent

# Generating a new SSH key

ssh-keygen -t ed25519 -C "your_email@example.com"

# Adding your SSH key to the ssh-agent

eval "$(ssh-agent -s)"

# Add your SSH private key to the ssh-agent. If you created your key with a different name, or if you are adding an existing key that has a different name, replace id_ed25519 in the command with the name of your private key file.

ssh-add ~/.ssh/id_ed25519

If you provide an SSH key, Hetzner will automatically disable Password authentication which provides an extra layer of security.

Make sure your /etc/ssh/sshd_config file does not allow password authentication (#PasswordAuthentication yes see the hash at the start of the line).

If you would need to change that, make sure to restart the ssh daemon afterwards (systemctl restart sshd).

Set the A record

Once you purchased your server, you will receive a IPv4 Address. When you already purchased a domain for your project, make sure to point the A Record of that domain to your newly purchased Server’s IPv4 Address. I always do that before setting up my server because that might take a few minutes to propagate through the DNS Servers.

This A record needs to be fully propagated once we try to aquire LetsEncrypt certificates later on.

Update packages

Run

### Ubuntu 20.04 ###
sudo apt-get update

### or for Amazon Linux 2 ###
sudo yum -y update

to update your packages.

Firewall setup

Install ufw (uncomplicated firewall) and allow ssh, http and https.

If you are using Amazon Web Services (AWS) , skip this step.

apt-get install ufw
ufw status
ufw allow ssh
ufw allow http
ufw allow https
ufw enable
ufw status

Create the rails user

Let’s create a new user called rails just for running the application.

adduser rails

This will create an interactive input for creating the user, set a password and just confirm the rest (ENTER).

We want to be able to ssh into our rails user. Lets copy our ~/.ssh/authorized_keys file from the root user.

mkdir -p /home/rails/.ssh
cp ~/.ssh/authorized_keys /home/rails/.ssh/
chown -R rails:rails /home/rails/.ssh/

This user needs to be a sudo user in order to restart the puma systemctl service we are going to create later on.

# TO SEE LIST OF GROUPS 
cat /etc/group

### Ubuntu 20.04 ###
usermod -aG sudo rails

### Amazon Linux 2 ###
sudo usermod -aG wheel rails


vim /etc/sudoers

# add the following line at the bottom

rails  ALL=(ALL) NOPASSWD: ALL

You should be able to ssh into your server with the rails user now.

ssh rails@mysite.com 

Create ssh key as rails user to allow make connection with github and add public key to GitHub.

Create an Amazon RDS instance - AWS Documentation

Edit VPC security groups > inbound rules of RDS DB:

Add EC2 instance to security group to allow make connections between RDS and EC2 instance.

Install postgres client and create db

### Ubuntu 20.04 ###
# https://wiki.postgresql.org/wiki/Apt
# Import the repository key from https://www.postgresql.org/media/keys/ACCC4CF8.asc:
sudo apt-get install curl ca-certificates gnupg
curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
# Create /etc/apt/sources.list.d/pgdg.list. The distributions are called codename-pgdg.
sudo sh -c 'echo "deb [arch=amd64] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" >> /etc/apt/sources.list.d/pgdg.list'
sudo apt-get update
sudo apt-get install postgresql-13-client libpq-dev


### Amazon Linux 2: postgresql10 or 11 or 12 ###
sudo amazon-linux-extras install postgresql13
sudo yum install postgresql-devel


### If Postgres installed local ###
sudo -u postgres psql

### FOR AMAZON RDS ###
psql --host=myinstancehost.eu-west-3.rds.amazonaws.com --port=5432 --username=postgres --password

create database mysite_production;
create user rails with password 'mypassword';

grant all privileges on database mysite_production to rails;

Install more dependencies

Next we need to install more dependencies:

  • Node
  • Yarn
  • RVM / RUBY
  • NGINX

Node & Yarn

### LOGIN AS RAILS USER ###
sudo su rails
cd
### Ubuntu 20.04 ###
# see https://github.com/nodesource/distributions/blob/master/README.md
curl -fsSL https://deb.nodesource.com/setup_14.x | sudo -E bash -
apt-get install -y nodejs

### Amazon Linux 2 ###
sudo yum -y update
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.0/install.sh | bash
### Activate nvm by typing the following at the command line. ###
. ~/.nvm/nvm.sh
### Use nvm to install the latest version of Node.js by typing the following at the command line. ###
# With "nvm install node" got error, so use:
nvm install 16

### Test that Node.js is installed and running correctly ###

node -v
npm install --global yarn
yarn -v

RVM

Install Ubuntu RVM: https://github.com/rvm/ubuntu_rvm and for others platforms:

su rails
cd
### Ubuntu 20.04 ###
# see https://rvm.io/rvm/install
gpg --keyserver hkp://pool.sks-keyservers.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
curl -sSL https://get.rvm.io | bash -s stable --ruby

### Amazon Linux 2 ###
sudo yum install gcc
gpg2 --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
curl -sSL https://get.rvm.io | bash -s stable 

Troubleshooting with install rvm see https://rvm.io/rvm/security. Alternatively you might want to import keys directly from our web server, although this is a less secure way:

### METHOD 1 ###
# Alternative sources for keyserver: hkp://ipv4.pool.sks-keyservers.net; hkp://pgp.mit.edu; hkp://keyserver.pgp.com
gpg2 --keyserver hkp://pool.sks-keyservers.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB

### OR ###
gpg2 --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB



### METHOD 2: although this is a less secure way ###
curl -sSL https://rvm.io/mpapis.asc | gpg --import -
curl -sSL https://rvm.io/pkuczynski.asc | gpg --import -

# Trust rvm keys
echo 409B6B1796C275462A1703113804BB82D39DC0E3:6: | gpg2 --import-ownertrust # mpapis@gmail.com
echo 7D2BAF1CF37B13E2069D6956105BD0E739499BDB:6: | gpg2 --import-ownertrust # piotr.kuczynski@gmail.com



# Run verified installation
curl -sSL https://get.rvm.io | bash -s stable

Check on your local machine which ruby version is used in your project (3.1.2 for me)

cd mysite
cat Gemfile | grep ruby


Then install that same version on the server using RVM.

# as root
/home/rails/.rvm/bin/rvm install ruby-3.1.2
su rails
cd
source ~/.rvm/scripts/rvm

# Use rvm get head to get the latest version.
rvm get head

# View the available versions of Ruby.
rvm list known

rvm use ruby-3.1.2
rvm use --default ruby-3.1.2
rvm -v
ruby -v

Install nginx

### Ubuntu 20.04 ###
apt-get install nginx
systemctl start nginx
systemctl enable nginx

### Amazon Linux 2 ###
sudo yum install nginx
sudo service nginx status

Install Git

apt-get install git

Add Database info connection to Environment variables:

vim ~/.bashrc

...

# DB CONNECTION INFO
export RDS_DATABASE=deploy_test
export RDS_USERNAME=postgres
export RDS_PASSWORD=password
export RDS_HOST=myhost.eu-west-3.rds.amazonaws.com
export RAILS_ENV=development


# OR vim /etc/environment

...

DB_NAME="mysite_name_db_production"
DB_USERNAME="rails"
DB_PASSWORD="my_password"
DB_HOST="localhost"
RAILS_ENV="production"

Or just use Rails environment credentials:

EDITOR=vim rails credentials:edit

# Store the below:
production:
	RDS_DATABASE=rds_db_production
	RDS_USERNAME=rd_username
	RDS_PASSWORD=rails_jvt
	RDS_HOST=rds-host.eu-west-3.rds.amazonaws.com

# Usage in config/database.yml
database: <%= Rails.application.credentials.production[:RDS_DATABASE] %>
host: <%= Rails.application.credentials.production[:RDS_HOST] %>
password: <%= Rails.application.credentials.production[:RDS_PASSWORD] %>
username: <%= Rails.application.credentials.production[:RDS_USERNAME] %>

Install Capistrano

# See: https://github.com/seuros/capistrano-puma/blob/master/README.md

Add the following gems to your group :development do block inside the Gemfile.

gem 'capistrano',         require: false
gem 'capistrano-rvm',     require: false
gem 'capistrano-rails',   require: false
gem 'capistrano-bundler', require: false
gem 'capistrano3-puma',   require: false

To see list of available  tasks use

cap -T

Install the gems and install cap

bundle install
cap install 

Add the following to your Capfile

require "capistrano/rails"
require "capistrano/bundler"
require "capistrano/rvm"
require "capistrano/puma"
install_plugin Capistrano::Puma
install_plugin Capistrano::Puma::Systemd
install_plugin Capistrano::Puma::Nginx    # if you want to upload a nginx site template
# install_plugin Capistrano::Puma::Workers  # if you want to control the workers (in cluster mode)
# install_plugin Capistrano::Puma::Jungle   # if you need the jungle tasks
# install_plugin Capistrano::Puma::Monit    # if you need the monit tasks

Adjust yourconfig/deploy/production.rb to be able to connect via ssh:

# Use Your IP instead of 172.00.00.00
server "172.00.00.00", user: "rails", roles: %w{web app db}

Adjust yourconfig/deploy.rb the file to look like this:

# config valid for current version and patch releases of Capistrano
lock "~> 3.17.0"


# set :puma_service_unit_env_file, '/etc/environment'
set :puma_service_unit_env_vars, [
  "RDS_HOST=%s" % [ENV['RDS_HOST']],
  "RDS_PASSWORD=%s" % [ENV['RDS_PASSWORD']],
  "RDS_USERNAME=%s" % [ENV['RDS_USERNAME']],
  "RDS_DATABASE=%s" % [ENV['RDS_DATABASE']]
]

# Default deploy_to directory is /var/www/my_app_name
set :deploy_to, "/home/rails/deploy/jvt"
set :application, "jvt"
set :repo_url, "git@github.com:psc-be/JVT.git"
# set :puma_conf, "{shared_path}/config/puma.rb"
set :rails_env, :production
set :stage, :production
set :user, "rails"
set :use_sudo, false

####
set :puma_bind,       "unix://#{shared_path}/tmp/sockets/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"


### NGINX, more info see below, block NGINX Setup
### if your nginx server configuration is not located in /etc/nginx, you may need to customize: ###
# set :nginx_sites_available_path, "/etc/nginx/conf.d"
set :nginx_sites_enabled_path, "/etc/nginx/conf.d"
set :nginx_config_name, "#{fetch(:application)}_#{fetch(:stage)}.conf"

# append :rvm_map_bins, 'puma', 'pumactl'

# Default branch is :master
# ask :branch, `git rev-parse --abbrev-ref HEAD`.chomp
set :branch, 'main'

# Default value for :format is :airbrussh.
# set :format, :airbrussh

# You can configure the Airbrussh format using :format_options.
# These are the defaults.
# set :format_options, command_output: true, log_file: "log/capistrano.log", color: :auto, truncate: :auto

# Default value for :pty is false
# set :pty, true

# Default value for :linked_files is []
# append :linked_files, "config/database.yml", "config/master.key"
append :linked_files, "config/master.key"

# Default value for linked_dirs is []
append :linked_dirs, "log", "tmp/pids", "tmp/cache", "tmp/sockets", "tmp/webpacker", "public/system", "vendor/bundle", "storage"

# Default value for default_env is {}
# set :default_env, { path: "/opt/ruby/bin:$PATH" }

# Default value for local_user is ENV['USER']
# set :local_user, -> { `git config user.name`.chomp }

# Default value for keep_releases is 5
# set :keep_releases, 5

# Uncomment the following to require manually verifying the host key before first deploy.
# set :ssh_options, verify_host_key: :secure

Commit and push your changes.

Copy your master.key to the shared dir.

ssh rails@mysite.com
mkdir -p apps/mysite/shared/config
# back on your machine
cd mysite
scp config/master.key rails@mysite.com:apps/mysite/shared/config

Adjust your config/database.yml file:

production:
  <<: *default
  database: deploy_test_production
  host: <%= ENV["RDS_HOST"] %>
  password: <%= ENV["RDS_PASSWORD"] %>
  username: <%= ENV["RDS_USERNAME"] %>

Test a production deploy

cap production deploy


If you are on mac os, you might encounter this error.

Your bundle only supports platforms ["x86_64-darwin-19"] but your local platform is x86_64-linux. Add the current platform to the lockfile with 'bundle lock --add-platform x86_64-linux' and try again.

In that case just run

bundle lock --add-platform x86_64-linux
# commit & push

Puma project config

# shared/puma.rb
cap production puma:config

Puma systemd service

The deploy should fail in the end with the message Failed to restart puma_mysite_production.service: Unit puma_mysite_production.service not found.

To generate unit file we can use the below command docs:

# for puma.service
cap production puma:systemd:config puma:systemd:enable

Or let’s create this service manually:

vim /etc/systemd/system/puma_mysite_production.service

Enter the following:

[Unit]
Description=Puma HTTP Server for mysite (production)
After=network.target

[Service]
Type=simple
User=rails
WorkingDirectory=/home/rails/apps/mysite/current
ExecStart=/home/rails/.rvm/bin/rvm default do bundle exec puma -C /home/rails/apps/mysite/shared/puma.rb
ExecReload=/bin/kill -TSTP $MAINPID
StandardOutput=append:/home/rails/apps/mysite/current/log/puma.access.log
StandardError=append:/home/rails/apps/mysite/current/log/puma.error.log
Restart=always
RestartSec=1
SyslogIdentifier=puma

[Install]
WantedBy=multi-user.target

Create the directory for the puma sockets to live in:

mkdir apps/mysite/shared/tmp/sockets

You should be able to run the service now:

systemctl start puma_mysite_production.service
systemctl enable puma_mysite_production.service
systemctl status puma_mysite_production.service

Try to deploy again, this time it should work fine:

cap production deploy:initial
cap production deploy

NGINX Setup

We need a webserver to proxy http and https requests to puma. NGINX does this nicely.

Nginx doesn't have folders like sites-enabled and sites-available. We create a "sites-available" folder on production server to avoid Capistrano error when uploading the site configuration:

ssh rails@mysite.com 

mkdir /etc/nginx/sites-available/

And we change "nginx_sites_enabled_path" in "deploy.rb", if it is not done yet.

set :nginx_sites_enabled_path, "/etc/nginx/conf.d"

NGINX load all configuration files from the /etc/nginx/conf.d directory.
The end of the file should be .conf.
Capistrano create a nginx config file without .conf, so we need to specify the correct nginx_config_name:

set :nginx_config_name, "#{fetch(:application)}_#{fetch(:stage)}.conf

Now we are ready to upload a NGINX site config (eg. /etc/nginx/sites-enabled/ ):

cap production puma:nginx_config

To customize these two templates locally before uploading use:

rails g capistrano:nginx_puma:config

### if your nginx server configuration is not located in /etc/nginx, you may need to customize: ###
set :nginx_sites_available_path, "/etc/nginx/sites-available"
set :nginx_sites_enabled_path, "/etc/nginx/sites-enabled"

### By default, nginx_config will be executed with :web role. But you can assign it to a different role: ###
set :puma_nginx, :foo

### or define a standalone one: ###
role :puma_nginx, %w{root@example.com}

Or add manually

vim /etc/nginx/sites-enabled/mysite
### or for Amazon Linux ###
vim /etc/nginx/conf.d/mysite.conf


Add the following:

upstream puma {
  server unix:///home/rails/apps/mysite/shared/tmp/sockets/mysite-puma.sock;
}

server {
  server_name mysite.com;

  root /home/rails/apps/mysite/current/public;
  access_log /home/rails/apps/mysite/current/log/nginx.access.log;
  error_log /home/rails/apps/mysite/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_set_header  X-Forwarded-Proto $scheme;
    proxy_set_header  X-Forwarded-Ssl on; # Optional
    proxy_set_header  X-Forwarded-Port $server_port;
    proxy_set_header  X-Forwarded-Host $host;

    proxy_redirect off;

    proxy_pass http://puma;
  }

  error_page 500 502 503 504 /500.html;
  client_max_body_size 100M;
  keepalive_timeout 10;
}


Check if the config is valid & restart nginx

nginx -t
systemctl restart nginx


In case you get a permissions error, running the following should fix that:

sudo chown rails:rails -R apps/mysite/


If you get an error like:

Error : /myproject/shared/tmp/sockets/puma.sock failed (13: Permission denied)


Change the user in nginx.cong to rails:

vim /etc/nginx/nginx.cong

Adding LetsEncrypt

You should be able to access your page now via http.

You need SSL Certificates in order to run your site via https. LetsEncrypt is free and easy to setup with NGINX.

### Ubuntu 20.04 ###
sudo apt-get install certbot python3-certbot-nginx
### For Amazon Linux 2 ###
sudo yum install epel-release
sudo yum install certbot-nginx

# Obtain a certificate
sudo certbot --nginx -d mydomain.be -d www.mydomain.be

Follow the interactive installer, I always choose redirect in the end and you should probably too. Refresh your page, you should be redirect to https now.

Debugging

Errors can occur in a few places here are a few hints:

# check if nginx is running
systemctl status nginx
journalctl -u nginx

# check if puma is running
systemctl status puma_mysite_production.service
journalctl -u puma_mysite_production.service

# application logs
tail -f apps/mysite/current/log/*

Hope you find it useful. Have a nice day :))

Comments

Popular posts from this blog

Installing the Certbot Let’s Encrypt Client for NGINX on Amazon Linux 2

psql: error: connection to server at "localhost" (127.0.0.1), port 5433 failed: ERROR: failed to authenticate with backend using SCRAM DETAIL: valid password not found

Deploy Nuxt.js app using Apache 2