Background

I wrote Deploy Rails App Using Capistrano in December 2017, against Capistrano 3.10 and Ruby 2.4.2. I have been quietly following my own instructions. Last week I finally read the deploy config properly instead of just running `cap production deploy`, and found that most of what has changed since 2017 is not in the guide and that several Capistrano defaults are actively wrong for a modern Rails app.


Everything here was verified against a real Rails 8.1 app on Ruby 4.0.6 with Bundler 4.0.19. Versions as of writing:

  • Capistrano 3.20.1
  • Capistrano-bundler 2.2.0
  • Capistrano-rails 1.7.0
  • Capistrano-rbenv 2.2.0
  • Capistrano-passenger 0.2.1

What changed since 2017 (Briefly)

  1. The config/secrets.yml is gone. It is config/credentials.yml.enc plus config/master.key now, and the master key is what you link.
  2. Sprockets is gone; Propshaft is the default pipeline.
  3. Solid Queue is the default, and it runs as a separate process you have to restart yourself.
  4. bundle install --deployment is gone. Bundler configuration is now bundle config set --local, and capistrano-bundler needs to be told about it.
  5. Ruby 4 ships Bundler 4, which is the single most likely thing to break a deploy config that has not been touched in a while.

Part 1: Setup Capistrano

Step 1 - Add the gems

1) In your Gemfile:

group :development do
  gem "capistrano",           require: false
  gem "capistrano-bundler",   require: false
  gem "capistrano-rails",     require: false
  gem "capistrano-rbenv",     require: false
  gem "capistrano-passenger", require: false

  gem "ed25519",      require: false  # SSH ed25519 key support
  gem "bcrypt_pbkdf", require: false  # SSH ed25519 key support
end

2) Then bundle install.

Step 2 - Run cap install

bundle exec cap install

If you only have one environment, delete config/deploy/staging.rb

Step 3 - Edit the Capfile

cap install writes a Capfile full of commented-out requires. Uncomment what you use:

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"
require "capistrano/passenger"

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

Step 4 - Configure config/deploy.rb

This is the minimum to get a first deploy working. We will come back and tune it once it runs.

lock "~> 3.20.0"

set :application, "myapp"
set :repo_url,    "git@github.com:me/myapp.git"
set :branch,      "main"
set :deploy_to,   "/srv/myapp"

append :linked_files, "config/database.yml", "config/master.key"
append :linked_dirs,  "log", "tmp/pids", "tmp/cache", "tmp/sockets", "storage"

set :keep_releases, 5
set :rbenv_ruby, "4.0.6"

Step 5 - Point the stage at your server

In config/deploy/production.rb:

server "x.x.x.x", user: "deploy", roles: %w[app db web], port: 22

One machine holding all three roles is the normal case for a small app. The roles matter because tasks target them: assets compile on `web`, migrations run on `db` (the primary one only), and most everything else runs on `app`.


Part 2: Setup the server

Step 1 - Ruby, via rbenv

Install rbenv and the exact version you set as :rbenv_ruby, as the deploy user:

rbenv install 4.0.6
rbenv global 4.0.6

Ruby 4 ships with Bundler 4, so there is nothing extra to install. Make the version match :rbenv_ruby exactly since capistrano-rbenv prefixes every command with rbenv exec against that version.

Step 2 - Create the deploy directory

sudo mkdir -p /srv/myapp
sudo chown deploy:deploy /srv/myapp

That is all you create by hand, and only because it is not writable by the deploy user. Capistrano builds the rest in the later steps.

Step 3 - Give the server access to your repository

Refer: https://calvin.my/posts/deploy-2-github-repositories-on-the-same-server

Step 4 - Let Capistrano scaffolds

Now run:

bundle exec cap production deploy:check

This is the step that scaffolds the server. Expect it to fail on this first run, listing the files it could not find.

Step 5 - Fill in the linked files

The linked_files are symlinked, never created, so each one has to exist in shared/ before a deploy can succeed. Step 4 already made the directories, so you only place the files:

vi /srv/myapp/shared/config/database.yml
vi /srv/myapp/shared/config/master.key
chmod 600 /srv/myapp/shared/config/master.key

Then run the check again, and keep going until it is silent:

bundle exec cap production deploy:check

Step 6 - nginx and Passenger

Point the server block at the current symlink's public directory:

server {
    ...
    root /srv/myapp/current/public;

    passenger_enabled on;
    passenger_app_env production;
    passenger_ruby /home/deploy/.rbenv/versions/4.0.6/bin/ruby;
}

Part 3: Deploy

bundle exec cap production deploy

Or, to see the whole plan without touching the server:

bundle exec cap production deploy --dry-run

To go back:

bundle exec cap production deploy:rollback

Which is what :keep_releases is really for. It is the number of releases you can roll back through.


