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
rootare 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:
- Browser requests
https://www.mydomain.com/some/path(or/vite/index-ABC.js, or/api/v1/products). - 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.
- Passenger receives the request and dispatches it to a Rails process.
- Rails API processes the request (controllers -> models -> DB) and produces a response JSON or status.
- 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-Controlreturned from server. - Reverse-proxy or CDN — e.g., Cloudflare, Fastly, CloudFront; caching behavior influenced by
s-maxageand 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-Matchon subsequent requests. Server returns304 Not Modifiedif ETag matches.
- Strong validator; server generates a value representing resource state. Client includes
Last-ModifiedandIf-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-EncodingorVary: Authorization).
- Tells caches that responses vary by certain request headers (e.g.,
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
privateorno-store. - Prefer CDN caching (s-maxage) for public endpoints. Use
s-maxageto 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_cachefor caching upstream responses, but that pattern is more common when you proxy to a separate Puma/Unicorn backend viaproxy_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_cachepatterns 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 minutess-maxage=600→ CDN caches for 10 minutesstale-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
systemdor process managers and to horizontally scale workers.
- Cons:
- Requires extra process management & reverse proxying (nginx
proxy_pass) configuration. - Slightly more operational overhead vs Passenger.
- Requires extra process management & reverse proxying (nginx
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.