Isometric illustration of a load balancer spreading traffic across a row of identical servers backed by a primary database and a replica

To scale a Rails app, first find the bottleneck (web capacity, database, memory or background jobs), then fix it in the cheapest order: tune and enlarge one instance, make the app stateless, add more web and worker instances behind a load balancer, and only then scale the database with connection pooling and read replicas. Most Rails apps that “need to scale” are fighting one slow query or a memory-hungry process, not a lack of servers. This guide explains each step, what it costs on Heroku, Render and Fly.io, and which approach fits which stage of an app.


TL;DR

  • Measure before scaling: request queue time and p95 latency tell you whether you need more web capacity or faster code. CPU and memory tell you which resource runs out first.
  • Scale vertically first. One bigger instance with tuned Puma workers is simpler and often cheaper than three small ones.
  • Before going horizontal, make the app stateless: uploads in object storage, a shared cache, cookie or database sessions, and nothing written to local disk.
  • Horizontal scaling multiplies database connections. Budget them, and add PgBouncer before PostgreSQL runs out of slots.
  • Scale the database last: indexes and caching first, then a read replica with Rails connects_to, then a bigger primary.

Table of Contents

What does it mean to scale a Rails app?

Scaling a Rails app means increasing how many requests and jobs it can handle while keeping response times acceptable. You can do that by giving a server more resources (vertical), by running more copies of the app (horizontal), or by reducing the work each request does.

Rails itself scales well. Shopify, GitHub and Basecamp run Rails at very large scale. What limits a typical app is how many Ruby processes fit in your memory budget, how fast the database answers, and whether anything in the app assumes there’s only one server. That’s why scaling Rails is mostly an infrastructure and architecture question, with a little Puma configuration on top.

How do I know which part of my Rails app needs scaling?

Look at four numbers: request queue time, p95 response time, CPU and memory per process, and database time per request. The combination tells you which resource is actually running out.

Which bottleneck are you actually hitting: symptoms, likely cause and what to scale

  • Request queue time is how long a request waits before a Puma thread picks it up. If it climbs while CPU is high, you’re out of web capacity, and more processes or instances help. Most APM tools report it when the proxy sets an X-Request-Start header.
  • p95 latency with low CPU means requests are waiting on something: usually the database, sometimes an external API. Adding servers won’t help. Fixing queries and caching will.
  • Memory limits how many Puma workers fit on one machine. A typical Rails worker uses 200–500 MB, and it grows over time. If processes are being OOM-killed, more threads make it worse.
  • Job queue latency growing while the web tier is fine means you need more worker capacity, not more web servers.

Set up these metrics before you scale anything. The Rails monitoring guide shows the instrumentation. The cause is often not where you’d expect.

Vertical vs horizontal scaling for Rails

Vertical scaling gives one server more CPU and memory. Horizontal scaling runs more identical servers behind a load balancer. Rails apps usually need both, in that order, because vertical scaling is simpler and horizontal scaling requires a stateless app.

  Vertical (bigger instance) Horizontal (more instances)
What changes More CPU cores and RAM → more Puma workers per instance More instances, each with the same Puma config
App changes needed None Must be stateless: shared cache, sessions, file storage
Failure tolerance One instance = single point of failure Survives one instance dying
Deploys Simple Needs a load balancer with health checks
DB connections Grow slowly Grow with every instance
Upper limit The largest machine you can buy Effectively the database
Cost curve Often cheaper per GB at the low end Linear; autoscaling can lower average cost

Pros of vertical scaling: no code changes, no load balancer, fewer moving parts, and processes on the same machine share memory through copy-on-write when Puma preloads the app.

Cons of vertical scaling: a single machine is a single point of failure, there’s a ceiling, and a deploy or crash affects all traffic at once.

Pros of horizontal scaling: redundancy, zero-downtime rolling deploys, and capacity that grows with traffic and can shrink at night.

Cons of horizontal scaling: every instance adds database connections, local state breaks, and you pay for a load balancer and duplicated memory overhead.

Step 1: Get the most out of one instance

Before adding servers, make sure the one you have is used well: the right number of Puma workers for its cores and memory, jemalloc, and no obviously slow endpoints.

