Updated October 2026: Corrected the “memory caching” description (
proxy_cache_pathis an on-disk cache whose keys live in shared memory), fixed the unsafe$uricache key, noted thatcaches_pagenow needs theactionpack-page_cachinggem, and addedtry_filesfor Rails page-cache files plus session bypass, cache locking and stale serving.
Combining page caching in Rails with nginx’s caching capabilities can greatly enhance the performance and scalability of web applications. In this guide, we’ll have Rails write cached pages as static files that nginx serves directly, and configure nginx’s proxy cache for the rest of the public responses.
A note on the title: nginx’s proxy cache is not a pure memory cache. The cached responses are stored as files on disk; only the cache keys and metadata live in a shared memory zone. That design is what makes it fast and still able to hold far more data than RAM.
I. Understanding Page Caching and the nginx Proxy Cache
Page Caching: Page caching involves storing the HTML output of a page and serving it directly from cache for subsequent requests. It reduces server load and improves response times by avoiding the need to regenerate the page dynamically.
nginx Proxy Cache: When nginx proxies to an application, proxy_cache can store the responses. Each response is written to a file whose name is the MD5 of the cache key, and all active keys plus information about the cached data are kept in a shared memory zone (keys_zone, about 8,000 keys per megabyte). Matching requests are then answered without hitting the backend.
II. Implementation Steps
1. Configure Page Caching in Rails
caches_page was removed from Rails core in Rails 4.0. It now lives in the actionpack-page_caching gem:
# Gemfile
gem "actionpack-page_caching"
class PagesController < ApplicationController
caches_page :index, :show
end
This instructs Rails to save the output of the index and show actions as .html files under public/ (for example /pages/about becomes public/pages/about.html). Rails does not expire these files on its own; delete them with expire_page when the content changes:
expire_page action: "show", id: @page.id
Only use caches_page for pages that are identical for every visitor (see part 3 for forms and CSRF tokens on such pages).
2. Serve Rails Page-Cache Files with try_files
The files Rails writes are plain static files, so nginx should serve them before even thinking about Rails. Only fall back to the app when no file exists:
server {
listen 80;
server_name example.com;
root /var/www/myapp/current/public;
location / {
# Static files only make sense for GET/HEAD; send everything else to Rails.
error_page 418 = @rails;
if ($request_method !~ ^(GET|HEAD)$) {
return 418;
}
# Page-cache file for "/" or "/pages/about", any other static file, then Rails.
try_files $uri/index.html $uri.html $uri @rails;
}
location @rails {
proxy_pass http://backend;
# ... see the next step
}
}
The method check matters: without it, a POST /pages would hit public/pages.html and nginx would answer 405 Not Allowed instead of passing the form to Rails.
3. Configure the nginx Proxy Cache
Page-cache files cover the pages you list in caches_page. For other responses that Rails marks as public (expires_in 10.minutes, public: true), add a proxy cache in front of the app. Put this in the http context (for example /etc/nginx/conf.d/myapp.conf, which the default nginx.conf includes inside http {}):
upstream backend {
server 127.0.0.1:3000; # Puma
keepalive 16;
}
# Responses on disk under /var/cache/nginx/myapp, keys in a 10 MB shared zone
# (about 80,000 keys). Entries unused for 60 minutes are removed.
proxy_cache_path /var/cache/nginx/myapp levels=1:2 keys_zone=myapp_cache:10m
max_size=1g inactive=60m use_temp_path=off;
server {
listen 80;
server_name example.com;
root /var/www/myapp/current/public;
location / {
error_page 418 = @rails;
if ($request_method !~ ^(GET|HEAD)$) {
return 418;
}
try_files $uri/index.html $uri.html $uri @rails;
}
location @rails {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache myapp_cache;
# Include scheme, host and the full URI with query string.
proxy_cache_key $scheme$host$request_uri;
# Fallback TTLs, used only when Rails sends no Cache-Control/Expires.
proxy_cache_valid 200 301 302 10m;
proxy_cache_valid 404 1m;
# Signed-in users (session cookie) and API clients skip the cache
# completely: don't serve them a cached page, don't store theirs.
# Rails names the cookie _<app_name>_session.
proxy_cache_bypass $cookie__myapp_session $http_authorization;
proxy_no_cache $cookie__myapp_session $http_authorization;
# On a miss, let one request fill the cache while the others wait.
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# Serve the stale copy while it is refreshed in the background,
# or while Rails is failing.
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
add_header X-Cache-Status $upstream_cache_status always;
}
}
What each part does:
proxy_cache_key $scheme$host$request_uri: the earlier version of this post used$uri, which drops the query string and the host./search?q=aand/search?q=bwould then share one cache entry, and so would two sites served by the same nginx.$request_urikeeps the query string;$hostseparates virtual hosts.- Rails decides what is cacheable. Headers from the backend take priority over
proxy_cache_valid: nginx never stores a response withCache-Control: private,no-cacheorno-store(Rails’ default for every action isprivate), and never stores one that has aSet-Cookieheader. Only actions that callexpires_in ..., public: trueend up in the cache. proxy_cache_bypass/proxy_no_cache: any non-empty value that is not"0"disables the cache for that request. Using the session cookie keeps per-user pages out of it, even if an action forgot to mark itselfprivate.proxy_cache_lock: prevents a stampede, where hundreds of requests for a just-expired page all hit Rails at once.proxy_cache_use_stale+proxy_cache_background_update: visitors get the old copy immediately while nginx refreshes it, and still get a page when Rails is down. nginx also honoursstale-while-revalidateandstale-if-errorsent by Rails.X-Cache-Status: showsHIT,MISS,BYPASS,EXPIRED,STALE,UPDATINGorREVALIDATED, which makes debugging much easier.
4. Test and Reload nginx
Check the configuration and reload it without dropping connections:
sudo nginx -t && sudo systemctl reload nginx
Then request a public page twice with curl -sI http://example.com/some/page and look for X-Cache-Status: MISS followed by HIT. To clear the whole proxy cache, delete the contents of /var/cache/nginx/myapp; nginx’s own proxy_cache_purge directive is only available in the commercial NGINX Plus.
III. Conclusion
Rails page caching and nginx complement each other: Rails writes the HTML of fully public pages to public/, and try_files lets nginx serve those files without ever starting a Ruby request. For everything else that Rails marks as public, nginx’s proxy cache (files on disk, keys in shared memory) stores the responses under a key that includes host and query string, skips users with a session cookie, lets one request refill an expired entry, and serves stale copies while it does. The result is fewer requests reaching Rails, faster responses, and no risk of one user seeing another user’s page.
Public comments are closed, but I love hearing from readers. Feel free to contact me with your thoughts.