Capistrano deploy to ubuntu with systemd, nginx/puma and rbenv
This tutorial will show you how to deploy a Rails app via Capistrano, run it with Puma, restart it without sudo access and be able to host multiple environments and apps on same server
This post assumes, that you have already done basic server setup, created user and installed rbenv. If not, please follow this tutorial to get instructions about initial server setup and then that one to get rbenv. Remember, app requires it's own user that should not have sudo rights!
As an app user install desired ruby version. I'm going to install 2.4.3 to illustrate how we can have multiple Rails apps on the same server using completely different environments:
$ rbenv install 2.4.3
...
$ rbenv global 2.4.3
Install gems that we'll need soon - bundler and puma:
$ gem install bundler puma --no-ri --no-rdoc
...
To avoid future problems define ruby version in your Gemfile
, on very top of it add (or change accordingly):
ruby File.read(File.expand_path('../.ruby-version', __FILE__)).chomp
If you don't have .ruby-version
file in your project, add it with content 2.4.3
and add to repo.
Capistrano
Time to install capistrano - in development
section of Gemfile
add two gems: capistrano-rails and capistrano-rbenv
gem 'capistrano-rails'
gem 'capistrano-rbenv', '~> 2.1'
install gems with
$ bundle install
If your private key is ED25519 you'll need to add 3 extra gems (to development
group):
gem 'bcrypt_pbkdf', '>= 1.0', '< 2.0'
gem 'rbnacl', '>= 3.2', '< 5.0'
gem 'rbnacl-libsodium'
Run capistrano generator:
$ bundle exec cap install
mkdir -p config/deploy
create config/deploy.rb
create config/deploy/staging.rb
create config/deploy/production.rb
mkdir -p lib/capistrano/tasks
create Capfile
Capified
this generated set of files: - Capfile
is more or less similar to Rakefile
, but for capistrano - config/deploy.rb
is responsible for general deployment config, tasks, etc - config/deploy/staging.rb
and config/deploy/production.rb
are deployment target specific files, they may overwrite some part of the default config, define repository address, etc
In Capfile
uncomment requires for rbenv
and bundler
:
# Load DSL and set up stages
require "capistrano/setup"
# Include default deployment tasks
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"
# 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 }
Open config/deploy.rb
and set proper application name (application
) and repository address (repo_url
) to reflect your config. Remove all default configs (commented out)
For the sake of simplicity here's working config, that should work for most scenarios:
# config valid only for current version of Capistrano
lock "~> 3.10.1"
# App name
set :application, "UbuntuPumaSystemd51"
# Where to fetch source from
set :repo_url, "git@github.com:prograils/ubuntu_puma_systemd_capistrano.git"
# Where app files will be
set :deploy_to, "/home/myapp51/app"
# Rbenv specific settings
set :rbenv_type, :user # or :system, depends on your rbenv setup
set :rbenv_ruby, File.read('.ruby-version').strip
set :rbenv_prefix, "RBENV_ROOT=#{fetch(:rbenv_path)} RBENV_VERSION=#{fetch(:rbenv_ruby)} #{fetch(:rbenv_path)}/bin/rbenv exec"
# when using db, you should add config/database.yml here
set :linked_files, fetch(:linked_files, []).concat(%w{.rbenv-vars})
set :linked_dirs, fetch(:linked_dirs, []).concat(%w{log tmp/pids tmp/cache tmp/sockets vendor/bundle})
The bottom part of this config (linked_
) defines a list of files and directories that will be symlinked from shared/
folder to current/
folder upon deployment.
As the branch is environment specific config, it's kept in config/deploy/*.rb
files - open production.rb
file from that directory and read through it. Let's take a look at production.rb
config, it should look more or less similar to following:
set :stage, :production # this defines production stage for deployment
set :branch, 'master'
role :app, %w(myapp51@myapp51.prograils.com)
role :web, %w(myapp51@myapp51.prograils.com)
role :db, %w(myapp51@myapp51.prograils.com)
So deployment stage is set to production
, code will be fetched from master
branch. App code, assets and db are on the same server.
It's time to check if config is correct. First - get the public key from your app user on target server and add it to deploy keys on your repository - Capistrano will fetch repository content as app user from app server - that's why it needs to access (read-only) to your git repository.
When that has been completed run:
$ bundle exec cap production deploy:check
00:00 git:wrapper
01 mkdir -p /tmp
✔ 01 myapp51@myapp51.prograils.com 0.501s
Uploading /tmp/git-ssh-UbuntuPumaSystemd51-production-mlitwiniuk.sh 100.0%
02 chmod 700 /tmp/git-ssh-UbuntuPumaSystemd51-production-mlitwiniuk.sh
✔ 02 myapp51@myapp51.prograils.com 0.401s
00:01 git:check
01 git ls-remote git@github.com:prograils/ubuntu_puma_systemd_capistrano.git HEAD
01 Warning: Permanently added the RSA host key for IP address '192.30.253.112' to the list of known hosts.
01 721d395ecb6c8034273455d13dbcbc5f8e6cf5eb HEAD
✔ 01 myapp51@myapp51.prograils.com 2.284s
00:03 deploy:check:directories
01 mkdir -p /home/myapp51/app/shared /home/myapp51/app/releases
✔ 01 myapp51@myapp51.prograils.com 0.514s
00:04 deploy:check:linked_dirs
01 mkdir -p /home/myapp51/app/shared/log /home/myapp51/app/shared/tmp/pids /home/myapp51/app/shared/tmp/cache /home/myapp51/a…
✔ 01 myapp51@myapp51.prograils.com 0.795s
00:05 deploy:check:make_linked_dirs
01 mkdir -p /home/myapp51/app/shared
✔ 01 myapp51@myapp51.prograils.com 0.402s
00:06 deploy:check:linked_files
ERROR linked file /home/myapp51/app/shared/.rbenv-vars does not exist on myapp51.prograils.com
Executed command checked if repository is accessible and created directory structure (including items listed by linked_dirs
variable from config/deploy.rb
). At the very end it threw an error - ~/app/shared/.rbenv-vars
file listed in linked_files
has not been found. Small explanation - this file will be used to get ENV variables from (you can also use dotenv-rails for that). SSH to app server (as app user), edit this file and set the initial value for SECRET_KEY_BASE
so that it looks more or less like this (with a different value of course)
$ cat app/shared/.rbenv-vars
SECRET_KEY_BASE=c9417ace9e4f3a6bcafba6f29e60306d1da52df0d41c00f0bc8ad19802528a2ec00bfaf46f105a55e53bac514d2246dcd573c7bec7b68be21a47be4fe4cccbe8
and run again
$ bundle exec cap production deploy:check
This time it should succeed and you're ready to move on. Normally in linked_files
you'll probably want to add config/database.yml
and few others.
Puma
This one is rather straightforward - add puma
gem to your Gemfile
- either globally or only for group production. One last step here is to create config for puma server - add following to config/puma.rb
# Puma can serve each request in a thread from an internal thread pool
# The `threads` method setting takes two numbers: a minimum and maximum
# Any libraries that use thread pools should be configured to match
# the maximum value specified for Puma. The default is set to 5 threads for minimum
# and maximum; this matches the default thread size of Active Record
#
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
# Specifies the `environment` that Puma will run in.
# Defaults to development
rails_env = ENV.fetch("RAILS_ENV") { "development" }
environment rails_env
app_dir = File.expand_path("../..", __FILE__)
directory app_dir
shared_dir = "#{app_dir}/tmp"
if %w[production staging].member?(rails_env)
# Logging
stdout_redirect "#{app_dir}/log/puma.stdout.log", "#{app_dir}/log/puma.stderr.log", true
# Set master PID and state locations
pidfile "#{shared_dir}/pids/puma.pid"
state_path "#{shared_dir}/pids/puma.state"
# Change to match your CPU core count
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
preload_app!
# Set up socket location
bind "unix://#{shared_dir}/sockets/puma.sock"
before_fork do
# app does not use database, uncomment when needed
# ActiveRecord::Base.connection_pool.disconnect!
end
on_worker_boot do
ActiveSupport.on_load(:active_record) do
# app does not use database, uncomment when needed
# db_url = ENV.fetch('DATABASE_URL')
# puts "puma: connecting to DB at #{db_url}"
# ActiveRecord::Base.establish_connection(db_url)
end
end
elsif rails_env == "development"
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT") { 3000 }
plugin :tmp_restart
end
note before_fork
and on_worker_boot
blocks - they are commented in this example, but you'll probably need to uncomment them.
systemd
The last piece of this puzzle is systemd config, which caused me most problems when I've tried to use it for the very first time. The biggest issue was that normal user can not start or stop services, which made deployments really problematic - it required either manual restarts or adding app user to sudoers
and using sudo to restart app - a scenario that was rejected instantly, as app user should never ever had a possibility to break the system. Just in case. The tricky part here is that - yes, service can be (re)started only by root, but after that you can control app behaviour via pumactl
as app user.
Here's complete puma service config file, that should be saved as /etc/systemd/system/puma-myapp51.service
- file name is up to you, a directory is important.
[Unit]
Description=Myapp51 Puma Server
After=network.target
[Service]
Type=simple
User=myapp51
EnvironmentFile=/home/myapp51/app/current/.rbenv-vars
Environment=RAILS_ENV=production
WorkingDirectory=/home/myapp51/app/current/
ExecStart=/home/myapp51/.rbenv/bin/rbenv exec bundle exec puma -C /home/myapp51/app/current/config/puma.rb
ExecStop=/home/myapp51/.rbenv/bin/rbenv exec bundle exec pumactl -F /home/myapp51/app/current/config/puma.rb stop
ExecReload=/home/myapp51/.rbenv/bin/rbenv exec bundle exec pumactl -F /home/myapp51/app/current/config/puma.rb phased-restart
TimeoutSec=15
Restart=always
KillMode=process
[Install]
WantedBy=multi-user.target
nginx
SSH to your machine and install nginx:
$ sudo apt-get install nginx
When it completes, go to /etc/nginx/sites-enabled
and remove symlink default
. Then cd to /etc/nginx/sites-available
and create file myapp51
(or however you'd like to call it) with the following content:
upstream myapp51_backend {
server unix:/home/myapp51/app/shared/tmp/sockets/puma.sock fail_timeout=0;
}
server {
listen 80;
root /home/myapp51/app/current/public;
index index.html index.htm;
if ($http_transfer_encoding ~* chunked) {
return 444;
}
server_name myapp51.prograils.com;
access_log off;
location ~ ^/assets/ {
gzip_static on;
expires max;
add_header Cache-Control public;
}
location / {
try_files $uri.html $uri @app;
}
location @app {
include proxy_params;
proxy_redirect off;
proxy_pass http://myapp51_backend;
}
}
(remember to revisit this config, including paths and server_name
directive!)
Then get back to /etc/nginx/sites-enabled
and symlink just created file here:
$ sudo ln -s /etc/nginx/sites-available/myapp51 .
and enable and start nginx service:
$ sudo systemctl enable nginx
$ sudo systemctl start nginx
when you now visit your server, you should get 502 error page:
deploy!
Push all your changes to repo and run
$ bundle exec cap production deploy
and wait until it completes.
After that, log in to server (normal user) and run:
$ sudo systemctl enable puma-myapp51
Created symlink from /etc/systemd/system/multi-user.target.wants/puma-myapp51.service to /etc/systemd/system/puma-myapp51.service.
$ sudo systemctl start puma-myapp51
to enable and start puma service. Go to your browser and call your page - everything should be working just fine now!
One last thing is instructing puma to perform restart upon each deployment. Open config/deploy.rb
and add following code:
namespace :deploy do
desc 'Restart application'
task :restart do
on roles(:app) do
execute "#{fetch(:rbenv_prefix)} pumactl -P ~/app/current/tmp/pids/puma.pid phased-restart"
end
end
end
after 'deploy:publishing', 'deploy:restart'
Read more about restarting puma here.
The whole code has been pushed to our github and as a bonus there is an additional rails52
branch with dummy code updated to Rails 5.2, but also deployed here - just to illustrate, that on the same server we can easily have two different environments.
Post-scriptum
To successfully deploy sample app I had to install NodeJS on server and Yarn. database.yml
config is part of repo, normally it should be symlinked and listed in linked_files
directive in deploy.rb
.
credits: photo from unsplash.com