Déployer Ruby on Rails avec Capistrano, Puma et Nginx

Illustration de Déployer Ruby on Rails avec Capistrano, Puma et Nginx

Configurer Capistrano, Puma, Nginx et systemd pour déployer une application Rails 7.1 sur un VPS Ubuntu.

J’ai déployé ma première application Ruby on Rails 7.1 sur un VPS Ubuntu hébergé chez AWS Lightsail. Il m’a fallu plusieurs heures pour réunir une configuration qui fonctionne. Voici les étapes et les fichiers que j’aurais aimé trouver au départ.

Contexte

L’application communique avec une API dont les URL viennent d’un fichier .env. Elle utilise SQLite en production. La ligne config.active_record.sqlite3_production_warning = false dans config/environments/production.rb supprime l’avertissement de Rails.

Ce déploiement repose sur Capistrano, Nginx et Puma.

  • Capistrano automatise le déploiement sur les serveurs distants.

  • Puma exécute l’application Ruby et Rack.

  • Nginx agit comme proxy inverse devant Puma.

Capistrano me permettra d’automatiser l’ensemble du processus de déploiement. Un accès SSH au serveur cible est indispensable. Le code à déployer doit également être versionné, par exemple avec Git.

Préparer le déploiement

Préparer le serveur distant

Commencez par vérifier que la version de Ruby installée sur votre serveur est identique à celle de votre environnement de développement. Pour la gérer simplement, vous pouvez utiliser RVM ou rbenv. Consultez la documentation de l’outil choisi. Une fois Ruby installé, installez également Bundler afin de pouvoir installer les différentes dépendances de l’application.

Bash
gem install bundler

J’utilise yarn et webpack pour compiler les ressources. Si c’est aussi votre cas, installez Node.js, ainsi que tout autre outil indispensable au bon fonctionnement de votre application (Redis, MySQL, etc.).

Installer les gems

Maintenant que tout est en place, je dois installer les gems suivantes. Les contraintes de version sont importantes pour éviter les problèmes de compatibilité.

Ruby
gem 'puma', '~> 6.0.0', '< 7'
gem 'dotenv-rails' # to handle .env files
gem 'sd_notify', '~> 0.1.0' # hotfix not actually used

group :development do
  gem 'capistrano'
  gem 'capistrano3-puma', '6.0.0.beta.1' # supports puma 6+
  gem 'capistrano-rails'
  gem 'capistrano-rvm' # capistrano-rbenv for rbenv
end

Exécutez ensuite bundle install pour installer les gems dans les versions indiquées.

Configurer Capistrano

Bash
cap install

Cette commande crée plusieurs fichiers et dossiers dans votre projet :

  • Capfile

  • config/deploy.rb

  • config/deploy/production.rb

  • config/deploy/staging.rb

Ajoutez les extensions nécessaires dans Capfile.

Ruby
# frozen_string_literal: true

require 'capistrano/setup'
require 'capistrano/deploy'
require 'capistrano/scm/git'
install_plugin Capistrano::SCM::Git

# Include tasks from other gems included in your Gemfile
require 'capistrano/rvm' # 'capistrano/rbenv' for rbenv
require 'capistrano/bundler'
require 'capistrano/rails' # will include assets and migrations tasks
require 'capistrano/puma'
require 'capistrano/puma/nginx'
install_plugin Capistrano::Puma
install_plugin Capistrano::Puma::Systemd

# Load custom tasks from `lib/capistrano/tasks` if you have any defined
Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r }

Personnalisez ensuite config/deploy.rb.

Ruby
# frozen_string_literal: true
lock '~> 3.19.1'

set :application, 'ecovet.cloud' # your app name
set :repo_url, 'git@github.com:bernard-ng/ecovet.cloud.git'
set :branch, 'main'
set :deploy_to, '/var/www/html/ecovet.cloud' # path on remote server

set :pty, true
append :linked_files, 'config/master.key', '.env', 'config/database.yaml'
append :linked_dirs, 'log', 'tmp/pids', 'tmp/cache', 'tmp/sockets', 'vendor', 'storage'

set :keep_releases, 2
set :ssh_options, {
  forward_agent: true,
  auth_methods: %w[publickey],
  keys: %w[~/.ssh/LightsailDefaultKey-eu-west-2.pem]
}

