← Back to blog

Magento 2 Varnish Full Page Cache: Setup That Actually Hits

Rodrigo Biazzi

Putting Varnish in front of Magento 2 is easy. Getting it to actually serve most requests from cache is a different job. Plenty of stores run Varnish with a hit rate so low that almost every visitor still waits for PHP. This article covers how Magento's full page cache works with Varnish in 2.4.x, how to set it up from the generated VCL, how to measure the hit rate and the usual reasons it stays low.

How Magento full page cache works

Magento decides which pages are cacheable. Varnish stores them and serves them. The two talk through HTTP headers:

  • Cache-Control on the response says whether the page may be cached and for how long. Pages that must not be cached are sent with private or no-store values.
  • X-Magento-Tags lists the cache tags of everything on the page, such as cat_p_123 for a product or cat_c_45 for a category. Varnish keeps the header with the object and strips it before sending the response to the browser.
  • The X-Magento-Vary cookie holds a hash of the context that changes the page: customer group, currency, store and similar values. It is part of the cache key, so a logged in wholesale customer does not get the retail page.

When an entity changes, Magento sends a PURGE request to Varnish with an X-Magento-Tags-Pattern header. The VCL turns it into a ban on every object whose tags match. That is how a product save invalidates the product page, its categories and any page that lists it, without flushing the rest.

Customer specific content, such as the mini cart, the welcome message and the wishlist count, is never part of the cached HTML. It is loaded by JavaScript from customer/section/load and stored in the browser. That is the private content mechanism, and it is the reason a full page cache can work on a store with logins and carts at all.

Setting up Varnish from the generated VCL

Do not write a VCL from scratch and do not copy one from an old blog post. Generate it from your Magento version, because the core VCL changes between releases.

bin/magento config:set system/full_page_cache/caching_application 2
bin/magento config:set system/full_page_cache/ttl 86400

bin/magento varnish:vcl:generate \
    --export-version=6 \
    --backend-host=127.0.0.1 \
    --backend-port=8080 \
    --access-list=127.0.0.1 \
    --output-file=/etc/varnish/default.vcl

bin/magento setup:config:set --http-cache-hosts=127.0.0.1:6081
bin/magento cache:flush
  • caching_application set to 2 switches full page cache from the built in application cache to Varnish.
  • --export-version must match the major version of the Varnish you run. Check which versions your Magento release supports in the system requirements.
  • --access-list lists the hosts allowed to send PURGE. Keep it to your application servers.
  • --http-cache-hosts in app/etc/env.php tells Magento where to send purge requests. With several Varnish nodes, list them all separated by commas.

Things to check in the generated file

  • The health check probe points to /pub/health_check.php. If your web server document root is already pub/, which it should be, change the probe URL to /health_check.php. Otherwise Varnish marks the backend as sick.
  • If TLS terminates in front of Varnish, make sure the terminator sends X-Forwarded-Proto. The generated VCL uses it in the hash so HTTP and HTTPS versions do not mix.
  • Large category pages produce long X-Magento-Tags headers. If they exceed Varnish limits, the backend fetch fails with a 503. Adobe's documentation recommends raising the http_resp_hdr_len and http_resp_size parameters for this reason.

Keep your own changes in a small, commented section and regenerate the base file when you upgrade Magento. A diff between the old and new generated files shows what the core changed.

Measuring the hit rate

You cannot fix what you do not measure. Varnish gives you the numbers directly:

varnishstat -1 -f MAIN.cache_hit -f MAIN.cache_miss -f MAIN.cache_hitpass -f MAIN.s_pass

varnishncsa -F '%U%q %{Varnish:hitmiss}x %{Varnish:handling}x'

The first command gives totals since Varnish started. The second streams each request with whether it was a hit, miss or pass. Leave it running for a few minutes during normal traffic and sort the misses and passes by URL. Patterns appear quickly: the same URL with different query strings, a whole page type that always passes, or POST requests you did not expect.

In developer mode Magento adds an X-Magento-Cache-Debug header with HIT or MISS, which is handy on staging. On production use varnishlog for a single URL:

varnishlog -g request -q 'ReqURL ~ "^/women/tops.html"'

Look at VCL_call and VCL_return lines to see whether the request went to hash, pass or pipe, and at the backend response headers to see what Magento sent.

Why the hit rate stays low

cacheable="false" in layout

This is the classic trap. One block marked cacheable="false" in a layout file makes the whole page uncacheable, not just that block. If it is in the default handle, the whole storefront is uncacheable.

