Non-FastCGI host + Apache mod_deflate: Grav's shutdown early-close emits Content-Encoding: identity while mod_deflate adds gzip -> two conflicting headers -> browsers render raw gzip bytes. Fix: debugger.shutdown.close_ connection:false in the prod env override. Documents the dead-end (cache.gzip/allow_webserver_gzip take the same code path) and the browser-style curl + screenshot verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
8.8 KiB
title, date, category, module, problem_type, component, severity, symptoms, root_cause, resolution_type, related_components, tags
| title | date | category | module | problem_type | component | severity | symptoms | root_cause | resolution_type | related_components | tags | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Grav garbage page from two conflicting Content-Encoding headers (identity + gzip) | 2026-07-04 | docs/solutions/integration-issues | grav / production deploy | integration_issue | tooling | high |
|
config_error | config_change |
|
|
Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)
Problem
Grav renders every dynamic page as binary garbage in the browser because it emits two conflicting Content-Encoding headers. The response body is valid gzip, but because the server advertises both content-encoding: identity and content-encoding: gzip, the browser cannot decide how (or whether) to inflate it, and paints the raw compressed bytes to screen. Static assets are unaffected — only Grav's own dynamically generated pages are broken. The problem surfaced only after switching the site to production Twig mode (twig.debug: false).
Symptoms
- The homepage and all dynamic Grav pages show a full screen of binary/mojibake characters in the browser (completely unreadable). The surrounding HTML shell and static assets are fine.
- A naive
curl https://site/(with noAccept-Encodingheader) returns clean, correct HTML — so a quick curl sanity check looks perfectly healthy and completely hides the bug. - A browser-style request exposes it.
curl -H "Accept-Encoding: gzip" -D - -o /dev/null https://site/shows twoContent-Encodingresponse headers:The body is valid gzip andcontent-encoding: identity content-encoding: gzipgunzips to the correct HTML. - A static asset served by the webserver alone (e.g. a CSS/JS file) shows a single clean
content-encoding: gzipunder the same request — confirming the webserver's gzip is fine and the duplication is Grav-originated. - The bug only appeared after switching the site to production Twig mode (
twig.debug: false), which activates Grav's full shutdown/output path.
What Didn't Work
- First fix attempt:
cache.gzip: false+allow_webserver_gzip: true. This was the key dead end. It had no effect — the duplicated headers were unchanged. Reading Grav's source explained why: the branch that emits the bogus header fires onif ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip')). Settingallow_webserver_gzip: truesatisfies the same||condition, so Grav still takes the identicalheader('Content-Encoding: identity')code path. The two knobs that look like they control this are both on the wrong side of the problem. - Verifying with a naive
curl(noAccept-Encoding: gzip). This hid the problem entirely, because the server only compresses when the client advertises gzip support. Any healthcheck that omitsAccept-Encoding: gzipreports a false "all clear." Reproduce it the way a browser does — sendAccept-Encoding: gzip, or take a real headless-browser (Playwright) screenshot.
Solution
Root the fix in Grav's shutdown handler, system/src/Grav/Common/Grav.php (~lines 615–631):
if ($config->get('system.debugger.shutdown.close_connection', true)) {
$success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false;
if (!$success) {
if (!ini_get('zlib.output_compression')) {
if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip')) {
header('Content-Encoding: identity'); // <-- the bogus header
} elseif (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
} else {
header('Content-Encoding: none');
}
header('Content-Length: ' . ob_get_length());
}
header('Connection: close');
ob_end_flush();
}
}
The entire problematic block is gated by system.debugger.shutdown.close_connection (default true). Disable it so the whole branch is skipped and Grav never touches Content-Encoding at all.
In this project it is applied as a per-environment (prod-only) override so local dev is untouched — deploy/env/prod/system.yaml, deployed via make remote-apply-env-prod:
debugger:
shutdown:
close_connection: false
Verify — the response must show exactly one content-encoding:
curl -s -D - -o /dev/null -H "Accept-Encoding: gzip" https://site/ | grep -i content-encoding
# content-encoding: gzip
Then take a screenshot of the rendered page to confirm it displays correctly. A curl-without-gzip check is not sufficient proof — it would have passed even while the bug was live.
Why This Works
debugger.shutdown.close_connection (default true) makes Grav flush the full response and close the connection to the browser early, so slow shutdown tasks (logging, debugger teardown) don't keep the visitor waiting. On a FastCGI/PHP-FPM host, Grav does this cleanly via fastcgi_finish_request() and never manipulates headers — which is why the bug is invisible on most stacks.
On a non-FastCGI host (LiteSpeed, suPHP, plain CGI), fastcgi_finish_request() does not exist, so $success is false and Grav falls back to closing the connection manually. To do that it must set an explicit Content-Length, and to keep that length honest it tries to tell the webserver "do not compress this body" — which, on the cache.gzip/allow_webserver_gzip branch, it expresses as header('Content-Encoding: identity').
But identity is not a real content transformation and is not a recognized "suppress compression" signal to Apache mod_deflate. mod_deflate ignores it, compresses the body anyway, and appends its own Content-Encoding: gzip. The response now carries two contradictory Content-Encoding headers (identity and gzip). Browsers cannot reconcile the contradiction, fail to inflate the gzip stream, and render the raw compressed bytes — the "binary garbage" screen.
Setting close_connection: false means Grav never enters the manual connection-close path, never emits Content-Encoding: identity, and leaves the webserver as the sole authority on compression. The webserver then sends a single, correct Content-Encoding: gzip, and the browser inflates and renders normally.
Prevention
- On non-FastCGI PHP hosts with server-side gzip (Apache
mod_deflate, LiteSpeed), setdebugger.shutdown.close_connection: falsefor that environment. Deliver it as a per-environment override, never by editing the committedsystem.yaml(which would silently change dev behavior too). - Reproduce compression bugs the way a browser sees them. Always test with
curl -H "Accept-Encoding: gzip" -D -and/or a headless-browser screenshot. A plaincurlnegotiates no compression and silently masks encoding bugs. - Health check: dynamic pages must return exactly one
Content-Encodingheader. Two of them (identity+gzip) is the unambiguous signature of this bug. Add this assertion to any smoke test. - Know the trigger. This can stay completely hidden in development mode and only appear once a site is switched to production mode (
twig.debug: false), which activates Grav's full shutdown/output path. Re-run the browser-style compression check as part of any production cutover.
Related
- Grav per-environment override mechanism —
deploy/env/prod/system.yamlapplied viamake remote-apply-env-prod, described inCLAUDE.md§1 ("Production mode — per-environment override"). The pattern that lets a prod-only setting likedebugger.shutdown.close_connection: falseship without mutating the committed devsystem.yaml. docs/working/git-sync-notes.md— documents theuser/env/<hostname>/config/override tree (where this fix physically lives on the server) and the caveat that config saved via Admin on the server stays server-only.docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md— a sibling gotcha from the same 2026-07-04 Grav 2.0.4 production cutover (account permission provisioning); different root cause, same deploy.