# puma
set :puma_workers, 2 # check your CPU specs
set :puma_rackup, -> { File.join(current_path, 'config.ru') }
set :puma_state, "#{shared_path}/tmp/pids/puma.state"
set :puma_pid, "#{shared_path}/tmp/pids/puma.pid"
set :puma_bind, "unix://#{shared_path}/tmp/sockets/puma.sock"
set :puma_default_control_app, "unix://#{shared_path}/tmp/sockets/pumactl.sock"
set :puma_access_log, "#{shared_path}/log/puma_access.log"
set :puma_error_log, "#{shared_path}/log/puma_error.log"
set :puma_conf, "#{shared_path}/puma.rb"

set :puma_control_app, false
set :puma_systemctl_user, :system
set :puma_service_unit_type, 'simple' # or notify
set :puma_enable_socket_service, true # mendatory in our case

# nginx
set :nginx_config_name, 'ecovet.cloud'
set :nginx_server_name, 'ecovet.cloud'
set :nginx_use_ssl, false # will be handled by certbot

Configurez l’environnement de production dans config/deploy/production.rb.

Ruby
# frozen_string_literal: true
# config/deploy/production.rb

server 'ecovet.cloud', user: 'ubuntu', roles: %w[app db web], ssh_options: { forward_agent: true }

Modèles de configuration

Puma, mon serveur web, doit rester disponible en permanence. Même si la machine redémarre ou rencontre une panne, Puma doit se relancer automatiquement. Pour cela, je peux utiliser systemd, un gestionnaire de services qui démarre, arrête et supervise automatiquement les processus.

La configuration de Puma avec systemd se divise en deux parties :

  1. La première concerne la définition du service Puma : son mode de démarrage, l’utilisateur qui l’exécute et sa stratégie de redémarrage.

  2. La seconde concerne la configuration du socket Puma, qui gère la communication entre Puma et Nginx, ou tout autre composant utilisant ce socket pour transmettre des requêtes HTTP.

Voici les modèles de configuration à ajouter à votre projet et à adapter si nécessaire. Au moment de la rédaction, la gem capistrano3-puma n’avait pas été mise à jour depuis deux ans, ce qui pouvait entraîner des problèmes avec sa configuration par défaut.

  • config/deploy/templates/puma.rb.erb

  • config/deploy/templates/puma.service.erb

  • config/deploy/templates/puma.socket.erb. Définissez aussi set :puma_enable_socket_service, true dans config/deploy.rb, sinon vous devrez créer le service du socket vous-même.

config/deploy/templates/puma.rb.erb

ERB
#!/usr/bin/env puma

directory '<%= current_path %>'
rackup "<%=fetch(:puma_rackup)%>"
environment '<%= fetch(:puma_env) %>'
<% if fetch(:puma_tag) %>
  tag '<%= fetch(:puma_tag)%>'
<% end %>
pidfile "<%=fetch(:puma_pid)%>"
state_path "<%=fetch(:puma_state)%>"
stdout_redirect '<%=fetch(:puma_access_log)%>', '<%=fetch(:puma_error_log)%>', true


threads <%=fetch(:puma_threads).join(',')%>

<%= puma_bind %>
<% if fetch(:puma_control_app) %>
activate_control_app "<%= fetch(:puma_default_control_app) %>"
<% end %>
workers <%= puma_workers %>
<% if fetch(:puma_worker_timeout) %>
worker_timeout <%= fetch(:puma_worker_timeout).to_i %>
<% end %>

<% if puma_preload_app? %>
preload_app!
<% else %>
prune_bundler
<% end %>

on_restart do
  puts 'Refreshing Gemfile'
  ENV["BUNDLE_GEMFILE"] = "<%= fetch(:bundle_gemfile, "#{current_path}/Gemfile") %>"
end

<% if puma_preload_app? and fetch(:puma_init_active_record) %>
on_worker_boot do
  ActiveSupport.on_load(:active_record) do
    ActiveRecord::Base.establish_connection
  end
end
<% end %>

config/deploy/templates/puma.service.erb