Part 4: Solid Queue

One addition if you run Solid Queue. Capistrano restarts Passenger but has no idea your job worker exists, so without this your workers keep running the previous release's code indefinitely.

namespace :deploy do
  task :restart_workers do
    on roles(:app) do
      execute :sudo, "supervisorctl restart myapp-solid-queue:*"
    end
  end
  after :finished, :restart_workers
end

Part 5: Now fix the defaults you just inherited

#1 bundle_version

On a Bundler 4, a warning like below is emitted:

[DEPRECATED] Using the `config` command without a subcommand [list, get, set, unset]
is deprecated and will be removed in the future.
Use `bundle config set --local deployment true` instead.

The reason being capistrano-bundler defaults :bundle_version to 2, and that default decides which form of the config command it emits:

config_args = fetch(:bundle_version, 2) >= 4 ? %w[config set] : %w[config]

This causes your Bundler 4 to run config instead of config set. To correct this, add the following in deploy.rb:

set :bundle_version, 4

#2 bundle_config clean up

set :bundle_config, { deployment: true, clean: true }

Bundler does not auto-clean when you have set a path. So every gem version you have ever deployed is still sitting in shared/bundle directory.

#3 conditionally_migrate

set :conditionally_migrate, true

By default Capistrano runs rake db:migrate on every single deploy, whether or not you have written a migration, against your live production database.

With this on, it first checks the differences against the live database and skips entirely when nothing changed.

Observe this happens in the capistrano's output:

[deploy:migrate] Checking changes in db
[deploy:migrate] Skip `deploy:migrate` (nothing changed in db)

#4 passenger_restart_with_touch

set :passenger_restart_with_touch, false

Leave this unset and capistrano-passenger shells out to passenger -v on every deploy and regex-parses the output, purely to work out whether your Passenger predates 4.0.33. If your passenger is a newer version, you can explicitly turn this off.


Part 6: Bootsnap's Disk Usage

Bootsnap caches compiled Ruby bytecode. 

The cache key is a hash of the absolute path of the source file. Capistrano deploys to releases/20260822073906/ - a new absolute path every single time. So every deploy strands your entire application-code cache, permanently, and bootsnap has no eviction of any kind.

On one of my app, shared/tmp/cache/bootsnap had reached 423 MB, against a real working set of 59 MB.

The fix is clean-up that relies on atime:

namespace :deploy do
  desc "Prune bootsnap cache entries not read in 30 days"
  task :prune_bootsnap_cache do
    on roles(:app) do
      cache = shared_path.join("tmp/cache/bootsnap")
      if test("[ -d #{cache} ]")
        before = capture(:du, "-sm", cache).split.first
        execute :find, cache, "-type f -atime +30 -delete"
        info "bootsnap cache: #{before}M -> #{capture(:du, '-sm', cache).split.first}M"
      end
    end
  end
end

Hook it late, after the site is already live, so it stays off the critical path. On first run, observes:

00:52 deploy:prune_bootsnap_cache
      01 find /srv/myapp/shared/tmp/cache/bootsnap -type f -atime +30 -delete
    ✔ 01 deploy@example.com 1.408s
      bootsnap cache: 423M -> 59M

The finished deploy.rb

Everything above, in one place:

lock "~> 3.20.0"

set :application, "myapp"
set :repo_url,    "git@github.com:me/myapp.git"
set :branch,      "main"
set :deploy_to,   "/srv/myapp"

append :linked_files, "config/database.yml", "config/master.key"
append :linked_dirs,  "log", "tmp/pids", "tmp/cache", "tmp/sockets", "storage"

set :keep_releases, 5
set :rbenv_ruby, "4.0.6"

set :bundle_version, 4                                  # Bundler 4: `bundle config set`
set :bundle_config, { deployment: true, clean: true }   # prune shared/bundle
set :keep_assets, 2                                     # prune shared public/assets
set :conditionally_migrate, true                        # skip db:migrate when db/ is unchanged
set :passenger_restart_with_touch, false                # skip the `passenger -v` probe

namespace :deploy do
  task :restart_workers do
    on roles(:app) do
      execute :sudo, "supervisorctl restart myapp-solid-queue:*"
    end
  end

  desc "Prune bootsnap cache entries not read in 30 days"
  task :prune_bootsnap_cache do
    on roles(:app) do
      cache = shared_path.join("tmp/cache/bootsnap")
      if test("[ -d #{cache} ]")
        before = capture(:du, "-sm", cache).split.first
        execute :find, cache, "-type f -atime +30 -delete"
        info "bootsnap cache: #{before}M -> #{capture(:du, '-sm', cache).split.first}M"
      end
    end
  end

  after :finished, :restart_workers
  after :restart_workers, :prune_bootsnap_cache
end