Blog

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

Check our latest product - it's based on our experience of managing over 50-people strong company. The tool we're missing as a small company and not an enterprise.

humadroid.io is an employee and performance management software. It's an unique tool allowing everyone to be in the loop - by having up to date info about co-workers, time-off, benefits, assets, helping with one-on-ones, being a go-to place for company-wide announcements.

Check out humadroid.io
Top

Contact us

* Required fields

The controller of your personal data provided via this contact form is Prograils sp. z o.o., with a registered seat at Sczanieckiej 9A/10, 60-215 Poznań. Your personal data will be processed in order to respond to your inquiries and for our marketing purposes (e.g. when you ask us for our post-development, maintenance or ad hoc engagements for your app). You have the rights to: access your personal data, rectify or erase your personal data, restrict the processing of your personal data, data portability and to object to the processing of your personal data. Learn more.

Notice

We do not track you online. We use only session cookies and anonymous identifiers for the purposes specified in the cookie policy. No third-party trackers.

I understand
Elo Mordo!Elo Mordo!