Introduction
Horizontal scaling adds more servers to handle increased traffic, rather than upgrading to bigger servers. Kamal makes this as simple as adding host IPs to your deploy.yml.
Key Concepts
- Horizontal Scaling: Adding more server instances to distribute load.
- Connection Pooling: Managing a fixed set of reusable database connections per process to prevent connection exhaustion.
- Puma Tuning: Configuring Puma's workers (processes) and threads to maximize throughput for your workload.
Real World Context
Black Friday traffic is 10x normal. With Kamal, you add 5 new server IPs to deploy.yml, run kamal deploy, and you're handling the load. After the rush, remove the servers.
Deep Dive
Adding Servers with Kamal
yaml# config/deploy.yml servers: web: hosts: - web1.example.com - web2.example.com - web3.example.com # Add more as needed
bashkamal deploy # Deploys to all hosts
Puma Tuning
ruby# config/puma.rb workers ENV.fetch('WEB_CONCURRENCY', 2) threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) threads threads_count, threads_count preload_app! # Note: on_worker_boot with establish_connection is no longer needed # in Rails 7.1+ — Active Record automatically re-establishes # connections after fork when using preload_app!
Database Connection Pooling
yaml# config/database.yml production: adapter: postgresql pool: <%= ENV.fetch('RAILS_MAX_THREADS', 5) %> url: <%= ENV['DATABASE_URL'] %>
Total connections = workers × threads × number_of_servers.
Caching for Scale
ruby# config/environments/production.rb # Solid Cache (default — scales with your database) config.cache_store = :solid_cache_store # Or Redis for very high-throughput caching config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'], pool_size: ENV.fetch('RAILS_MAX_THREADS', 5) }
Common Pitfalls
- Not matching pool size to threads — Each thread needs its own database connection. Pool must be >= thread count.
- Adding unnecessary
on_worker_bootblocks — In Rails 7.1+, Active Record automatically re-establishes connections after fork. Adding manualestablish_connectioncalls is redundant.
Best Practices
- Start with 2 workers, 5 threads — This is Puma's default and works well for most Rails apps. Profile before tuning.
- Use PgBouncer for many servers — With 10+ servers, PgBouncer multiplexes connections to stay within PostgreSQL's limits.
Summary
- Add server IPs to deploy.yml and run
kamal deployto scale horizontally. - Match database pool size to your Puma thread count.
- Use
preload_app!for efficient process forking (Rails 7.1+ handles connection re-establishment automatically). - Monitor total database connections across all servers.
Code Examples
ruby
# config/puma.rb — production tuning
workers ENV.fetch('WEB_CONCURRENCY', 2) # Processes
threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
threads threads_count, threads_count # Threads per worker
preload_app! # Load app once, fork workers
# Rails 7.1+ automatically re-establishes DB connections after fork
# Total capacity: 2 workers × 5 threads = 10 concurrent requests
# Total DB connections: 2 × 5 = 10