# config/puma.rb
# One worker per CPU core is a good starting point; memory is usually the real limit.
workers ENV.fetch("WEB_CONCURRENCY") { Etc.nprocessors }
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count

preload_app!                # share memory between workers via copy-on-write
port ENV.fetch("PORT", 3000)

The rough sizing rule: WEB_CONCURRENCY ≈ number of cores, as long as workers × memory per worker fits in about 80% of RAM. With 2 GB and workers at around 350 MB, that’s four workers at most, whatever the core count says.

Three cheap wins that often double capacity before you touch infrastructure:

  1. jemalloc. The Rails 8 Dockerfile preloads it. It often cuts Ruby memory use by 20–40%, which means more workers per GB.
  2. Fix the top three slowest endpoints. One N+1 query on a popular page can consume more capacity than everything else together.
  3. Move slow work to background jobs. A request that waits eight seconds for a PDF holds a Puma thread for eight seconds. See running Rails background jobs in production.

Pro Tip: Vertical scaling has a hidden benefit with Puma. All workers on one machine share the preloaded application code in memory, so four workers on one 2 GB instance use less total memory than four single-worker 512 MB instances.

Step 2: Make the app stateless

A Rails app is ready for horizontal scaling when any request can go to any instance and get the same result. That rules out storing anything on the local disk or in process memory that another instance would need.

State Where it lives on one server Where it must live with N servers
File uploads (Active Storage) storage/ on local disk S3-compatible object storage
Cache :memory_store or :file_store Solid Cache (database) or Redis
Sessions Cookie store (fine) or :cache_store with a local cache Cookie store, or cache store on a shared cache
Background jobs :async adapter (in-process) Solid Queue or Sidekiq
Action Cable async adapter Solid Cable or Redis adapter
Generated files, exports tmp/, public/ Object storage, with a signed URL to the user
Cron / scheduled tasks System crontab on the server Solid Queue recurring tasks, run once

The matching production configuration:

# config/environments/production.rb
Rails.application.configure do
  config.active_storage.service = :amazon            # defined in config/storage.yml
  config.cache_store = :solid_cache_store            # or :redis_cache_store, { url: ENV["REDIS_URL"] }
  config.active_job.queue_adapter = :solid_queue     # or :sidekiq
  config.session_store :cookie_store, key: "_myapp_session"
end
# config/storage.yml
amazon:
  service: S3
  access_key_id: <%= ENV["S3_ACCESS_KEY_ID"] %>
  secret_access_key: <%= ENV["S3_SECRET_ACCESS_KEY"] %>
  region: <%= ENV.fetch("S3_REGION", "eu-central-1") %>
  bucket: <%= ENV["S3_BUCKET"] %>
  endpoint: <%= ENV["S3_ENDPOINT"] %>   # set for R2, Hetzner, MinIO, etc.
# config/cable.yml
production:
  adapter: solid_cable
  connects_to:
    database:
      writing: cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

Test it before you need it: run two instances behind a local load balancer (or docker compose up --scale web=2 with a proxy in front), upload a file, log in and out, and reload pages a dozen times. If anything is inconsistent, there’s still local state somewhere.

Step 3: Scale web and workers horizontally

With a stateless app, add identical web instances behind a load balancer that health-checks each one, and scale job workers separately based on queue latency. Web and workers should never share a scaling decision.

A horizontally scaled Rails app: CDN, load balancer, N web and job instances, PostgreSQL primary and replica, shared cache and storage

The pieces that must be in place:

  • A health check that the load balancer uses to route traffic only to booted instances. Keep it shallow. The Rails health check guide explains why.
  • No sticky sessions. With cookie-based sessions you don’t need them, and they defeat even load distribution.
  • Migrations run once per deploy, not once per instance. The Rails advisory lock protects you from parallel runs, but one place is cleaner.
  • Connection budget. Every web instance adds workers × threads connections per database. Do the arithmetic from the connection pool guide before scaling from 2 to 10 instances.

With Kamal, horizontal scaling means adding hosts and putting a load balancer in front:

# config/deploy.yml
service: myapp
image: myorg/myapp

