yum upgrades for production use, this is the repository for you.
Active subscription is required.
Technical Briefing: NGINX ESI as a Varnish Replacement for Magento 2
The Problem
Running Magento 2 behind Varnish means operating a second proxy, a second configuration language (VCL), and a second cache lifecycle in the request path — all sitting between NGINX and the application. The question this work addresses is whether NGINX can own the entire edge path itself: proxy, cache, TLS, and ESI fragment assembly, without Varnish.
The Implementation
nginx-module-esi was built for exactly this purpose and benchmarked against Varnish Cache OSS rather than assumed faster. It ships in GetPageSpeed Pro for supported NGINX stable and mainline packages on RPM and Debian-family systems. Canonical documentation covers installation, every directive, Magento configuration, gzip safety, limitations, and rollback.
How It Works
- An origin caches the stable shell of a page for hours while marking volatile blocks with
<esi:include src="/fragments/stock/42" />. NGINX serves the cached shell, fetches the fragment as an internal subrequest, and replaces the tag before sending the response. - Shell and fragments can use different
proxy_cachezones and different TTLs. Includes at the same nesting level run concurrently. esi_planmemoizes the parsed structure of a cached object for higher throughput.esi_stitchstores compressed runs of the stable shell and compresses only live fragments on delivery, avoiding inflate/recompress of a large cached page on every hit.
Supported ESI Subset
<esi:include>withsrc,alt, andonerror="continue";<esi:remove>;<esi:comment>removal;<esi:vars>tag stripping while preserving content.- Unknown ESI tags pass through with a warning.
esi:choose,esi:when,esi:otherwise, and ESI variable substitution are not implemented. Neither Varnish Cache OSS nor this module implements the whole ESI 1.0 language — test applications using more than includes before migrating.
Magento Configuration Details
Adobe Commerce’s generated Varnish 7 config enables ESI for text responses, enables gzip for text, and handles a PURGE carrying X-Magento-Tags-Pattern by banning cached objects whose X-Magento-Tags metadata matches (see Magento’s current varnish7.vcl).
Magento emits <esi:include> only when full-page cache is enabled, the page is cacheable, a block has a ttl, and the cache application is set to Varnish. Keep that setting when NGINX replaces Varnish:
sudo -u www-data php bin/magento config:set \
system/full_page_cache/caching_application 2
sudo -u www-data php bin/magento cache:flush config full_page
Magento 2.4.8-p5 does not condition ESI generation on the Surrogate-Capability request header — its ProcessLayoutRenderElement observer checks the full-page-cache type. Still advertise the capability as the correct negotiation signal for other surrogate-aware middleware:
proxy_set_header Surrogate-Capability \
'nginx="Surrogate/1.0 ESI/1.0 tags/1"';
Magento generates absolute HTTP include URLs even on HTTPS storefronts. NGINX ESI 1.0.1 recognizes same-authority absolute and scheme-relative URLs, safely reduces them to local subrequests, and rejects remote authorities and unsafe encoded traversal.
Installation
RPM:
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-esi nginx-module-cache-purge
Load both dynamic modules near the top of /etc/nginx/nginx.conf, before events:
load_module modules/ngx_http_esi_filter_module.so;
load_module modules/ngx_http_cache_purge_module.so;
Debian-family: enable the repository via the NGINX Extras APT setup, then:
sudo apt-get update
sudo apt-get install nginx-module-esi nginx-module-cache-purge
Debian packages load their dynamic modules automatically. RPM and APT module indexes track available builds.
Reference Cache Configuration
Magento origin listens on 127.0.0.1:8080; edge NGINX owns port 80 or 443. Keep normal PHP and static-file locations on the origin. At the edge, serve /static/ and /media/ directly from the shared Magento filesystem rather than proxying through PHP.
Zones and maps in the http context:
proxy_cache_path /var/cache/nginx/magento levels=1:2
keys_zone=magento_cache:64m max_size=4g inactive=1d
use_temp_path=off;
esi_plan_zone magento_esi_plans:64m;
map $request_method $magento_skip_method {
default 1;
GET 0;
HEAD 0;
PURGE 0;
}
map $uri $magento_skip_path {
default 0;
~^/(?:customer|checkout)(?:/|$) 1;
~^/(?:pub/)?health_check\.php$ 1;
~^/graphql(?:/|$) 1;
}
map $args $magento_skip_query {
default 1;
"" 0;
}
map $http_authorization $magento_skip_auth {
default 1;
"" 0;
}
map "$magento_skip_method:$magento_skip_path:$magento_skip_query:$magento_skip_auth"
$magento_bypass {
default 0;
~1 1;
}
map $http_cookie $magento_vary {
default "";
"~*(?:^|;\s*)X-Magento-Vary=([^;]*)" $1;
}
Edge server locations — the named location keeps personalized/unsafe requests out of the full-page cache:
location ^~ /page_cache/block/esi/ {
internal;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Accept-Encoding "";
proxy_set_header Surrogate-Capability
'nginx="Surrogate/1.0 ESI/1.0 tags/1"';
proxy_cache magento_cache;
proxy_cache_key "fragment:$scheme:$host:$uri$is_args$args:$magento_vary";
proxy_cache_valid 200 5s;
proxy_cache_lock on;
proxy_ignore_headers Set-Cookie;
proxy_hide_header Set-Cookie;
esi on;
}
location / {
if ($magento_bypass) { return 418; }
error_page 418 = @magento_pass;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Accept-Encoding "";
proxy_set_header Surrogate-Capability
'nginx="Surrogate/1.0 ESI/1.0 tags/1"';
proxy_cache magento_cache;
proxy_cache_key "page:$scheme:$host:$request_uri:$magento_vary";
proxy_cache_valid 200 24h;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504;
proxy_ignore_headers Set-Cookie;
proxy_hide_header Set-Cookie;
proxy_hide_header X-Magento-Tags;
proxy_cache_purge PURGE from 127.0.0.1;
cache_purge_tags X-Magento-Tags X-Magento-Tags-Pattern;
esi on;
esi_plan magento_esi_plans;
esi_stitch on;
add_header X-Magento-Cache-Debug
"NGINX-$upstream_cache_status" always;
}
location @magento_pass {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Accept-Encoding "";
proxy_set_header Surrogate-Capability
'nginx="Surrogate/1.0 ESI/1.0 tags/1"';
add_header X-Magento-Cache-Debug "NGINX-BYPASS" always;
}
Key configuration notes:
- The fragment cache key deliberately uses
$uri$is_args$args— inside an NGINX subrequest,$request_uristill belongs to the main request and would collapse different ESI fragments onto one cache key. proxy_ignore_headers Set-Cookieis appropriate only for Magentottlblocks designed as public fragments. Do not put customer names, carts, account data, or other private output in a cacheable ESI block; Magento’s customer sections are normally rendered client-side for this reason.
Validation and Testing
Reload: sudo nginx -t then sudo systemctl reload nginx.
Verify Magento produces ESI markup at the origin while the NGINX ESI edge returns rendered fragments:
curl -sS -H 'Host: shop.example.com' \
http://127.0.0.1:8080/product.html | grep -o '<esi:include[^>]*>'
curl -sS -H 'Host: shop.example.com' \
http://127.0.0.1/product.html | grep '<esi:include'
The first should show tags; the second should print nothing.
Prime the page twice and inspect the debug header — expect NGINX-MISS, then NGINX-HIT:
curl -sSI -H 'Host: shop.example.com' http://127.0.0.1/product.html \
| grep -i x-magento-cache-debug
curl -sSI -H 'Host: shop.example.com' http://127.0.0.1/product.html \
| grep -i x-magento-cache-debug
For gzip, use curl’s decoder and verify the body still contains rendered fragment output:
curl --compressed -sS -D /tmp/headers \
-H 'Host: shop.example.com' -H 'Accept-Encoding: gzip' \
http://127.0.0.1/product.html -o /tmp/product.html
grep -i '^content-encoding: gzip' /tmp/headers
grep '<esi:include' /tmp/product.html
The last command must have no output.
Selective Invalidation
cache_purge_tags gives ngx_cache_purge the missing tag index behavior. On a PURGE, it scans cache metadata, matches X-Magento-Tags-Pattern against stored X-Magento-Tags, and removes matching files. It does no scan on ordinary traffic.
Verified with Magento’s real invalidation path: Magento loaded product ID 36, resolved tags cat_p_36 and cat_p, and dispatched its clean_cache_by_tags event through the normal page-cache observer and purge transport.
Control was Magento’s cookie-policy CMS page carrying unrelated CMS tags. A full bin/magento cache:clean full_page also produced MISS then HIT on both arms. A PURGE sent from outside the host was denied by both proxies: Varnish returned 405, NGINX returned 403.
This yields selective invalidation rather than the conservative full-cache flush older NGINX Magento recipes settle for. Untagged fragment objects also survive Magento’s .* full-page tag purge, matching the behavior intended by Magento’s generated VCL.
Benchmark Results
Tested against Varnish Cache OSS 7.6.5 on a dedicated eight-vCPU Linode, each proxy pinned to one core, caches warm, byte-identical output before timing, A/A control showing 0.27% median drift.
- With a 256 KB cached shell, gzip, and one fragment: NGINX delivered 8,877 req/s vs Varnish’s 3,915. CPU time was 112 ms per 1,000 requests vs 256 ms. Across the 24-row matrix, NGINX used two to six times less CPU per request and Varnish won no row.
- The larger latency difference appears with several uncached or expired fragments, due to concurrency: Varnish Cache OSS resolves ESI includes sequentially. Varnish Enterprise provides parallel ESI, so do not project these multi-fragment results onto the Enterprise product. For those preferring to keep Varnish, parallel ESI for Varnish 6.0 LTS is now packaged as
vmod-pesiin the same repository, closing much of the multi-fragment gap on the Varnish side. - No magic win when there is no parallel work: one-fragment rows with 20 ms and 50 ms origin delay were a dead heat, 2.5% and 1.1% apart against a roughly 0.1% floor.
- Shell size matters: before stitching, NGINX beat Varnish by 18% at 16 KB, lost by 62% at 64 KB, and lost by 90% at 256 KB.
esi_stitchcloses that large-shell gap. It costs a second representation inesi_plan_zoneand about 12% at compression-level segment boundaries. In the measured case Varnish paid the same boundary cost and both produced an identical 1,030-byte response.
Cloned Magento store test: two identical Ubuntu 24.04 Linodes with Magento Open Source 2.4.8-p5, same database and application, 2,040 sample products, eight five-second ESI probe blocks. One edge ran Varnish 8.0.2 with Magento’s generated VCL; the other ran NGINX ESI 1.0.1 and cache-purge 2.6.0 built for NGINX 1.30.4.
Across 12 rounds with the page shell cached and all eight fragments cold: Varnish median assembly time 1.001 s, NGINX median 0.411 s — 2.43 times faster, 59% lower. Both returned eight rendered fragments and no raw ESI markup.
Treating Varnish as an output oracle caught a silent gzip corruption bug: a gzipped fragment could be inserted as raw deflate into an already-decoded page, producing a 200 response with a compressed hole. Version 1.0.1 contains the fix and a dedicated regression test.
The rerun disproved the first explanation for slower large-shell rows: the real limit was output_buffers, which capped fragment concurrency when stitching was disabled. Moving from output_buffers 2 32k to 4 512k more than doubled throughput and halved p99 in that workload. With esi_stitch on, performance no longer depended on that tuning.
Operational Caveats
- Keep the fragment location
internal— browsers should not invoke it directly through the edge. - Restrict
PURGEto loopback or a tightly controlled management network. Never usefrom allon a public server. - Include
X-Magento-Varyin page and fragment keys so store and design variants cannot collide. - Set
proxy_set_header Accept-Encoding ""as shown. If the origin must send gzip, enable NGINXgunzipbefore ESI processing. - Confirm full-page cache is enabled,
caching_applicationis2, the page is cacheable, and the block has attl. TheSurrogate-Capabilityheader alone does not switch Magento into ESI mode. - Confirm
esi onapplies to the final response location and that its content type is inesi_types. Runnginx -Tto inspect the assembled configuration rather than only the file you edited. - Do not use
$request_uriin the fragment cache key — use$uri$is_args$args, because$request_uricontinues to describe the parent request during an ESI subrequest. - Magento pages can carry a large
X-Magento-Tagsresponse header. Increaseproxy_buffer_sizeandproxy_buffers, e.g.proxy_buffer_size 16k; proxy_buffers 8 16k;, then retest with a tag-heavy category page. - Make the upstream response uncompressed or use
gunzip on. The module deliberately refuses to splice live bytes into a raw gzip stream when it cannot prove the representation is safe. - Enable
esi_planandesi_stitch. Without stitching, largeroutput_bufferscan be necessary to keep more fragment subrequests in flight. Measure before changing buffer sizes because larger per-request buffers consume more memory under concurrency.
Bottom Line
NGINX ESI covers the practical Magento path: same-authority absolute includes, concurrent fragment assembly, gzip-safe stitching, independently cached fragments, and selective X-Magento-Tags invalidation — reducing the stack without giving up Magento’s real purge semantics.
Limits are explicit: it implements the include-focused ESI subset, stores a second compressed representation when stitching is enabled, and the strongest multi-fragment comparison is against sequential ESI in Varnish Cache OSS. Within that boundary, both the synthetic matrix and the cloned Magento store support the same conclusion: NGINX can own the complete edge path.
nginx-module-esi and nginx-module-cache-purge install from the maintained NGINX package repositories via GetPageSpeed Pro.
For configuration-regression visibility, GetPageSpeed Amplify runs scheduled gixy scans across every host and ties findings to live NGINX runtime metrics. It is drop-in compatible with the deprecated nginx-amplify-agent (EOL January 2026).
Read the full article: NGINX ESI: Replace Varnish for Magento 2
