Optimizing Passenger and Nginx for Rails 8
The Performance Baseline of Rails 8 and Passenger
Historically, Ruby on Rails has occasionally been criticized for its memory consumption. Today, though, Ruby on Rails 8 introduces significant performance improvements, particularly when paired with Ruby 3.3 or newer and its default YJIT compiler. However, deploying a Rails application with Phusion Passenger and Nginx requires explicit configuration tuning to maximize these benefits.
Out-of-the-box settings for Passenger are designed for maximum compatibility across varied environments — not necessarily for the high-concurrency, memory-intensive realities of a modern production application. Relying on default configurations often leads to inefficient resource utilization, slow response times during traffic spikes, and application instability. In this guide, we will outline practical strategies for optimizing Passenger and Nginx specifically for a Rails 8 architecture.
Calculating passenger_max_pool_size for Ruby 3.3+
The most critical Passenger setting for any Rails application is passenger_max_pool_size. This directive dictates the maximum number of Ruby application processes Passenger is allowed to spawn. Setting this value too low creates a bottleneck where incoming requests queue up waiting for an available process. Setting it too high leads to memory exhaustion, forcing the operating system to use swap space, which degrades performance.
To calculate the optimal pool size, we must determine the total available RAM on our server, subtract the baseline memory required by the operating system and background services (like PostgreSQL or Redis, if hosted on the same machine), and divide the remainder by the average memory consumed by a single Rails process.
With Rails 8 and YJIT enabled, we can generally expect individual process memory usage to be slightly higher than in previous versions — often between 300MB and 600MB depending on the application’s complexity.
For example, if you have a server with 8GB of RAM, and the OS and auxiliary services consume 1GB, you have 7GB (7168MB) available. If a single Rails process averages 400MB:
7168MB / 400MB = 17.92
In this scenario, a passenger_max_pool_size of 17 is appropriate. We would configure this in our Nginx server block like this:
passenger_max_pool_size 17;
Tip: When adjusting
passenger_max_pool_size, it is generally better to be slightly conservative. It is far better to have a few requests queue up momentarily than to trigger an out-of-memory event that brings the entire server to a halt.
Mitigating Cold Starts with passenger_min_instances
Passenger dynamically scales the number of application processes based on traffic volume. During periods of low traffic, it shuts down idle processes to conserve memory. When traffic spikes again, Passenger must boot new Rails processes to handle the load. This initialization sequence — loading the Rails framework, gems, and application code — takes time and results in a “cold start” delay for the user triggering the spawn.
To prevent this latency, we can configure passenger_min_instances. This setting guarantees a baseline number of processes remain active at all times, regardless of traffic volume. Set this value high enough to handle your application’s typical baseline traffic without requiring new processes to spawn immediately. For a server with a maximum pool size of 15, a minimum instance count of 5 to 8 is often a pragmatic starting point.
passenger_min_instances 5;
Preventing Memory Bloat with passenger_max_requests
While Ruby’s garbage collector has improved significantly over the years, long-running processes in complex applications can still experience memory bloat or gradual leaks over time. As a process consumes more memory, the garbage collector works harder, increasing response latency.
The passenger_max_requests directive acts as a safeguard against runaway memory consumption. It instructs Passenger to gracefully restart a Ruby process after it has handled a specified number of requests. The optimal value depends entirely on how quickly your application expands its memory footprint. A common baseline is between 1000 and 3000 requests.
passenger_max_requests 2000;
We can monitor our application’s memory profile using tools like passenger-memory-stats to find a threshold that recycles processes before they become bloated, without recycling them so frequently that we impact overall throughput. We can run this from the command line:
$ sudo passenger-memory-stats
Optimizing Nginx Worker Processes for High Concurrency
Of course, Passenger doesn’t run in isolation; Nginx serves as the reverse proxy sitting in front of Passenger, handling raw HTTP connections, static assets, and SSL termination. Its performance is governed by worker_processes and worker_connections.
The worker_processes directive should generally map to the number of available CPU cores on your server. Nginx is highly efficient and event-driven; one worker process per core is typically sufficient to handle thousands of concurrent connections. Setting this to auto allows Nginx to detect the number of cores and configure itself automatically.
The worker_connections directive determines how many simultaneous connections a single worker process can manage. The default is often 1024. For high-traffic applications, this can be safely increased to 4096 or 8192, provided the operating system’s file descriptor limits are configured to support it.
worker_processes auto;
events {
worker_connections 4096;
}
Offloading Static Assets from the Rails Stack
Rails 8 applications still rely on static assets, whether managed by Propshaft or traditional pipelines. Serving these assets through the Ruby application stack is an inefficient use of resources. Nginx, strictly speaking, is fundamentally designed to serve static files rapidly with minimal overhead.
We should ensure our Nginx server block is configured to bypass Passenger entirely for requests matching the /assets/ or /public/ directories. We can use the expires max; and add_header Cache-Control public; directives to instruct the client’s browser to cache these files aggressively. By offloading this work, we free up our Ruby processes to handle dynamic application logic and database queries.
location ~ ^/(assets|packs)/ {
expires max;
add_header Cache-Control public;
# Passenger will automatically bypass these if passenger_document_root is set
}
Implementing Compression for Faster Payload Delivery
Minimizing the size of the payload sent over the network directly improves the perceived performance of your application. Nginx supports on-the-fly compression of text-based responses (HTML, CSS, JavaScript, and JSON) using Gzip or the more modern Brotli algorithm.
We can ensure gzip on; is enabled in our Nginx configuration, along with a comprehensive list of gzip_types.
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
If your server environment supports it, compiling Nginx with the Brotli module yields significantly better compression ratios than Gzip, reducing payload sizes further and accelerating rendering times for end users.
Validating Passenger Configurations in Production
Configuration tuning is an iterative process. We must validate the impact of our changes using empirical data. We can use the passenger-status command to observe the current state of the application pool, including the number of active processes, the queue length, and the current memory consumption:
$ sudo passenger-status
Before applying new configurations, we should establish a baseline of our application’s performance using Application Performance Monitoring (APM) tools. We should monitor our p95 response times, CPU utilization, and memory charts after the deployment. If we observe increased queuing or memory swapping, we will need to adjust the pool size or request limits accordingly. Pragmatic optimization requires continuous measurement and adjustment.
Sponsored by Durable Programming
Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.
Hire Durable Programming