Part 3 – Passenger, Nginx and the Request Lifecycle (deep dive)

Overview / Goal

In Part 3 we explain how Passenger sits behind Nginx in an API-only Rails app, where caching belongs in the stack, how to safely cache API responses (or avoid caching sensitive ones), and how to verify behavior. This part covers Passenger role, request lifecycle, and API response caching strategy.


1) Passenger’s role in a Rails API app — what it is and how it plugs into Nginx

What is Passenger?
Passenger (Phusion Passenger) is an application server that runs Ruby apps (Rails) and integrates tightly with web servers such as Nginx. It manages application processes, handles spawning, lifecycle, zero-downtime restarts, and serves Rack apps directly without a separate reverse-proxy layer.

Why using Passenger in your stack matters:

  • Nginx serves static files directly (fast).
  • If a request cannot be served as a static file, Nginx hands it to Passenger, which invokes your Rails app (API).
  • Passenger takes care of Ruby processes, workers, memory limits, restarts, etc., reducing operational complexity compared to orchestrating your own Puma cluster + systemd.

Typical nginx + passenger snippet (conceptual)

server {
  listen 443 ssl http2;
  server_name api.mydomain.com www.mydomain.com;
  root /apps/mydomain/current/public;

  passenger_enabled on;
  passenger_ruby /apps/mydomain/shared/ruby;
  passenger_min_instances 2;
  passenger_max_pool_size 6;
  passenger_preload_bundler on;

  # static + caching rules here ...
  location / {
    # fallback: handover to passenger (Rails)
    passenger_enabled on;
  }
}

Passenger is enabled per-server or per-location. Static files under root are resolved by nginx first — Passenger only gets requests that do not map to files (or that you explicitly route to Passenger).


2) Request lifecycle (browser → Nginx → Passenger → Rails API)

A canonical sequence for a request to your site:

  1. Browser requests https://www.mydomain.com/some/path (or /vite/index-ABC.js, or /api/v1/products).
  2. Nginx checks if the request maps to a static file under root /apps/mydomain/current/public.
    • If file exists → serve it directly and attach headers (Cache-Control, etc.).
    • If not → pass the request to Passenger.
  3. Passenger receives the request and dispatches it to a Rails process.
  4. Rails API processes the request (controllers -> models -> DB) and produces a response JSON or status.
  5. Rails returns the response to Passenger → Passenger returns it to Nginx → Nginx returns it to the browser.

Key layers where caching can occur:

  • Client-side (browser) — controlled by Cache-Control returned from server.
  • Reverse-proxy or CDN — e.g., Cloudflare, Fastly, CloudFront; caching behavior influenced by s-maxage and surrogate headers.
  • Application caching (Rails/Redis) — memoization or precomputed JSON payloads to reduce DB cost.
  • Nginx (edge) caching — possible for static assets; less common for dynamic Rails responses when using Passenger (but possible with proxying setups).

3) Where API caching should sit (principles)

Because your Rails app is API-only, you should carefully control caching:

  • Static assets (JS/CSS/fonts/images) = Nginx (1-year for hashed assets).
  • API responses (JSON) = usually short-lived or uncached unless content is highly cacheable and non-sensitive. If cached:
    • Prefer caching at CDN layer (s-maxage) or using application-level caching (Rails + Redis).
    • Use cache validation (ETag, Last-Modified) to enable conditional requests and 304 responses.
  • Sensitive endpoints (auth, user-specific data) = never cached publicly. Use Cache-Control: no-store, private.

4) Cache-Control and related headers for APIs — recommended practices

Important response headers and their recommended usage

  • Cache-Control:
    • no-store — do not store response anywhere (safest for sensitive data).
    • no-cache — caches may store but must revalidate with origin before use (useful if you want caching but require revalidation).
    • private — response intended for a single user; shared caches (CDNs) must not store it.
    • public — response may be stored by browsers and CDNs.
    • max-age=SECONDS — TTL in seconds.
    • s-maxage=SECONDS — TTL for shared caches (CDNs); supersedes max-age for shared caches.
    • must-revalidate / proxy-revalidate — force revalidation after expiration.
    • stale-while-revalidate / stale-if-error — allow stale responses while revalidation or in case of errors (good for resilience).
  • ETag:
    • Strong validator; server generates a value representing resource state. Client includes If-None-Match on subsequent requests. Server returns 304 Not Modified if ETag matches.
  • Last-Modified and If-Modified-Since:
    • Based on timestamp; less precise than ETag but simple.
  • Vary:
    • Tells caches that responses vary by certain request headers (e.g., Vary: Accept-Encoding or Vary: Authorization).

Example header patterns

  • Public, CDN cacheable API (e.g., public product listings): Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30 ETag: "abc123" Vary: Accept-Encoding
    • Browser caches for 60s. CDN caches for 300s. Meanwhile allow stale while revalidate.
  • User-specific / sensitive responses: Cache-Control: private, no-store, no-cache, must-revalidate
    • Prevents sharing.
  • No caching (strict): Cache-Control: no-store

