--- title: "Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)" date: 2026-07-04 category: docs/solutions/integration-issues module: "grav / production deploy" problem_type: integration_issue component: tooling severity: high symptoms: - "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)" root_cause: config_error resolution_type: config_change related_components: - "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" tags: - 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 `gunzip`s 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 615–631): ```php 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`: ```yaml debugger: shutdown: close_connection: false ``` Verify — the response must show **exactly one** `content-encoding`: ```bash 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. ## Related - **Grav per-environment override mechanism** — `deploy/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//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.