Deployment, performance and next steps
Bundler in production, Puma tuning, memory and GC behaviour, profiling with StackProf, structured logging, and where to go next.
Deploying Ruby
# deterministic install without touching the system gem path
bundle config set --local deployment true
bundle config set --local without "development test"
bundle config set --local path vendor/bundle
bundle install --jobs 4 --retry 3
# run behind a process manager
bundle exec puma -C config/puma.rb
# health check for the load balancer
curl -fsS http://127.0.0.1:3000/health || exit 1deployment truemakes Bundler refuse to run ifGemfile.lockis out of date, which turns a silent version drift into a failed deploy.- Install gems into
vendor/bundleso the release directory is self-contained and rollback restores the exact dependency set. - Run the migration as a separate step before starting new code, and keep it backward compatible with the running version.
RUBY_YJIT_ENABLE=1where supported gives a real throughput win on CPU-heavy work at the cost of a slower warm-up.
💡
A Ruby process that has been running for hours is not the same process that started. Memory grows with fragmentation and retained caches, so puma_worker_killer or a rolling restart is normal operation, not a sign of a leak you failed to find.
Measuring before tuning
require "benchmark"
require "stackprof"
time = Benchmark.realtime do
100.times { Report.build(sample_data) }
end
puts format("100 reports in %.3fs", time)
# where the CPU actually goes
StackProf.run(mode: :cpu, out: "tmp/stackprof.dump", raw: true) do
Report.build(sample_data)
end
# then: stackprof tmp/stackprof.dump --text --limit 20
# allocation pressure
StackProf.run(mode: :object) { Report.build(sample_data) }
GC.stat[:total_allocated_objects]| Symptom | Likely cause | Check |
|---|---|---|
| High CPU, low throughput | GVL contention or GC pressure | StackProf CPU mode, GC.stat |
| Memory grows monotonically | Unbounded cache or retained references | ObjectSpace count by class |
| Slow after deploy | Cold caches, cold JIT | Warm the cache in a post-deploy step |
| Slow only under load | Database connection pool exhaustion | Pool size versus thread count |
| Spiky latency | GC pauses | Tune RUBY_GC_HEAP_GROWTH_FACTOR, reduce allocations |
# structured logs a platform can index
require "json"
require "logger"
logger = Logger.new($stdout).tap { |l| l.formatter = proc do |severity, time, _prog, msg|
JSON.generate(level: severity.downcase, ts: time.utc.iso8601(3), msg: msg) + "\n"
end }
logger.info("report built rows=#{rows} ms=#{elapsed_ms}")
# never log tokens, passwords, card data or full request bodies- Optimise the query before the Ruby: one missing index costs more than every micro-optimisation in your application combined.
- Freeze string literals, avoid building strings in loops, and prefer
eachwith an accumulator over chainedmapandselecton large collections. - Add real instrumentation in production (OpenTelemetry, AppSignal, Skylight) rather than guessing from a local benchmark.
- Reading list: the Ruby documentation on GC and Ractors,
rackandpumaREADMEs, the Sidekiq best practices wiki, and the RubyGems guide on publishing.
FAQ
How many Puma workers should I run?
Start with one or two per CPU core and measure. Each worker is a full copy of the application in memory, so workers trade memory for CPU parallelism, while threads trade nothing but only help I/O-bound work.
Why is the first request after deploy slow?
The class cache, JIT and any application-level cache are cold. Warm the application with a scripted request to the main endpoints after the deploy, before the load balancer sends real traffic.
Related
Background jobs and the Ruby web stack Concurrency: threads, fibers and Ractors
Last refreshed 2026-09-18.