servers:
  web:
    hosts:
      - 10.0.0.11
      - 10.0.0.12
      - 10.0.0.13
  job:
    hosts:
      - 10.0.0.21
    cmd: bin/jobs

proxy:
  ssl: false            # TLS ends at the load balancer
  host: example.com
  healthcheck:
    path: /up

On a PaaS, it’s a slider or a command: heroku ps:scale web=3 worker=2, fly scale count web=3 worker=2, or the instance count in Render’s dashboard.

Step 4: Scale the database

The database is the last thing to scale and the hardest. Before adding hardware, make sure each query is fast: indexes on the columns you filter and join on, no N+1 queries, and caching for expensive read-heavy pages. After that, the options are pooling, read replicas, and a bigger primary.

1. Connection pooling. Many web instances mean many idle connections. PgBouncer in transaction mode lets hundreds of Rails connections share a few dozen PostgreSQL backends.

2. Read replicas. Rails has built-in support for sending reads to a replica:

# config/database.yml
production:
  primary:
    url: <%= ENV["DATABASE_URL"] %>
  primary_replica:
    url: <%= ENV["DATABASE_REPLICA_URL"] %>
    replica: true
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  connects_to database: { writing: :primary, reading: :primary_replica }
end
# config/environments/production.rb
Rails.application.configure do
  # Send GET/HEAD requests to the replica, unless this session wrote in the last 2 seconds.
  config.active_record.database_selector = { delay: 2.seconds }
  config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
  config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
end

Or switch explicitly for reports and expensive pages:

# app/controllers/reports_controller.rb
def index
  @rows = ActiveRecord::Base.connected_to(role: :reading) do
    Order.where(created_at: 30.days.ago..).group(:status).count
  end
end

Replicas lag behind the primary by milliseconds to seconds. The 2-second delay in the selector means a user who just saved something reads from the primary and sees their own write.

3. A bigger primary. Vertical scaling works for databases too, and it’s often the right answer up to surprisingly large sizes. Sharding (Rails supports it with connects_to shards:) is a last resort that very few apps ever need.

How much does it cost to scale a Rails app?

Scaling from one instance to four costs anywhere from about $50 to $1,000 a month depending on the platform, and the price of autoscaling varies even more. Here’s what roughly comparable web capacity cost on public price lists in October 2026, compute only, without databases or bandwidth:

Platform Instance used RAM Price per instance/month 4 instances Autoscaling
Heroku Standard-2X 1 GB $50 $200 Only on Performance dynos ($250+ each)
Heroku Performance-M 2.5 GB $250 $1,000 Yes
Render Standard 2 GB $25 $100 On Pro workspace plans and above
Fly.io shared-cpu-1x 2 GB $13.39 $53.56 Autostop/autostart; metrics-based autoscaler app
Fly.io performance-1x 2 GB $33.00 $132.00 Same

Prices from each provider’s public pricing page; Fly.io figures are for its lowest-priced regions (iad, ewr) and assume a full month of uptime. Render also charges a workspace plan fee on top of compute ($25/month on Pro).

Two things stand out. First, the cost of one extra instance varies by a factor of about 20 across platforms for similar memory. Second, autoscaling is a premium feature on Heroku, where it’s tied to Performance dynos, so many small teams scale manually or not at all.

How Heroku, Render, Fly.io, Upsun and Enkihost scale Rails apps

Heroku, Render, Fly.io and Upsun scale Rails horizontally by running more copies of the same process type behind their router. Enkihost ties scaling to the plan, with autoscaling on Blaze. They differ in instance sizes, whether autoscaling is included, and how workers scale separately from web.

Platform Horizontal scaling Vertical scaling Autoscaling Workers
Heroku heroku ps:scale web=N Change dyno type Performance dynos only, based on response time Separate worker process type
Render Instance count per service Change instance type CPU/memory targets on Pro workspace plans Separate Background Worker service
Fly.io fly scale count web=N per process group fly scale vm / fly scale memory Autostop/autostart by traffic; separate autoscaler for metrics Process groups in fly.toml
Upsun Instance count per app (horizontal scaling) upsun resources:set for CPU/RAM per container Not automatic by default Workers defined in app config
Enkihost Blaze plan: high availability and autoscaling Move from Ignite to Blaze Blaze only; triggers not documented No separate worker; Solid Queue inside Puma