5) How to add caching headers in a Rails API controller (practical examples)

Because you run an API-only app, prefer setting headers in controllers selectively for GET endpoints you consider safe to cache.

Basic manual header (safe and explicit):

class Api::V1::ProductsController < ApplicationController
  def index
    @products = Product.popular.limit(20)
    # set short-lived cache for 60 seconds for browsers
    response.set_header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=30')
    render json: @products
  end
end

Using conditional GET with ETag / Last-Modified:

class Api::V1::ProductsController < ApplicationController
  def show
    product = Product.find(params[:id])
    # This helps return 304 Not Modified if product hasn't changed
    if stale?(etag: product, last_modified: product.updated_at)
      render json: product
    end
  end
end

Notes: stale? and fresh_when are provided by ActionController::ConditionalGet. In an API-only app these helper methods are normally available, but confirm by checking your ApplicationController inheritance; if not, you can use response.set_header('ETag', ...) directly.

Setting ETag manually:

etag_value = Digest::SHA1.hexdigest(product.updated_at.to_i.to_s + product.id.to_s)
response.set_header('ETag', "\"#{etag_value}\"")
# then Rails will respond with 304 if If-None-Match matches


6) Important rules for API caching

  • Only cache GET responses. Never cache responses to POST, PUT, PATCH, DELETE.
  • Do not cache user-specific or sensitive info in shared caches. Use private or no-store.
  • Prefer CDN caching (s-maxage) for public endpoints. Use s-maxage to instruct CDNs to keep content longer than browsers.
  • Use ETags or Last-Modified for validation to reduce bandwidth and get 304 responses.
  • Consider short TTLs and stale-while-revalidate to reduce origin load while keeping content fresh.
  • Version your API (e.g., /api/v1/) so you can change caching behavior on new releases without conflicting with old clients.

7) Nginx + Passenger and caching for API endpoints — what to do (and what to avoid)

  • Avoid using Nginx proxy cache with Passenger by default. Passenger is not a reverse proxy; it’s an app server. Nginx can use proxy_cache for caching upstream responses, but that pattern is more common when you proxy to a separate Puma/Unicorn backend via proxy_pass. With Passenger, it’s simpler and safer to set cache headers in Rails and let CDNs or clients respect them.
  • If you want edge caching in Nginx, it is technically possible to enable fastcgi_cache/proxy_cache patterns if you have an upstream; use caution — caching dynamic JSON responses at the web server is tricky and must be carefully invalidated.

Recommended: set caching headers in Rails (as shown), then let a CDN (Cloudflare/Fastly/CloudFront) apply caching and invalidation; Passenger remains the process manager for Rails.


8) Example: making a public, cacheable endpoint safe and CDN-friendly

class Api::V1::PublicController < ApplicationController
  def top_offers
    data = Offer.top(10) # expensive query
    response.set_header('Cache-Control', 'public, max-age=120, s-maxage=600, stale-while-revalidate=30')
    # Optionally set ETag
    fresh_when(etag: Digest::SHA1.hexdigest(data.map(&:updated_at).join(',')))
    render json: data
  end
end

  • max-age=120 → browsers cache for 2 minutes
  • s-maxage=600 → CDN caches for 10 minutes
  • stale-while-revalidate=30 → CDN/browsers may serve stale for 30s while origin revalidates

9) Passenger vs Puma — quick comparison (for API deployments)

Passenger

  • Pros:
    • Tight nginx integration (simpler config).
    • Auto-manages application processes; zero-downtime restarts are straightforward (passenger-config restart-app).
    • Good defaults for concurrency and memory management.
  • Cons:
    • Less flexible for custom proxy patterns (compared to running Puma behind nginx).
    • Some advanced caching/proxy setups are easier with a dedicated reverse-proxy architecture.

Puma (common alternative)

  • Pros:
    • Lightweight, highly configurable; often used behind nginx as reverse proxy.
    • Works well in containerized environments (Docker/Kubernetes).
    • Easy to pair with systemd or process managers and to horizontally scale workers.
  • Cons:
    • Requires extra process management & reverse proxying (nginx proxy_pass) configuration.
    • Slightly more operational overhead vs Passenger.

For an API-only Rails app with static assets served by nginx, Passenger is a great choice when you want fewer moving pieces. Puma + nginx gives more flexibility if you need advanced proxy caching or plan to run in a container orchestration platform.

I’ll continue with Part 4 covering Redis caching (optional), invalidation strategies, testing, debugging, commands, examples of common pitfalls and a final checklist.


Unknown's avatar

Author: Abhilash

Hi, I’m Abhilash! A seasoned web developer with 15 years of experience specializing in Ruby and Ruby on Rails. Since 2010, I’ve built scalable, robust web applications and worked with frameworks like Angular, Sinatra, Laravel, Node.js, Vue and React. Passionate about clean, maintainable code and continuous learning, I share insights, tutorials, and experiences here. Let’s explore the ever-evolving world of web development together!

Leave a comment