<referenceContainer name="sidebar.additional">
    <block class="Acme\Promo\Block\Countdown" name="acme.promo.countdown"
           template="Acme_Promo::countdown.phtml" cacheable="false"/>
</referenceContainer>

Extension authors add it when a block shows dynamic or customer data, because it makes the block correct in testing. Find every instance:

grep -rn 'cacheable="false"' app/code app/design vendor --include=*.xml | grep -v "/adminhtml/"

Pages like cart, checkout and customer account are meant to be uncacheable. Anything on default, catalog_category_view, catalog_product_view or cms_index_index deserves a closer look. The fix is to render the dynamic part through private content or an AJAX call and remove the flag.

Query strings that fragment the cache

Each unique URL is a separate cache object. Marketing links add parameters like utm_source or gclid, and without handling every campaign click is a miss. The generated VCL already strips a list of common marketing parameters before hashing. Check that list against the parameters your traffic really uses and extend it if needed. Parameters that change the page, such as layered navigation filters, sorting and pagination, must stay.

Too many contexts

Everything that goes into X-Magento-Vary multiplies the number of versions of each page. Many customer groups, several currencies and custom context values added by extensions through Magento\Framework\App\Http\Context can spread the traffic over so many variants that few of them are ever warm. Review what is added to the HTTP context and keep only what really changes the HTML.

Cookies and sessions on cacheable pages

If a custom module sets a cookie or writes to the session while rendering a cacheable page, it either breaks caching or, worse, leaks one visitor's state into the cached HTML. Session data has no place in a block that renders on a cached page.

Purges that are too broad

A frequent full purge empties the cache over and over. Common causes are a cron job or import that calls cache:flush or cleans the full_page type, an ERP sync that saves every product on each run even when nothing changed, and stock updates for products shown on every page. Log purge requests in Varnish for a day and check what triggers them.

varnishlog -g request -q 'ReqMethod eq "PURGE"' -i ReqHeader

Private content done right

When a block needs to show something customer specific on a cached page, use customer data sections instead of disabling cache. A section is a PHP class that implements Magento\Customer\CustomerData\SectionSourceInterface and returns an array. It is registered in di.xml and read in JavaScript through the Magento_Customer/js/customer-data module in Luma, or the equivalent event in Hyvä.

To refresh a section after an action, declare it in etc/frontend/sections.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Customer:etc/sections.xsd">
    <action name="acme_promo/coupon/apply">
        <section name="cart"/>
        <section name="acme-promo"/>
    </action>
</config>

After a POST to that action, the browser reloads those sections. The HTML stays cached and shared, and only a small JSON response is personal.

Hole punching with ESI

Some content is the same for everyone but changes more often than the page around it. For that, Magento supports Edge Side Includes. A block with a ttl attribute in layout is rendered as an ESI include when Varnish is the cache application, and Varnish fetches and caches it separately with its own lifetime.

<block class="Acme\Promo\Block\Banner" name="acme.promo.banner"
       template="Acme_Promo::banner.phtml" ttl="600"/>

Use this sparingly. Every ESI block is another backend request when it expires, and ESI is the wrong tool for customer specific content, which belongs in private content.

Grace and warm up

The grace period lets Varnish serve a slightly stale object while it fetches a fresh one in the background, and keeps the site up if the backend is briefly sick. The generated VCL supports it and the --grace-period option sets the value. After a deploy or a full flush the cache is empty, so the first visitors pay for the uncached render. A simple warm up that requests the home page, top categories and best selling products from the sitemap reduces that hit. Keep it slow enough not to overload the backend you just deployed.

FAQ

Does one cacheable=false block disable cache for the whole page?

Yes. If any block in the page layout is marked cacheable=false, Magento sends the whole page as uncacheable. Put it in the default handle and no storefront page is cached.

How do I check if a Magento page is a Varnish hit?

In developer mode Magento adds an X-Magento-Cache-Debug header with HIT or MISS. On production use varnishlog for the URL or varnishncsa with the Varnish:hitmiss field to see how each request was handled.

How does Magento purge Varnish when a product changes?

Magento sends a PURGE request with an X-Magento-Tags-Pattern header listing the affected cache tags. The generated VCL turns it into a ban on all cached objects carrying those tags in X-Magento-Tags.

Can I cache pages for logged in customers?

Yes. Logged in customers get cached pages per context, mainly customer group, and the personal parts such as cart and name come from customer data sections loaded by JavaScript.

I am available for freelance Magento and Adobe Commerce work, from module development and performance audits to migrations and Hyvä builds. If that sounds useful, get in touch.

Related articles