ERB
[Unit]
Description=Puma HTTP Server for <%= "#{fetch(:application)} (#{fetch(:stage)})" %>
<%= "Requires=#{fetch(:puma_service_unit_name)}.socket" if fetch(:puma_enable_socket_service) %>
After=syslog.target network.target

[Service]
Type=<%= service_unit_type %>
WatchdogSec=10
<%="User=#{puma_user(@role)}" if fetch(:puma_systemctl_user) == :system %>
WorkingDirectory=<%= current_path %>
ExecStart=<%= expanded_bundle_command %> exec --keep-file-descriptors puma -e <%= fetch(:puma_env) %> -C /var/www/html/ecovet.cloud/shared/puma.rb
ExecReload=/bin/kill -USR1 $MAINPID
PIDFile=<%= fetch(:puma_pid)%>
<%- Array(fetch(:puma_service_unit_env_files)).each do |file| %>
<%="EnvironmentFile=#{file}" -%>
<% end -%>
<% Array(fetch(:puma_service_unit_env_vars)).each do |environment_variable| %>
<%="Environment=\"#{environment_variable}\"" -%>
<% end -%>

# if we crash, restart
RestartSec=1
Restart=on-failure

<%="StandardOutput=append:#{fetch(:puma_access_log)}" if fetch(:puma_access_log) %>
<%="StandardError=append:#{fetch(:puma_error_log)}" if fetch(:puma_error_log) %>

SyslogIdentifier=<%= fetch(:puma_service_unit_name) %>
[Install]
WantedBy=<%=(fetch(:puma_systemctl_user) == :system) ? "multi-user.target" : "default.target"%>

config/deploy/templates/puma.socket.erb

ERB
[Unit]
Description=Puma HTTP Server Accept Sockets for <%= "#{fetch(:application)} (#{fetch(:stage)})" %>

[Socket]
<% puma_binds.each do |bind| -%>
<%= "ListenStream=#{bind.local.address}" %>
<% end -%>

Accept=no
<%= "NoDelay=true" if fetch(:puma_systemctl_user) == :system %>
ReusePort=true
Backlog=1024

SyslogIdentifier=puma_socket

[Install]
WantedBy=sockets.target

Envoyer les configurations de Nginx et Puma

Après avoir ajouté ces configurations, vous pouvez les envoyer vers votre serveur distant avec les commandes Capistrano suivantes. Cette étape n’est pas nécessaire si vos configurations Puma, Nginx ou systemd n’ont pas changé.

Bash
cap production puma:config # will upload puma.rb
cap production puma:nginx_config # will upload nginx config
cap production puma:install # will create systemd service and stocket
cap production puma:start # will start the puma service

Prise en charge du fichier .env

Modifiez config/application.rb pour charger le fichier .env.

Ruby
# ...
# Load .env file
Dotenv::Rails.load

Prise en charge de HTTPS

Let’s Encrypt est une autorité de certification qui permet d’obtenir et d’installer facilement des certificats TLS/SSL afin d’activer une connexion HTTPS chiffrée sur un serveur web. Son client, Certbot, automatise la plupart, voire la totalité, des étapes nécessaires. L’obtention et l’installation d’un certificat sont entièrement automatisées pour Apache comme pour Nginx.

Bash
# remote server
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx
sudo service nginx reload

Déployer l’application

Une fois la configuration terminée, lancez le déploiement avec cette commande :

Bash
cap production deploy

Cette commande effectue plusieurs actions pour déployer une version complète et fonctionnelle de votre application :

  1. Le dépôt Git est cloné dans un nouveau dossier sous releases/{timestamp} sur votre serveur distant.

  2. Capistrano crée les liens symboliques définis dans linked_files et linked_dirs depuis le dossier shared vers current.

  3. bundle install installe les dépendances Ruby du Gemfile.

  4. Yarn et Webpack précompilent les ressources lorsque le projet l’exige.

  5. bundle exec rails db:migrate applique les migrations à la base de données.

  6. Capistrano démarre ou redémarre Puma avec config/puma.rb.

Lancer le déploiement

Cette configuration a mis Ecovet.cloud en ligne. Le site était un projet d’étude et n’avait pas vocation à rester disponible durablement. Les fichiers présentés ici restent toutefois une base concrète pour un déploiement Rails avec des versions comparables de Capistrano et Puma.