Skip to main content

NGINX / Server Setup

NGINX http3 http_host Is Empty — Here’s the Fix

by ,


We have by far the largest RPM repository with NGINX module packages and VMODs for Varnish. If you want to install NGINX, Varnish, and lots of useful performance/security software with smooth 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:

  1. Is HTTP/3 enabled? (Look for listen ... quic in your config.)
  2. Does your config use $http_host anywhere — in proxy_set_header, fastcgi_param, log_format, Lua/njs, or if conditions?

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 none to 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.

D

Danila Vershinin

Founder & Lead Engineer

NGINX configuration and optimizationLinux system administrationWeb performance engineering

10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes:

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

This site uses Akismet to reduce spam. Learn how your comment data is processed.