The Host header: how nginx, ALBs, and Cloudflare all play the same game (differently)

7 min read
DNSRoutingDeployment

I hit the same IP address twice. Different Host headers. Different responses.

$ curl -skI https://104.16.132.229/ -H "Host: cloudflare.com"
HTTP/2 301

$ curl -skI https://104.16.132.229/ -H "Host: github.com"
HTTP/2 403

Same IP. Same port. Same TLS handshake. Two completely different outcomes — because one header changed.

That header is Host. And once you understand how it works, you'll see it everywhere: in nginx configs, in ALB listener rules, in Cloudflare's routing logic, in every reverse proxy you'll ever operate. It's one of those things that's quietly running the show.


How a request actually gets routed

Before DNS resolution, before TCP, before TLS — when you type rudr.me into a browser, the OS has to turn that name into an IP address. It asks a recursive resolver (usually your ISP's, or 8.8.8.8, or 1.1.1.1), which walks the DNS tree until it gets an A record back: a single IP.

That IP might serve one site. Or it might serve thousands.

When your browser opens a TCP connection to that IP and sends an HTTP request, it includes a Host header:

GET / HTTP/1.1
Host: rudr.me

This header is how the server on the other end knows which site you want. Not from the IP — the IP just got you to the machine. The Host header tells the machine what you're looking for.

This is called virtual hosting, and it's been in the HTTP spec since 1.1 (1997). One IP, many domains, routing by header. The entire modern web runs on it.


What nginx does with it

In nginx, virtual hosting is explicit. You define server blocks, each with a server_name directive:

server {
    listen 80;
    server_name rudr.me www.rudr.me;
    root /var/www/rudr;
}

server {
    listen 80;
    server_name api.rudr.me;
    proxy_pass http://localhost:3000;
}

When a request comes in, nginx reads the Host header and matches it against every server_name. First match wins. If nothing matches, nginx falls through to a default server — usually the first block defined, or one explicitly marked default_server.

This is permissive by default. Send Host: anything.example.com to an nginx server with no matching block, and it'll likely serve you something — the default. That's a deliberate design choice: fail open rather than fail closed.

You can make it strict by defining a catch-all that returns 444 (nginx's "drop connection" status):

server {
    listen 80 default_server;
    server_name _;
    return 444;
}

But out of the box, nginx doesn't enforce that Host has to match anything specific.


What an AWS ALB does with it

An Application Load Balancer operates at Layer 7 — it terminates HTTP/S, reads headers, and routes based on rules you define. Host-based routing is a first-class feature:

IF Host header is "api.rudr.me"
  THEN forward to target group: api-service

IF Host header is "rudr.me" OR "www.rudr.me"
  THEN forward to target group: frontend

ALBs evaluate rules in priority order. If no rule matches, the default action fires — which you configure explicitly (usually a fixed 404 or redirect).

The critical difference from nginx: an ALB's routing rules are defined in AWS, not in a config file. They're managed through the console, CLI, or Terraform. And because the ALB sits in front of your entire infrastructure, it's the first thing that sees the Host header — your backend never has to think about routing.

One thing ALBs do that surprises people: by default, they forward the original Host header to the backend unchanged. So if api.rudr.me hits your ALB and routes to an EC2 instance, that instance sees Host: api.rudr.me — not the ALB's internal hostname. You can override this with a target group attribute, but the default is pass-through.

I tested this when I had my own ALB running. Sending a Host header for a domain that wasn't configured in any rule just hit the default action — no response from an unexpected backend, no information leak. The ALB didn't try to be helpful. It just matched rules, and if nothing matched, it used the default. That's the difference in philosophy from nginx.


What Cloudflare does with it

Cloudflare's IP space is shared across millions of sites. When you put your domain behind Cloudflare, your DNS A record points to a Cloudflare IP — the same IP potentially used by thousands of other domains. Cloudflare's edge network uses the Host header (and SNI during the TLS handshake) to figure out which customer's configuration applies to your request.

This is where my original curl experiment comes in:

$ curl -skI https://104.16.132.229/ -H "Host: cloudflare.com"
HTTP/2 301

$ curl -skI https://104.16.132.229/ -H "Host: github.com"
HTTP/2 403

104.16.132.229 is a Cloudflare IP. When I told it Host: cloudflare.com, it knew that domain — and redirected me (301). When I told it Host: github.com, it had no configuration for that domain on that IP. Not a permissive fallback like nginx. Not a configured default action like an ALB. A hard 403.

Cloudflare is strict by design. Sending a Host it doesn't recognize means the request has no place to go. There's no leaking traffic to an unexpected backend, no serving a default page. The request is rejected.

This strictness is a security property. At Cloudflare's scale, being permissive would be dangerous — you'd risk routing requests to wrong customers, leaking responses, or creating ambiguity that could be exploited. So they fail closed.


The pattern: same mechanism, different policy

All three systems — nginx, ALB, Cloudflare — use the Host header to route requests. The mechanism is identical. What differs is the policy:

SystemNo matching Host?Why
nginxServe default blockFail open; operator's responsibility to harden
AWS ALBExecute default action (usually 404/redirect)Explicit fallback; infrastructure-defined
Cloudflare403Fail closed; multi-tenant security requirement

Understanding this distinction matters when you're debugging. If a request lands somewhere unexpected, the question isn't "is the routing broken?" — it's "which policy fired, and why?"


The debugging superpower

Once you know this, curl -H "Host: ..." becomes a tool you'll use constantly.

Test a backend before DNS propagates:

curl -sI http://NEW_SERVER_IP/ -H "Host: myapp.com"

Verify which ALB rule fires for a given domain:

curl -skI https://ALB_DNS_NAME/ -H "Host: api.myapp.com"

Check if nginx is serving the right vhost:

curl -sI http://localhost/ -H "Host: staging.myapp.com"

You're not waiting for DNS. You're not guessing what the load balancer will do. You're sending the exact signal the routing layer reads, and watching what comes back.

Senior engineers reach for this during deploys, during incidents, during DNS migrations. It's fast, it's precise, and it works against every HTTP server ever built — because the Host header has been required since HTTP/1.1.


What's next from here

The Host header is the HTTP-layer version of this problem. TLS has its own version: SNI (Server Name Indication), which solves the same problem one layer down — how does a server know which certificate to serve before it's decrypted the request? Worth one short session if you want the full picture.

Kubernetes Ingress is the declarative version of everything nginx does here, at cluster scale. If you're heading toward DevOps/SRE work, you'll write Ingress rules constantly — and they're just Host-header routing with a YAML interface.

The header is small. The surface area it controls is not.

Tagged

Host headernginxAWS ALBCloudflarereverse proxyHTTP