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)
- 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.
- Sprockets is gone; Propshaft is the default pipeline.
- Solid Queue is the default, and it runs as a separate process you have to restart yourself.
- bundle install --deployment is gone. Bundler configuration is now bundle config set --local, and capistrano-bundler needs to be told about it.
- 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