Zum Inhalt

Native ESI for NGINX

nginx-module-esi assembles a cached page from independently cached fragments inside NGINX. It removes the usual need for a separate Varnish tier when an application depends on Edge Side Includes.

The module resolves sibling includes as parallel NGINX subrequests, accepts Magento's same-authority absolute ESI URLs, processes gzipped parent responses safely, and can reuse both parsed ESI plans and compressed page-shell segments. Pair it with nginx-module-cache-purge to preserve Magento 2 X-Magento-Tags invalidation.

The maintained RPM and DEB packages are part of GetPageSpeed Pro. Read the measured Magento and Varnish comparison for the test method, full configuration, and performance boundary.

Supported ESI markup

Markup Behavior
<esi:include src="/uri" /> Replaced by a local subrequest. Same-authority absolute and scheme-relative URLs are accepted.
<esi:include src="..." alt="/fallback" /> Fetches the fallback after a 4xx or 5xx fragment.
<esi:include src="..." onerror="continue" /> Suppresses a failed fragment.
<esi:remove>...</esi:remove> Removes the element and its content.
<!--esi ... --> Removes the wrapper and processes the enclosed ESI.
<esi:comment /> Removes the ESI comment.
<esi:vars>...</esi:vars> Removes the wrapper and preserves its content without variable substitution.
Other <esi:*> tags Pass through with a warning.

Nested includes are supported with recursion protection. esi:choose, esi:when, esi:otherwise, and ESI variable substitution are not implemented.

Install

RHEL, Rocky Linux, AlmaLinux, and compatible systems

sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-esi

Load the dynamic module near the top of /etc/nginx/nginx.conf, before events:

load_module modules/ngx_http_esi_filter_module.so;

One shared object registers both the ESI parser filter and the gzip stitch filter. Do not add a second load_module line for stitching.

Debian and Ubuntu

First set up the GetPageSpeed APT repository, then install:

sudo apt-get update
sudo apt-get install nginx-module-esi

The Debian package enables module loading automatically. No load_module directive is needed.

Validate the installed configuration before reloading NGINX:

sudo nginx -t
sudo systemctl reload nginx

Directive reference

Directive Context Default Purpose
esi on or esi off http, server, location, location if off Enables ESI response-body processing.
esi_silent_errors on or off http, server, location off Suppresses fragment error bodies. Prefer explicit onerror="continue" when the markup is under your control.
esi_buffer_size size http, server, location one OS page Sets the streaming parser input buffer size. It is not a whole-response buffer.
esi_types mime-type ... http, server, location text/html Selects response MIME types eligible for ESI processing.
esi_plan_zone name:size http none Allocates shared memory for cached parse plans and optional compressed shell segments. The minimum is eight OS pages.
esi_plan zone or esi_plan off http, server, location off Reuses the parsed structure of a cached object.
esi_stitch on or esi_stitch off http, server, location off Stores compressed stable runs and stitches them with live fragments. Requires esi_plan.
esi_stitch_level 1..9 http, server, location 6 Sets the one-time compression level for stored shell segments.

esi_plan_zone belongs directly in http. All other directives inherit through the usual http to server to location hierarchy.

Minimal production configuration

This example caches a page shell for one hour and its fragments for five seconds. The origin listens on 127.0.0.1:8080.

proxy_cache_path /var/cache/nginx/esi
    keys_zone=esi_cache:32m max_size=2g inactive=1h
    use_temp_path=off;

esi_plan_zone esi_plans:32m;

server {
    listen 80;
    server_name shop.example.com;

    location ^~ /fragments/ {
        internal;
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header Accept-Encoding "";

        proxy_cache esi_cache;
        proxy_cache_key "fragment:$scheme:$host:$uri$is_args$args";
        proxy_cache_valid 200 5s;
        proxy_cache_lock on;

        esi on;
    }

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header Accept-Encoding "";

        proxy_cache esi_cache;
        proxy_cache_key "page:$scheme:$host:$request_uri";
        proxy_cache_valid 200 1h;
        proxy_cache_lock on;

        esi on;
        esi_plan esi_plans;
        esi_stitch on;

        add_header X-Cache-Status $upstream_cache_status always;
    }
}

Use $uri$is_args$args for fragment cache keys. During an ESI subrequest, $request_uri still describes the parent request and can collapse different fragments onto one cache key.

Keep personalized output out of public fragment caches. Do not cache customer names, carts, account data, checkout output, or authenticated GraphQL responses unless you have designed explicit private-cache boundaries and keys.

Magento 2 configuration

