Updated October 2026: Replaced
caches_page(removed from Rails core in 4.0) withCache-Controlheaders that Varnish honours, rewrote the VCL so it no longer caches pages with session cookies orprivateresponses, and added grace mode, PURGE/BAN invalidation and a note on TLS termination.
Page caching combined with Varnish can significantly boost the performance and scalability of web applications by serving cached pages directly from memory. In this article, we’ll let Rails decide which pages are safe to share, and use Varnish as a reverse proxy cache that stores and serves exactly those pages.
I. Understanding Page Caching and Varnish
Page Caching: Page caching involves storing the entire HTML output of a page and serving it directly from cache for subsequent requests. It eliminates the need to regenerate the page dynamically, resulting in faster response times and reduced server load.
Varnish: Varnish is an open-source, high-performance reverse proxy cache. It sits in front of web servers and caches HTTP responses, serving cached content to clients without hitting the backend server. Varnish is known for its ability to handle large volumes of traffic and improve the overall responsiveness of web applications.
Why not caches_page? An earlier version of this post used caches_page. That method was removed from Rails core in Rails 4.0 and now lives in the actionpack-page_caching gem, which writes each page as a static .html file under public/ for nginx or Apache to serve from disk. Behind Varnish those files add nothing: Varnish only sees HTTP responses and their headers. What Varnish needs from Rails is a clear Cache-Control header saying which responses may be shared.
II. Implementation Steps
1. Mark Public Pages in Rails
Rails responses are private by default, which tells Varnish not to store them. Opt in per action for pages that look the same for every visitor:
class PagesController < ApplicationController
def show
@page = Page.find_by!(slug: params[:slug])
# Cache-Control: max-age=3600, public, stale-while-revalidate=300
expires_in 1.hour, public: true, stale_while_revalidate: 5.minutes
# Adds ETag / Last-Modified so clients can revalidate with a cheap 304
fresh_when @page, public: true
end
end
Keep these actions free of per-user content and of anything that writes to the session (flash messages, a signed-in user’s name, or a CSRF token from csrf_meta_tags or form_with). A session write makes Rails send Set-Cookie, and Varnish’s built-in logic will not cache a response that sets a cookie. Part 3 covers how to keep forms working on such pages.
2. Install Varnish and Put TLS in Front of It
Install Varnish (the VCL below compiles on 7.x and 8.0) from your distribution or the official packages and point it at Rails (here Puma on port 3000). See the Varnish documentation for installation steps.
Open-source Varnish does not terminate TLS. Put a TLS proxy in front of it, such as nginx, Hitch or HAProxy:
browser --HTTPS--> nginx / Hitch / HAProxy --HTTP--> Varnish --HTTP--> Rails (Puma)
With a TLS proxy on the same host, every request reaches Varnish from 127.0.0.1. Hitch (write-proxy-v2 = on) and HAProxy can forward the real client address with the PROXY protocol, so client.ip in VCL is the visitor’s address again. That matters for the purge ACL below. varnishd accepts several -a listeners, so you can keep one for the TLS proxy and a plain HTTP one on the private network for purge requests:
varnishd -a :8443,PROXY -a 10.0.0.5:6081 -f /etc/varnish/default.vcl ...
3. Configure Varnish to Cache Safely
Edit the Varnish configuration file (/etc/varnish/default.vcl). The key idea is to add only what you need and then fall through to Varnish’s built-in VCL, which already does the safe thing: it never caches requests that carry Authorization or Cookie, and never stores responses with Set-Cookie or Cache-Control: private, no-cache or no-store.
vcl 4.1;
import std;
backend default {
.host = "127.0.0.1";
.port = "3000";
}
# Hosts allowed to invalidate the cache (app servers, deploy box).
# Do not list localhost if a TLS proxy on this host forwards visitors
# without the PROXY protocol: they would all appear as 127.0.0.1.
acl purgers {
"10.0.0.0"/8;
}
sub vcl_recv {
# Remove a single URL (all its Vary variants).
if (req.method == "PURGE") {
if (client.ip !~ purgers) {
return (synth(405, "Not allowed."));
}
return (purge);
}
# Invalidate every URL on this host matching the regex in X-Ban-Url,
# e.g. "X-Ban-Url: ^/pages/"
if (req.method == "BAN") {
if (client.ip !~ purgers) {
return (synth(405, "Not allowed."));
}
if (std.ban("obj.http.x-host == " + req.http.host +
" && obj.http.x-url ~ " + req.http.x-ban-url)) {
return (synth(200, "Ban added"));
}
return (synth(400, std.ban_error()));
}
# Paths that are identical for every visitor: drop the cookies so the
# built-in rule below doesn't send them straight to Rails.
if ((req.method == "GET" || req.method == "HEAD") &&
req.url ~ "^/(assets|pages)/") {
unset req.http.Cookie;
}
# No return here on purpose: the built-in vcl_recv runs next and passes
# (never caches) non-GET/HEAD requests and any request that still has
# an Authorization or Cookie header.
}
sub vcl_backend_response {
# Store URL and host on the object so bans can be evaluated by the
# background ban lurker.
set beresp.http.x-url = bereq.url;
set beresp.http.x-host = bereq.http.host;
# Keep serving the expired copy for up to 1 hour while Varnish fetches a
# fresh one in the background. Without this line Varnish uses the
# stale-while-revalidate value from Rails, or the default_grace parameter.
set beresp.grace = 1h;
# No return: the built-in code still refuses to store responses with a
# zero TTL, Set-Cookie, or Cache-Control private/no-cache/no-store, so
# the TTL comes from Rails' Cache-Control (max-age / s-maxage).
}
sub vcl_deliver {
unset resp.http.x-url;
unset resp.http.x-host;
}
What this configuration does:
- Respects Rails. A page is cached only if Rails sent
publicwith a positivemax-age(ors-maxage). Everything else, including every defaultprivate, must-revalidateresponse, goes to Rails. - Keeps sessions out of the cache. Requests with a session cookie or
Authorizationheader are passed, except on the paths you have explicitly declared public. Note that if analytics or other third-party cookies are sent on every page, they also make the built-in rule pass; strip those invcl_recvand keep only your session cookie. - Serves stale while revalidating.
beresp.gracelets visitors get the old page instantly while the new one is fetched. - Can be invalidated. Only hosts in the
purgersACL can sendPURGEorBAN.
4. Invalidate Pages When Content Changes
From an app server (inside the ACL), after a page is updated:
# Purge one URL. The Host header must match the one visitors use,
# because Varnish hashes the URL together with the host.
curl -X PURGE -H "Host: www.example.com" http://10.0.0.5:6081/pages/about
# Ban every page under /pages/
curl -X BAN -H "Host: www.example.com" -H "X-Ban-Url: ^/pages/" http://10.0.0.5:6081/
In Rails, send the same request from an after_commit callback or, better, a background job so a slow cache never slows down the save.
5. Reload Varnish and Verify
Check that the VCL compiles, then reload. With the packaged systemd unit, reload runs varnishreload, which loads the new VCL into the running Varnish, so cached objects survive; restart starts a new process with an empty in-memory cache.
sudo varnishd -C -f /etc/varnish/default.vcl > /dev/null && sudo systemctl reload varnish
Request a public page twice: the second request should not reach Rails (nothing new in the Rails log). varnishlog -g request -q 'ReqURL ~ "^/pages/"' shows whether each request was a hit, miss or pass, and why.
III. Conclusion
Varnish can serve whole Rails pages from memory, but the safe way to do it is to let Rails decide: mark shareable pages with Cache-Control: public (expires_in ... public: true), keep them free of session writes, and let Varnish’s built-in rules pass everything private. Add grace mode for smooth revalidation, a PURGE/BAN endpoint restricted by an ACL for invalidation, and a TLS proxy in front, and you get fast pages without ever serving one user’s page to another.
Public comments are closed, but I love hearing from readers. Feel free to contact me with your thoughts.