Production capacity, latency and reliability depend on process and thread settings as much as on code.
Rails 8 apps are deployed as Docker containers with Kamal 2. config/deploy.yml lists servers, the image registry and environment secrets (.kamal/secrets). kamal setup prepares fresh servers, and kamal deploy builds the image, pushes it, boots new containers and switches traffic through kamal-proxy with zero downtime and automatic Let's Encrypt TLS. Accessories run databases or Redis next to the app. Heroku, Render and Fly.io remain alternatives.
Puma serves requests with processes and threads:
WEB_CONCURRENCYsets workers (forked processes). Use roughly one per CPU core for real parallelism, since the GVL lets only one thread run Ruby at a time per process.RAILS_MAX_THREADSsets threads per worker (default 3 since Rails 7.2). Threads overlap I/O waits (database, HTTP) but compete for the GVL, so more threads raises throughput and also latency.preload_app!loads the app before forking so workers share memory via copy-on-write.
The database connection pool (pool: in database.yml) must be at least the number of threads that use the database in each process, including Solid Queue threads if they run inside Puma; otherwise threads wait for connections and time out.
Production checklist: enable YJIT (on by default when available), serve assets with long cache headers via Thruster or a CDN, keep jobs off the web process for heavy loads, and watch p95 and p99 latency, not averages. Find bottlenecks with an APM or open-source tools: rack-mini-profiler, query logs with config.active_record.query_log_tags_enabled, and bin/rails logs showing time spent in views versus Active Record.
Example
# config/deploy.yml (Kamal 2, abridged)
service: blog
image: acme/blog
servers:
web: [203.0.113.10, 203.0.113.11]
job:
hosts: [203.0.113.12]
cmd: bin/jobs
proxy:
ssl: true
host: blog.example.com
env:
clear:
WEB_CONCURRENCY: 4 # ~1 Puma worker per core
RAILS_MAX_THREADS: 3 # threads per worker
secret: [RAILS_MASTER_KEY]
# config/database.yml (production excerpt)
# pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %>
# $ kamal setup # first time: install Docker, boot kamal-proxy, deploy
# $ kamal deploy # zero-downtime rollout
# $ kamal app logs -fRemember
- Kamal 2 deploys Docker images with zero downtime through kamal-proxy
- Puma workers give parallelism; threads overlap I/O within the GVL
- Size the DB pool to at least the threads per process that use the database
Watch out for
- Raising RAILS_MAX_THREADS without raising the database pool, causing connection timeouts under load.