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 1
  • deployment true makes Bundler refuse to run if Gemfile.lock is out of date, which turns a silent version drift into a failed deploy.
  • Install gems into vendor/bundle so 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=1 where 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]
SymptomLikely causeCheck
High CPU, low throughputGVL contention or GC pressureStackProf CPU mode, GC.stat
Memory grows monotonicallyUnbounded cache or retained referencesObjectSpace count by class
Slow after deployCold caches, cold JITWarm the cache in a post-deploy step
Slow only under loadDatabase connection pool exhaustionPool size versus thread count
Spiky latencyGC pausesTune 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 each with an accumulator over chained map and select on 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, rack and puma READMEs, 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.

Background jobs and the Ruby web stack Concurrency: threads, fibers and Ractors

Last refreshed 2026-09-18.