Magento emits ESI markup 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 Magento mode 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 can generate absolute HTTP fragment URLs even when the storefront uses HTTPS. The module reduces same-authority absolute and scheme-relative URLs to local subrequests. Remote authorities and unsafe encoded traversal fail closed.

Advertise surrogate support to Magento and other ESI-aware middleware:

proxy_set_header Surrogate-Capability \
    'nginx="Surrogate/1.0 ESI/1.0 tags/1"';

Use Magento's ESI endpoint as the independently cached fragment location:

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_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;
}

Only ignore Set-Cookie for Magento blocks you have deliberately made public. Include Magento's X-Magento-Vary value in both page and fragment keys so store and design variants cannot collide.

The complete Magento 2 configuration also excludes customer, cart, checkout, authenticated, GraphQL, and query-string traffic from the anonymous page cache.

Pair with Magento cache-tag purge

Install the Pro cache-purge module alongside ESI:

sudo dnf install nginx-module-cache-purge

On Debian and Ubuntu, use sudo apt-get install nginx-module-cache-purge instead. Then load ngx_http_cache_purge_module.so on RPM systems and add these directives to the cached page location:

proxy_cache_purge PURGE from 127.0.0.1;
cache_purge_tags X-Magento-Tags X-Magento-Tags-Pattern;
proxy_hide_header X-Magento-Tags;

This matches Magento's purge regex against cached X-Magento-Tags metadata and removes only related objects. Restrict PURGE to loopback or a trusted management network. Never expose it with from all.

Gzip safety

Gzipped parent responses are decoded before ESI parsing and recompressed normally. A gzipped fragment is different: it enters through a subrequest after the parent has been decoded. Give fragment locations one of these safeguards:

location /fragments/ {
    gunzip on;
    proxy_pass http://backend;
}

Or ask the fragment origin for plaintext:

location /fragments/ {
    proxy_set_header Accept-Encoding "";
    proxy_pass http://backend;
}

With neither safeguard, the module refuses the unsafe response instead of serving a 200 response with compressed bytes embedded in plaintext.

esi_stitch is a delivery optimization for cached shells. It stores independently decompressible runs in esi_plan_zone, compresses only live fragments, and folds the segment checksums into one valid gzip trailer. Size the zone for compressed shell bytes, not only parse records.

Verify the result

The origin should expose ESI markup while the edge response should contain rendered fragment output:

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 command should show the include. The second should print nothing. Prime the edge twice and check the cache header:

curl -sSI -H 'Host: shop.example.com' http://127.0.0.1/product.html \
    | grep -i x-cache-status
curl -sSI -H 'Host: shop.example.com' http://127.0.0.1/product.html \
    | grep -i x-cache-status

Expect MISS, then HIT.

Limitations

  • The module implements the include-focused ESI subset, not the full ESI 1.0 language.
  • Open-source Varnish comparisons do not describe Varnish Enterprise, which provides parallel ESI.
  • esi_plan stores parse metadata beside NGINX's cache rather than inside its cache-file format.
  • esi_stitch stores a second compressed representation of stable page runs and therefore uses more shared memory.
  • More fragments still mean more NGINX subrequests. Parallel fetching removes serial origin delay but not origin work.
  • Cross-authority includes are rejected. Use an explicit NGINX location if a remote fragment source must be proxied under the local authority.

Roll back

  1. Restore the previous upstream path or Varnish route before changing package state.
  2. Remove esi, esi_plan, esi_plan_zone, and esi_stitch directives from the active configuration.
  3. Remove the RPM load_module line.
  4. Run sudo nginx -t and reload NGINX.
  5. Remove the package with sudo dnf remove nginx-module-esi or sudo apt-get remove nginx-module-esi.

Removing the package before the load_module line makes the next configuration test fail because the shared object is missing.

Benchmark provenance

The NGINX ESI and Magento 2 benchmark records the hardware, versions, cache state, byte-for-byte output validation, A/A drift control, 24-row synthetic matrix, and cloned Magento Open Source 2.4.8-p5 comparison.

The strongest multi-fragment result is explained by concurrency: Varnish Cache OSS resolves ESI includes sequentially, while this module issues sibling subrequests in parallel. Varnish Enterprise parallel ESI is a different product and is not covered by that comparison.

For the tested Magento store, eight cold fragments produced median page assembly of 0.411 seconds through NGINX versus 1.001 seconds through Varnish 8.0.2, a 59% reduction. Both paths returned eight rendered fragments and honored Magento's real cache-tag purge events.

Continue with the production Magento configuration and test evidence, or get GetPageSpeed Pro to install the maintained packages.