After evaluating package managers for our Rails project, I decide migrated from Yarn to pnpm for stricter, faster, and more disk‑efficient installs.
This guide is organized into four parts so you can adopt pnpm in the right order.
Why pnpm
- Disk efficiency: A content‑addressable store and hardlinks dramatically reduce duplicate files.
- Speed: Faster cold and warm installs with solid caching.
- Strictness: Fails on missing/phantom deps and ensures reproducible installs.
- Workspaces: First‑class monorepo support without heavy hoisting.
And the key reason: yarn is not doing any release in past two years and META just lay-off huge people recently.
How to do import in Node.js side
- Install pnpm (prefer Corepack):
| # If Node comes with Corepack (Node 16.13+)
corepack enable
corepack prepare pnpm@latest --activate
pnpm --version # should avtivate now
# Otherwise fall back to npm with global
npm i -g pnpm@latest
|
- Import your existing Yarn lockfile first:
| # Generates pnpm-lock.yaml from yarn.lock
pnpm import
git add pnpm-lock.yaml
|
- Remove Yarn artifacts and switch to pnpm in package.json:
| {
"packageManager": "pnpm@10"
}
|
- Clean and install deterministically:
| rm -rf node_modules .yarn .yarn-cache
pnpm install --frozen-lockfile
pnpm approve-builds
|
- (Optional) Workspaces: add
pnpm-workspace.yaml in a monorepo:
| # Only enable if you meeting build problem as pnpm by default using isolated
# https://pnpm.io/settings#nodelinker
# nodeLinker: hoisted
packages:
- '.'
# - 'packages/**'
|
How to change Rails to using pnpm
- Remove Yarn‑specific deploy helpers (if present):
Gemfile
| - gem 'capistrano-yarn'
+ gem 'capistrano-pnpm'
|
Capfile
| - require 'capistrano/yarn'
+ require 'capistrano/pnpm'
|
- Add a
bin/pnpm wrapper so Rails tasks can call pnpm consistently:
| #!/usr/bin/env ruby
APP_ROOT = File.expand_path('..', __dir__)
Dir.chdir(APP_ROOT) do
pnpm = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR)
.reject { |dir| File.expand_path(dir) == __dir__ }
.product(%w[pnpm pnpm.cmd pnpm.ps1])
.map { |dir, file| File.expand_path(file, dir) }
.find { |file| File.executable?(file) }
if pnpm
exec pnpm, *ARGV
else
$stderr.puts 'pnpm executable was not detected in the system.'
$stderr.puts 'Install pnpm via https://pnpm.io/installation'
exit 1
end
end
|
Make it executable: chmod +x bin/pnpm.
- Keep backward compatibility by proxying
bin/yarn to pnpm (update existing file):
| #!/usr/bin/env ruby
APP_ROOT = File.expand_path('..', __dir__)
Dir.chdir(APP_ROOT) do
pnpm = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR)
.reject { |dir| File.expand_path(dir) == __dir__ }
.product(%w[pnpm pnpm.cmd pnpm.ps1])
.map { |dir, file| File.expand_path(file, dir) }
.find { |file| File.executable?(file) }
if pnpm
warn 'Deprecated: bin/yarn now proxies to pnpm. Use bin/pnpm.' unless ENV['PNPM_PROXY_SILENT']
exec pnpm, *ARGV
else
$stderr.puts 'pnpm executable was not detected in the system.'
$stderr.puts 'Install pnpm via https://pnpm.io/installation'
exit 1
end
end
|
- Call pnpm from Rails scripts:
| # bin/setup (snippet)
system! 'bin/pnpm', 'install', '--frozen-lockfile'
# bin/update (snippet)
# system('bin/pnpm', 'install', '--frozen-lockfile')
|
- Webpacker binaries (if you still use webpacker):
| # Add to both bin/webpack and bin/webpack-dev-server
ENV['WEBPACKER_NODE_MODULES_BIN_PATH'] ||= File.expand_path('../node_modules/.bin', __dir__)
|
- Capistrano: use pnpm during deploys.
Create lib/capistrano/tasks/pnpm.rake:
| # frozen_string_literal: true
namespace :pnpm do
desc 'Install JavaScript dependencies using pnpm'
task :install do
on roles(fetch(:pnpm_roles, :web)) do
within release_path do
with fetch(:pnpm_env, {}) do
flags = Array(fetch(:pnpm_flags, %w[--frozen-lockfile]))
execute :pnpm, :install, *flags
end
end
end
end
end
before 'deploy:updated', 'pnpm:install'
|
And in config/deploy.rb:
| # Ensure node_modules is NOT a linked dir with pnpm
append :linked_dirs, 'log', 'tmp/pids', 'tmp/cache', 'tmp/sockets', 'public/system', 'storage'
# Important: pass strict install flags to pnpm
set :pnpm_flags, %w(--frozen-lockfile)
|
- (Optional, webpacker only) Replace the yarn check with a pnpm check:
| # lib/tasks/pnpm.rake
namespace :webpacker do
begin
Rake::Task['webpacker:check_yarn'].clear
rescue RuntimeError, NameError
end
desc 'Verifies if pnpm is installed (replaces webpacker yarn check)'
task :check_yarn do
pnpm_version = `pnpm --version`.strip
if pnpm_version.empty?
$stderr.puts 'pnpm not installed. Please install pnpm to compile webpacker assets.'
exit!
end
end
end
|
How to change CI/CD (optional)
Keep this for last. After local migration is green, update pipelines.
GitHub Actions
| jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- run: corepack enable && corepack prepare pnpm@latest --activate
- run: pnpm install --frozen-lockfile
- run: bin/rails assets:precompile
|
GitLab CI
| cache:
- key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store/
before_script:
- |
if command -v corepack >/dev/null; then
corepack enable
corepack prepare pnpm@latest --activate
else
npm i -g pnpm@latest
fi
install:
script:
- pnpm install --frozen-lockfile --store-dir .pnpm-store
|
CircleCI
| - node/install-packages:
pkg-manager: pnpm
cache-key: pnpm-lock.yaml
|
Conclusion
Enjoy faster, stricter, and much leaner pnpm!