Files
intotheeast-com/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md
m038andClaude Opus 4.8 66438836de docs: capture stale-GRAV_VERSION api-plugin-install failure (ce-compound)
Fresh prod install ran Grav rc.10 (stale .env.prod GRAV_VERSION), so GPM
would not serve the api plugin (needs >=2.0.4); admin2 (auth via /api/v1)
then 404'd login silently. Documents the dead ends (deploying api config,
gpm index -f, same-channel assumption) and the fix (self-upgrade core +
reinstall + bump .env.prod). Adds reciprocal 'same 2026-07-04 cutover'
cross-links across the three sibling deploy gotchas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 23:59:44 +02:00

9.1 KiB
Raw Permalink Blame History

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
Homepage and every dynamic Grav page render as full-screen binary/mojibake garbage in the browser
curl without Accept-Encoding returns clean HTML, so the page looks fine from a naive curl
curl -H "Accept-Encoding: gzip" (a browser-style request) returns raw gzip bytes
Response carries two conflicting headers: content-encoding: identity AND content-encoding: gzip
Only appeared after switching prod to production Twig mode (twig.debug: false)
config_error config_change
Apache mod_deflate
DirectAdmin shared host
Grav shutdown handler (system/src/Grav/Common/Grav.php)
deploy/env/prod/system.yaml
make remote-apply-env-prod
grav
content-encoding
gzip
mod-deflate
fastcgi-finish-request
apache
production-deploy
twig-debug

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 no Accept-Encoding header) 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 two Content-Encoding response headers:
    content-encoding: identity
    content-encoding: gzip
    
    The body is valid gzip and gunzips to the correct HTML.
  • A static asset served by the webserver alone (e.g. a CSS/JS file) shows a single clean content-encoding: gzip under 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 on if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip')). Setting allow_webserver_gzip: true satisfies the same || condition, so Grav still takes the identical header('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 (no Accept-Encoding: gzip). This hid the problem entirely, because the server only compresses when the client advertises gzip support. Any healthcheck that omits Accept-Encoding: gzip reports a false "all clear." Reproduce it the way a browser does — send Accept-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 615631):

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), set debugger.shutdown.close_connection: false for that environment. Deliver it as a per-environment override, never by editing the committed system.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 plain curl negotiates no compression and silently masks encoding bugs.
  • Health check: dynamic pages must return exactly one Content-Encoding header. 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.
  • Grav per-environment override mechanismdeploy/env/prod/system.yaml applied via make remote-apply-env-prod, described in CLAUDE.md §1 ("Production mode — per-environment override"). The pattern that lets a prod-only setting like debugger.shutdown.close_connection: false ship without mutating the committed dev system.yaml.
  • docs/working/git-sync-notes.md — documents the user/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/integration-issues/stale-grav-version-blocks-api-plugin-install.md — sibling from the same cutover: Admin2 login failed because a stale GRAV_VERSION installed an rc core and GPM wouldn't serve the api plugin. Different root cause, same deploy.
  • 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.