yum upgrades for production use, this is the repository for you.
Active subscription is required.
You’ve enabled HTTP/3 on your NGINX server, everything looks fine, and then one day you notice $_SERVER['HTTP_HOST'] is empty in PHP. Or your proxy_set_header Host $http_host is sending a blank header upstream. Or your access logs show - where the hostname should be — but only for some requests.
The culprit? The nginx http3 http_host variable — $http_host — is broken over QUIC. It works perfectly fine on HTTP/1.1 and HTTP/2, but over QUIC it evaluates to empty. And since modern browsers prefer HTTP/3 when available, that could be the majority of your traffic silently misbehaving.
This isn’t a misconfiguration on your part. It’s a real bug in how NGINX handles the :authority pseudo-header in HTTP/3. Upstream finally shipped a partial fix in NGINX 1.29.5 (February 4, 2026) — but nginx-mod had the complete fix since September 30, 2025, four months earlier.
Where $http_host gets its value
To understand why this breaks, you need to know what $http_host actually reads. In ngx_http_variables.c:
{ ngx_string("http_host"), NULL, ngx_http_variable_header,
offsetof(ngx_http_request_t, headers_in.host), 0, 0 },
So $http_host reads from r->headers_in.host — a pointer to a Host header entry in the request’s headers list. If that pointer is NULL, you get an empty variable.
In HTTP/1.1, the client sends a literal Host: header. NGINX parses it, adds it to r->headers_in.headers, and points r->headers_in.host at it. Simple, works fine.
In HTTP/2, there’s no Host: header — clients send :authority instead. But NGINX’s HTTP/2 code handles this properly with ngx_http_v2_parse_authority(), which creates a synthetic Host entry:
static ngx_int_t
ngx_http_v2_parse_authority(ngx_http_request_t *r, ngx_str_t *value)
{
ngx_table_elt_t *h;
static ngx_str_t host = ngx_string("host");
h = ngx_list_push(&r->headers_in.headers);
/* ... sets h->key = "host", h->value = authority value ... */
/* Then calls ngx_http_process_host() which sets r->headers_in.host */
}
HTTP/2 creates a proper Host header from :authority. That’s why $http_host works over HTTP/2.
HTTP/3 doesn’t do this. When NGINX processes :authority in ngx_http_v3_request.c, it stores the value in r->host_start/r->host_end and copies it to r->headers_in.server:
if (name->len == 10 && ngx_strncmp(name->data, ":authority", 10) == 0) {
r->host_start = value->data;
r->host_end = value->data + value->len;
/* ... */
}
/* Later, in ngx_http_v3_init_pseudo_headers(): */
r->headers_in.server = host; /* from host_start/host_end */
And that’s it. No synthetic Host entry in r->headers_in.headers. The r->headers_in.host pointer stays NULL. And $http_host returns empty.
Now you might wonder: “But my site still works over HTTP/3, the hostname resolves correctly?” That’s because $host — the other hostname variable — has a fallback chain. It first checks r->headers_in.host, then r->headers_in.server (populated by :authority), and finally falls back to server_name. So $host masks the problem. But $http_host has no fallback — it reads r->headers_in.host and nothing else.
Here’s the critical section in ngx_http_v3_process_request_header():
if (r->headers_in.host) {
/* Validates Host matches :authority — but only if Host exists */
if (r->headers_in.host->value.len != r->headers_in.server.len
|| ngx_memcmp(...) != 0)
{
/* error: authority and Host differ */
goto failed;
}
}
/* No else clause. If headers_in.host is NULL, nothing happens. */
The if block only fires when a client sends both :authority and Host: (unusual). The common case — :authority only — falls through with headers_in.host still NULL.
Proving it: stock NGINX vs nginx-mod
I tested this on Rocky Linux 10, first with stock NGINX from the GetPageSpeed repository, then upgraded to nginx-mod. Same config, same requests — different results.
The test setup
log_format h3test '$remote_addr $server_protocol http_host="$http_host" host="$host"';
server {
listen 443 ssl;
listen 443 quic reuseport;
http2 on;
server_name test.local;
ssl_certificate /etc/nginx/test.crt;
ssl_certificate_key /etc/nginx/test.key;
add_header Alt-Svc 'h3=":443"; ma=86400';
access_log /var/log/nginx/h3test.log h3test;
location /test-headers {
add_header X-Http-Host $http_host always;
add_header X-Host $host always;
return 200 "http_host=[$http_host] host=[$host]\n";
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
}
And a PHP test file:
<?php echo "HTTP_HOST=" . ($_SERVER['HTTP_HOST'] ?? 'EMPTY') . "\n"; ?>
Stock NGINX (1.28.1-40) — the bug
$ rpm -q nginx
nginx-1.28.1-40.el10.gps.aarch64
HTTP/1.1 works as expected:
Protocol: HTTP/1.1
X-Http-Host: test.local
X-Host: test.local
HTTP/3 with the same :authority: test.local:
Protocol: HTTP/3
X-Http-Host: (empty)
X-Host: test.local
PHP tells the same story — HTTP_HOST=test.local over HTTP/1.1, but HTTP_HOST=EMPTY over HTTP/3.
And the access log makes it obvious:
127.0.0.1 HTTP/1.1 http_host="test.local" host="test.local"
127.0.0.1 HTTP/3.0 http_host="-" host="test.local"
$host works fine (it has that fallback chain). But $http_host — which is what most configs actually use — is empty.
nginx-mod (1.28.2-20) — the fix
$ rpm -q nginx-mod
nginx-mod-1.28.2-20.el10.gps.aarch64
Same config, same requests. After upgrading and restarting:
Protocol: HTTP/3
X-Http-Host: test.local
X-Host: test.local
PHP over HTTP/3: HTTP_HOST=test.local
And the access log:
127.0.0.1 HTTP/1.1 http_host="test.local" host="test.local"
127.0.0.1 HTTP/3.0 http_host="test.local" host="test.local"
No config changes needed. It just works.
Try it yourself
You can reproduce this on any RHEL-family server. Generate a self-signed certificate:
openssl req -x509 -nodes -days 1 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout /etc/nginx/test.key -out /etc/nginx/test.crt -subj '/CN=test.local'
Use the config above and send HTTP/3 requests with curl (if built with HTTP/3 support) or any QUIC client:
curl --http3-only -k https://localhost/test-headers -H "Host: test.local"
Compare the X-Http-Host header between HTTP/1.1 and HTTP/3. On stock NGINX, you’ll see it empty for HTTP/3.
What breaks in practice
The broken nginx http3 http_host isn’t just a cosmetic issue. Here’s what actually goes wrong:
FastCGI / PHP-FPM. The default fastcgi_params doesn’t explicitly set HTTP_HOST. Instead, NGINX’s FastCGI module iterates over r->headers_in.headers and passes each entry as an HTTP_* parameter:
part = &r->headers_in.headers.part;
header = part->elts;
for (i = 0; /* void */; i++) {
/* ... passes each header as HTTP_<NAME> to the FastCGI backend ... */
}
No Host in the headers list means no $_SERVER['HTTP_HOST'] in PHP. Laravel’s request()->getHost(), WordPress’s virtual host detection, Symfony’s routing — they all rely on it.
Reverse proxy. If you’re using the common proxy_set_header Host $http_host; pattern, HTTP/3 requests send an empty Host: header to your backend. The upstream may serve the wrong vhost, return defaults, or reject the request outright.
Access logs. Any log_format with $http_host shows - for HTTP/3 traffic. If you use GoAccess, AWStats, or custom log pipelines that key on the host field, your HTTP/3 visitors become invisible.
Lua/njs. OpenResty’s ngx.var.http_host and njs r.headersIn['Host'] both return empty. Any hostname-based routing logic in these modules breaks silently.
The workaround (and why it’s not great)
The obvious fix is to explicitly set it:
fastcgi_param HTTP_HOST $host;
This works for FastCGI, but only FastCGI. You’d also need to change every proxy_set_header, every log format, and every Lua/njs script that reads $http_host.
And here’s the real problem: $host isn’t the same as $http_host. The $host variable strips trailing dots and never includes a port number. If a client sends Host: example.com.:8080, you get example.com.:8080 from $http_host but example.com from $host. Applications that parse the port or rely on exact host matching will behave differently.
The right fix isn’t patching every directive individually. It’s making $http_host work correctly at the protocol layer.
How nginx-mod fixes it
nginx-mod ships a patch based on Angie commit 140c3a6 (August 12, 2025). It adds a new function to ngx_http_v3_request.c that does exactly what HTTP/2’s handler does — creates a synthetic Host header from :authority:
static ngx_int_t
ngx_http_v3_set_host(ngx_http_request_t *r, ngx_str_t *value)
{
ngx_table_elt_t *h;
static ngx_str_t host = ngx_string("host");
h = ngx_list_push(&r->headers_in.headers);
if (h == NULL) {
return NGX_ERROR;
}
h->hash = ngx_hash(ngx_hash(ngx_hash('h', 'o'), 's'), 't');
h->key.len = host.len;
h->key.data = host.data;
h->value.len = value->len;
h->value.data = value->data;
h->lowcase_key = host.data;
r->headers_in.host = h;
h->next = NULL;
return NGX_OK;
}
The call site is elegant — it goes right after the existing host validation check:
if (r->headers_in.host) {
/* existing: validate Host matches :authority */
...
} else if (r->headers_in.host == NULL && r->host_end) {
/* NEW: Host header is missing — set from :authority */
if (ngx_http_v3_set_host(r, &r->headers_in.server) != NGX_OK) {
return NGX_ERROR;
}
}
The condition r->headers_in.host == NULL && r->host_end means this only fires when :authority was provided but no explicit Host: header exists — which is the normal HTTP/3 case.
Because the fix operates at the protocol layer, it fixes everything downstream: $http_host, FastCGI iteration, proxy headers, logs, Lua/njs, SCGI, uwsgi. No config changes required.
This shipped in nginx-mod 1.26.3-14 on September 30, 2025.
What upstream did differently
Upstream NGINX took a narrower approach. PR #1031, merged January 15, 2026, changed the default parameters in FastCGI, SCGI, and uwsgi modules to explicitly pass $host instead of relying on headers list iteration.
This shipped in NGINX 1.29.5 (February 4, 2026) — a development branch release. There’s still no fix in any stable release.
Here’s where the two approaches differ:
| Scenario | nginx-mod (Sep 2025) | Upstream 1.29.5 (Feb 2026) |
|---|---|---|
$http_host variable |
Fixed | Still empty |
FastCGI HTTP_HOST |
Fixed | Fixed |
SCGI/uwsgi HTTP_HOST |
Fixed | Fixed |
proxy_set_header Host $http_host |
Fixed | Still broken |
Access log $http_host |
Fixed | Still shows - |
Lua/njs reading $http_host |
Fixed | Still empty |
Upstream’s fix covers FastCGI/SCGI/uwsgi defaults. nginx-mod’s fix covers everything, because it operates at the right abstraction level.
Worth noting: NGINX 1.29.5 is a development branch (odd minor version = development). As of February 2026, no stable release has any fix. If you’re running NGINX stable (1.28.x), upstream doesn’t have a solution for you at all.
Timeline
- October 2024: Issue #256 filed against upstream
- August 12, 2025: Angie publishes commit 140c3a6 with the protocol-layer fix
- September 30, 2025: nginx-mod ships the complete fix (version 1.26.3-14)
- January 15, 2026: Upstream merges PR #1031 — partial fix for FastCGI/SCGI/uwsgi defaults only
- February 4, 2026: NGINX 1.29.5 released (development branch, not stable)
Are you affected?
If you answer “yes” to both of these, you have this bug:
- Is HTTP/3 enabled? (Look for
listen ... quicin your config.) - Does your config use
$http_hostanywhere — inproxy_set_header,fastcgi_param,log_format, Lua/njs, orifconditions?
The default fastcgi_params shipped with NGINX doesn’t set HTTP_HOST explicitly, so any PHP site using include fastcgi_params; is affected. And most configuration guides (including NGINX’s own docs) use $http_host for proxying and logging, so this isn’t an uncommon pattern.
Installing nginx-mod
Switching on RHEL, CentOS, Rocky, AlmaLinux, or Fedora:
sudo dnf -y install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --disable getpagespeed-extras-mainline
sudo dnf config-manager --enable getpagespeed-extras-nginx-mod
sudo dnf -y install nginx
It’s a drop-in replacement — same config paths, same systemd service, same module ecosystem. See the nginx-mod product page for the full feature list.
More than just this fix
The $http_host fix is one of many improvements in nginx-mod:
- HTTP/3 (QUIC) with persistent host key for 0-RTT resumption
- kTLS offload for TLS encryption at kernel level
- Dynamic TLS records for optimal TTFB
- Full HPACK encoding for HTTP/2 response headers
server_tokens noneto hide version from headers and error pages- CONNECT method for forward proxy use cases
- Rate limiting improvements from the Angie project
- Health checks for upstream backends
- 130+ compatible modules from the GetPageSpeed repository
All covered by a single subscription.
Wrapping up
The broken nginx http3 http_host isn’t an edge case — it affects every NGINX server with QUIC enabled that uses $http_host in its configuration. The fix belongs at the protocol layer, not in individual module defaults.
Stop adding fastcgi_param HTTP_HOST $host; workarounds everywhere. Subscribe to GetPageSpeed and switch to nginx-mod — your $http_host variable will just work, as it always should have.