The common thread is the Rails side. On every platform, the app must be stateless before a second instance helps, and every extra instance adds database connections that someone has to budget for.

Enkihost runs each app with allocated memory and CPU and per-app resource isolation, so Step 1 (tuning Puma workers to the resources you have) applies directly. Moving to a larger plan is the vertical scaling path there.

Which scaling approach should you choose?

Pick the cheapest step that removes your current bottleneck. Here’s how that usually maps to the stage an app is at:

Stage Typical traffic Recommendation
Side project / MVP < 10 req/s One instance, Solid Queue in Puma, Solid Cache. Don’t scale; measure.
Small business app 10–50 req/s Vertical: a bigger instance, tuned Puma workers, jemalloc, background jobs for slow work.
Growing SaaS 50–300 req/s Stateless app, 2–4 web instances behind a load balancer for redundancy, separate job workers, PgBouncer.
Read-heavy content or API Any, DB-bound Caching first, then a read replica with connects_to and the database selector.
Spiky traffic (launches, campaigns) Bursts of 10× Horizontal with autoscaling or scheduled scale-up, and a CDN in front of assets and cacheable pages.

If you only remember one thing: scale for redundancy at two instances, scale for capacity only when the metrics say so.

Author Perspective: most apps never need step four

Most “we need to scale” conversations I’ve been in ended with an index, a cache and a bigger instance. The apps that truly needed ten web servers and a replica were rare, and when they did, it was obvious from the metrics months in advance. What I see much more often is a team that went horizontal too early. They had three small instances where one medium one would do, uploads still on local disk, and a connection count nobody had added up. My advice: run two instances as soon as uptime matters, because that’s redundancy, not scaling. Everything beyond that should be driven by a graph, not a hunch.

Scaling a Rails app on Enkihost

Most of scaling a Rails app is getting the most out of the resources you have, then making sure nothing breaks when you add more. Enkihost covers the pieces this guide depends on:

Enkihost

  • Allocated memory and CPU per app, with per-app resource isolation. You size WEB_CONCURRENCY and RAILS_MAX_THREADS for a known budget, and a noisy neighbour doesn’t eat it.
  • PostgreSQL and Redis add-ons inject DATABASE_URL and REDIS_URL, which covers the shared cache, sessions and job queue that a stateless app needs.
  • Zero-downtime deploys, so a release doesn’t drop requests while it rolls out.
  • Clear plans: Ignite (5 EUR per month, 14-day free trial) and Blaze (16 EUR per month, with dedicated vCPUs, high availability and autoscaling), so the cost of moving up a step is known in advance.

See enkihost.com.

FAQ

Can Ruby on Rails scale?

Yes. Rails powers some of the largest web applications, including Shopify, GitHub and Basecamp. Scaling limits in practice come from database design, memory per process and application state, not from the framework. A stateless Rails app scales horizontally like any other web stack.

Should I scale vertically or horizontally first?

Vertically first. A bigger instance with tuned Puma workers needs no code changes and keeps operations simple. Move to horizontal scaling when you need redundancy, when you reach the largest practical instance, or when traffic is spiky enough that autoscaling saves money.

How many requests per second can one Rails server handle?

It depends on how much work each request does. A useful estimate is total Puma threads divided by average response time in seconds: 4 workers with 3 threads and 100 ms average responses handle about 120 requests per second at full load. Database time and external calls change that number far more than the server size does.

Do I need Kubernetes to scale a Rails app?

No. A load balancer in front of several identical instances, deployed with Kamal or a PaaS, covers the needs of most Rails apps. Kubernetes becomes worthwhile when you run many services and have a team to operate it, not because a single Rails app grew.

When should I add a read replica?

Add a read replica when the primary database’s CPU is consistently high because of read queries, after you’ve already added indexes and caching. Rails supports replicas natively with connects_to and the automatic database selector. Replicas don’t help with write load.

Sources