Skip to main content
What Happens on the Server When a Web Request Arrives
On this page

What Happens on the Server When a Web Request Arrives

17 September 2026

Your website and eleven others can share one IP address. A browser opens a connection to that address and says almost nothing about who it is looking for. One program on that machine then has to decide, in a couple of milliseconds, which of the twelve sites the visitor actually wanted, which certificate proves it, which directory to read from, and whether to answer the request itself or hand it to something else. It gets this right so reliably that almost nobody asks how.

This article picks up where what actually happens when you enter a URL hands off. That article follows the request out of the browser and across the network, and treats the server as one step. This one opens that step. It covers what a web server actually is, how one machine answers for many domain names, why the certificate and the site are chosen by two different mechanisms that can disagree, how the request reaches your application, and how to read the error when it does not. Every command output below is real: the local runs on a local Apache 2.4.66 with PHP-FPM 8.3.26 and a purpose-built nginx 1.29 configuration you can rebuild in a minute, both on 16 September 2026, and the runs against the live server behind petermartin.nl re-measured on 17 September 2026 after its virtual host configuration changed.

DNS gets you to the machine. The Host header gets you to the site. They are not the same step, and that is where most of the confusion lives.

Goal: after reading this you can explain why one IP address can serve a hundred sites, predict which one answers when something goes wrong, and tell from a single response header which layer produced it.

1. The Basics

A web server is not a machine. It is a program that waits on a port, accepts connections, and writes answers back. RFC 9110, the current definition of HTTP semantics, is exact about it:

An HTTP "server" is a program that accepts connections in order to service HTTP requests by sending HTTP responses.

The same document adds the sentence that clears up most of the vocabulary trouble: "The terms client and server refer only to the roles that these programs perform for a particular connection." Your hosting provider sells you space on a machine. The web server is one process on it, and that machine is almost certainly running several other programs that are also servers, and some that are clients.

1.1 The Five Decisions

Everything a web server does for a single request comes down to five decisions, taken in this order. They are the spine of this article.

#DecisionWhat it readsWhat it cannot use yet
1 Which certificate do I present? The SNI field in the TLS ClientHello The Host header, which is still encrypted
2 Which site is this for? The Host header, or :authority on HTTP/2 and HTTP/3 The IP address, which stopped being useful once the connection existed
3 What does this path mean? The document root plus the request path, plus index and rewrite rules The fragment, which is never sent
4 Who produces the bytes? A file on disk, or an application behind an interface -
5 What do I send back? The outcome of all of the above -

Decisions 1 and 2 are the ones people merge into a single idea, and they are the subject of section 5. They use different inputs, they happen at different moments, and a misconfiguration can make them disagree.

1.2 The Address Stops Mattering the Moment You Arrive

This is the mental model worth carrying. The IP address is a delivery address for a building, not for an office inside it. DNS resolves example.com to 203.0.113.10, the packets arrive at that machine, and at that point the name is gone. Nothing in the TCP connection remembers it.

So the client has to say the name again, inside the request. RFC 9110 puts the reason in one sentence:

The "Host" header field in a request provides the host and port information from the target URI, enabling the origin server to distinguish among resources while servicing requests for multiple host names.

That single header is the entire mechanism behind shared hosting. Remove it and every machine on the internet could host exactly one website.

1.3 What the Server Actually Receives

Strip away TLS and HTTP/2 framing and a request is a few lines of text. Over HTTP/1.1 you can type it by hand:

GET /en/ HTTP/1.1
Host: petermartin.nl
User-Agent: curl/8.5.0
Accept: */*

The first line names a method, a path and a version. The path is relative: it says /en/, not https://petermartin.nl/en/. The scheme and the hostname live in the Host header instead. Section 7.1 covers the older alternative form, which still works and still surprises people.

Back to top

2. Where the Names Come From

Four words in this article carry history that explains the behaviour.

2.1 Server

The word names a role in the client/server model, not a box. RFC 9110 defines it as a program, and adds that a program can be a server on one connection and a client on another. That is not a technicality: a reverse proxy is exactly this, a server to the browser and a client to your application, which is why section 6.5 exists.

2.2 httpd, and the Letter d

Apache's binary is called httpd. The d is for daemon, the Unix word for a program that runs in the background with no terminal attached. The Apache Software Foundation's own history page uses exactly that phrase for the software Apache grew out of: "the public domain HTTP daemon developed by Rob McCool at the National Center for Supercomputing Applications". Thirty years later the name still tells you how to find the process.

2.3 Virtual Host

A host was a machine. Calling something a virtual host was a deliberate contradiction: it is one of several websites pretending, to the outside world, to be a machine of its own. The phrase entered the standards when HTTP/1.1 made it possible. Section 19.5.1 of RFC 2068, from January 1997, is titled "Changes to Simplify Multi-homed Web Servers and Conserve IP Addresses", which is the whole motivation in one heading: addresses were scarce, sites were not.

2.4 Document Root

The directory a site's files are served from, and the boundary the server refuses to cross. Everything above it on the filesystem is invisible to the web by design. When a misconfiguration exposes a database password, it is usually because a file that should sit above the document root was placed inside it.

2.5 Origin Server

RFC 9110 reserves a separate term for the machine that actually holds the truth: "The term 'origin server' refers to a program that can originate authoritative responses for a given target resource." Caches, CDN edges and proxies answer on its behalf. The distinction matters the moment you deploy a change and cannot see it.

Back to top

3. A Short History

Almost every awkward part of server configuration is a fossil of a constraint that no longer applies.

WhenWhat happenedWhy it still shows
1993-1996 One site per IP address. NCSA httpd is the common server. IP-based virtual hosting still exists as a configuration option
Feb 1995 Eight webmasters coordinate patches to NCSA httpd and form the Apache Group The project's per-directory, override-friendly design dates from here
Apr 1995 First public Apache release, 0.6.2 -
1 Dec 1995 Apache 1.0 -
Jan 1997 RFC 2068 makes the Host header a requirement of HTTP/1.1 A request without it is a 400, thirty years later
Oct 2004 RFC 3875 documents CGI version 1.1, long after everyone had implemented it Your PHP still reads $_SERVER['QUERY_STRING'], a CGI meta-variable
5 Oct 2004 nginx 0.1.0 is published An event-driven answer to serving many idle connections at once
Jan 2011 RFC 6066 carries Server Name Indication into the current TLS extension document HTTPS on shared hosting depends on it entirely
Jun 2014 RFC 7239 standardises the Forwarded header Everyone still uses X-Forwarded-For anyway
Jun 2022 RFC 9110 and RFC 9112 replace the 1999 and 2014 HTTP documents The current text for everything quoted in this article

The nginx date is worth a note on method: nginx.org's own download index still lists nginx-0.1.0.tar.gz with a timestamp of 5 October 2004, and the file's Last-Modified header agrees. That is a primary source you can check yourself, which is better than the release date repeated on a dozen blogs.

3.1 The One Change That Made Shared Hosting Possible

Before January 1997 a request said only GET /index.html HTTP/1.0. There was nowhere to put a hostname, so a machine could serve exactly one site per address. Hosting ten customers meant ten IP addresses.

You can check that claim in one command. Search RFC 1945, the 60-page HTTP/1.0 document from May 1996, for the header name Host and you get nothing. The word appears seven times in lower case, in ordinary sentences about hosts and hostnames, and never once as a header. It did not exist.

HTTP/1.1 added one mandatory header and the economics of the web changed. RFC 9112, the current HTTP/1.1 document, still states the rule in both directions:

A client MUST send a Host header field in all HTTP/1.1 request messages. [...] A server MUST respond with a 400 (Bad Request) status code to any HTTP/1.1 request message that lacks a Host header field and to any request message that contains more than one Host header field line or a Host header field with an invalid field value.

3.2 And the One That Made It Work Over HTTPS

TLS then broke it again. The server has to present a certificate before it can read any header, so it has to know which site the client wants before the encrypted channel exists. RFC 6066 states the gap plainly - "TLS does not provide a mechanism for a client to tell a server the name of the server it is contacting" - and defines Server Name Indication to close it, in order to support "secure connections to servers that host multiple 'virtual' servers at a single underlying network address".

So the hostname is now sent twice, by two different mechanisms, at two different moments. Section 5.5 is what happens when they disagree.

Back to top

4. One Site on One Server

Start with the simplest case: one domain, one document root, static files.

4.1 From Path to File

The server takes the request path, appends it to the document root, and looks for what it finds there. Nothing more clever than that:

document root   /var/www/example.com/public
request path    /images/logo.png
file opened     /var/www/example.com/public/images/logo.png

Two rules fill the gaps. If the path names a directory, the server tries a list of index files in order (index.html, then index.php, in a typical configuration) and serves the first one that exists. If nothing matches, it either lists the directory, refuses with 403, or sends the request to a rewrite rule that turns it into something an application understands.

That last case is how every CMS works. There is no directory called /en/focus-on/web/ on the server behind this page: a rewrite rule sends the whole path to a single front controller, which reads it as a route.

4.2 A Static File, Answered by the Server Itself

Here is a real static response from the local Apache used for this article:

$ curl -skI https://local.test.nl/media/system/js/core.js
HTTP/1.1 200 OK
Date: Wed, 16 Sep 2026 15:40:07 GMT
Server: Apache/2.4.66 (Unix) OpenSSL/3.5.5
X-Content-Type-Options: nosniff
Last-Modified: Tue, 18 Aug 2026 15:10:00 GMT
ETag: "66c7-65953ae330200"
Accept-Ranges: bytes
Content-Length: 26311

Last-Modified, ETag, Content-Length and Accept-Ranges are all facts the server read off the filesystem. It knows the size before it starts, it knows when the file changed, and it can serve a byte range out of the middle of it. That is why static files are cheap: there is nothing to decide.

4.3 What the Server Refuses to Do

The document root is a boundary, and the server enforces it before anything else looks at the path. Two attempts against the nginx test configuration from section 5.2:

$ curl -s -o /dev/null -w '%{http_code}\n' --path-as-is \
      -H 'Host: alpha.example' 'http://127.0.0.1:18080/../../etc/passwd'
400

$ curl -s -o /dev/null -w '%{http_code}\n' \
      -H 'Host: alpha.example' 'http://127.0.0.1:18080/%2e%2e%2f%2e%2e%2fetc/passwd'
400

Note the second one. The dots are percent-encoded, so the traversal is invisible until the path is decoded. The server decodes first and normalises second, which is why the encoded form fails in the same way. Directory traversal bugs are almost always a case of an application doing its own path handling and skipping that order.

4.4 Which Layer Answered? Read the Headers

Two requests for two files that do not exist, on the same server, in the same second:

$ curl -skI https://local.test.nl/nope.txt | head -3
HTTP/1.1 404 Not Found
Date: Wed, 16 Sep 2026 15:40:19 GMT
Server: Apache/2.4.66 (Unix) OpenSSL/3.5.5

$ curl -skI https://local.test.nl/nope.php | head -4
HTTP/1.1 404 Not Found
Date: Wed, 16 Sep 2026 15:40:19 GMT
Server: Apache/2.4.66 (Unix) OpenSSL/3.5.5
X-Powered-By: PHP/8.3.26

Same status code, two completely different journeys. The first 404 was decided by Apache, which looked for a file and did not find it. The second was forwarded to PHP-FPM, which started PHP, which produced a 404 and stamped it with X-Powered-By.

That header is a free diagnostic. If it is present, your application ran. If a page is broken and X-Powered-By is missing, the request never reached your code and no amount of debugging the application will help.

4.5 Static Is Nearly Free, and Dynamic Is Not

Three requests each for a static file and a CMS page on the live server behind petermartin.nl, measuring time to first byte over the same connection, on 16 September 2026:

$ curl -s -o /dev/null -w '%{time_starttransfer}\n' https://petermartin.nl/URL

/media/system/js/core.js     0.132  0.105  0.085
/en/                         0.306  0.231  0.231

Both figures include DNS, the handshakes and the network round trip, so neither is server time on its own. The difference between them is what matters: about 145 milliseconds, and all of it is the application. The web server handed over a file in the first case and started a PHP process, a database connection and a template render in the second.

This is the single most useful comparison to run on a slow site. If static files are fast and pages are slow, the web server is fine and the application is the problem. If both are slow, look at the network or the machine.

4.6 A Rewrite and a Redirect Are Not the Same Thing

Two mechanisms change which resource answers, and they get confused constantly because they are configured in the same block of the same file.

A rewrite is internal. The server changes the path it works with and answers the original request. One request, one response, and the browser never learns it happened: the address bar still shows what the visitor typed. Every CMS depends on this, which is how /en/focus-on/web/ becomes /index.php without anybody seeing it.

A redirect is external. The server answers with a 301 or a 302 and a Location header, and that request is finished. The browser then starts a new one from the top, possibly with a new DNS lookup and a new connection, certainly with a new round trip.

rewrite    GET /news/my-article   →  server reads /index.php internally
           1 request, 1 response, the URL never changes

redirect   GET /old               →  301 + Location: /new
           browser starts again   →  GET /new
           2 requests, 2 responses, the URL changes

The practical difference is cost. A rewrite is free. A redirect is an entire extra round trip, which is why a chain of three of them can cost more than the page they lead to, and why turning a redirect into a rewrite is sometimes the cheapest performance win available.

Back to top

5. Many Domains, One IP Address

This is the part the hub article leaves out, and the reason most people find this page.

5.1 Why It Works at All

Twelve domains resolve to one address. Twelve sets of packets arrive at the same network interface, on the same port, and there is nothing in the IP header or the TCP header that says which site they are for. The name was used by DNS and then thrown away.

The server recovers it from the request itself. Apache's own documentation puts it without ceremony:

With name-based virtual hosting, the server relies on the client to report the hostname as part of the HTTP headers. Using this technique, many different hosts can share the same IP address.

"Relies on the client" is the important phrase. The visitor's browser tells the server which site it wants, and the server believes it. There is no other source of that information.

5.2 Three Sites in One Configuration

The fastest way to see this is to build it. This is a complete nginx configuration with three virtual hosts and no files at all - each one just answers with its own name:

server {
    listen 80 default_server;
    server_name _;
    default_type text/plain;
    return 404 "no site on this server answers to that name\n";
}
server {
    listen 80;
    server_name alpha.example www.alpha.example;
    default_type text/plain;
    location / { return 200 "you reached ALPHA\n"; }
}
server {
    listen 80;
    server_name beta.example;
    default_type text/plain;
    location / { return 200 "you reached BETA\n"; }
}

Run it, then ask the same address four different questions. Only the Host header changes:

$ for h in alpha.example www.alpha.example beta.example gamma.example; do
      printf '%-20s ' "$h"
      curl -s -H "Host: $h" http://127.0.0.1:18080/
  done

alpha.example        you reached ALPHA
www.alpha.example    you reached ALPHA
beta.example         you reached BETA
gamma.example        no site on this server answers to that name

One IP address, one port, one process, one connection pattern, four different answers. Nothing about the network changed between those requests.

5.3 The Default Server Catches Everything Else

gamma.example is not configured, so it fell through to the block marked default_server. Every server has one of these, whether or not anybody chose it, and it answers for:

  • every hostname that resolves to the address but is not configured
  • requests made to the bare IP address
  • requests with no Host header at all, or with one nobody recognises
  • every scanner on the internet that connects to an address and asks for /

The last point is the practical one. If you do not configure a default server deliberately, one of your real sites becomes it, and traffic meant for nobody lands on a customer's site. In the demonstration above, the fall-through answered even to a bare HTTP/1.0 request with no Host line:

$ printf 'GET / HTTP/1.0\r\n\r\n' | nc 127.0.0.1 18080 | tail -1
no site on this server answers to that name

5.4 Apache and nginx Choose Differently

Both servers reach the same result by different rules, and the difference matters when a name matches more than one block.

StepnginxApache
Narrow by address Blocks whose listen matches the address and port <VirtualHost> blocks with the most specific matching address and port
Match the name Exact name, then longest *.example.org wildcard, then longest mail.* wildcard, then first matching regular expression Compare ServerName and then ServerAlias
No match The block whose listen carries default_server The first listed virtual host for that address and port
No Host header Matched by an empty server_name "", otherwise the default server Falls to the default virtual host

The rules are set out in nginx's server names documentation and Apache's name-based virtual host page, and both are worth reading once rather than guessing. Two details catch people out. In nginx, default_server is a parameter of listen, not of server_name - the documentation says so explicitly, because it is the most common misreading. In Apache, the default is simply whichever virtual host appears first in the configuration, which means the order of your Include lines silently decides which customer catches stray traffic.

5.5 SNI Picks the Certificate, Host Picks the Site

Over HTTPS the hostname is sent twice, and the server uses the two copies for two different decisions. SNI arrives first, in the clear, before any encryption exists, and chooses the certificate. The Host header arrives afterwards, encrypted, and chooses the site.

They are normally identical, so the distinction stays invisible. Three requests to the live server behind petermartin.nl pull them apart. Several of my own sites share that machine, and essen.db8.dev is configured as the default one, which is what makes the experiment safe to publish: every result below is my own infrastructure answering.

# 1. correct SNI, correct Host: the site answers
$ printf 'GET / HTTP/1.1\r\nHost: petermartin.nl\r\nConnection: close\r\n\r\n' \
  | openssl s_client -connect 23.88.98.40:443 -servername petermartin.nl -quiet
HTTP/1.1 301 Moved Permanently
Location: https://petermartin.nl/en/

# 2. correct SNI, unknown Host: the DEFAULT site answers
$ printf 'GET / HTTP/1.1\r\nHost: bogus.example\r\nConnection: close\r\n\r\n' \
  | openssl s_client -connect 23.88.98.40:443 -servername petermartin.nl -quiet
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 8683        <- essen.db8.dev's page, not petermartin.nl's

# 3. NO SNI at all, correct Host: the site still answers
$ printf 'GET / HTTP/1.1\r\nHost: petermartin.nl\r\nConnection: close\r\n\r\n' \
  | openssl s_client -connect 23.88.98.40:443 -quiet
HTTP/1.1 301 Moved Permanently
Location: https://petermartin.nl/en/

Read those three together and the model falls into place:

SNI saysHost saysCertificate presentedSite that answered
petermartin.nl petermartin.nl petermartin.nl petermartin.nl
petermartin.nl bogus.example petermartin.nl essen.db8.dev (the default)
nothing petermartin.nl essen.db8.dev's petermartin.nl

In row two the server proved it was petermartin.nl and then served a different site's homepage. In row three it proved it was that other site and then served petermartin.nl. Both are correct behaviour: the certificate answers "can I trust this connection?" and the Host header answers "which site did you want?", and nothing requires a server to cross-check them. Here both domains happen to be mine. On a hosting provider's shared machine the site in that column belongs to a stranger, and the behaviour is identical.

A normal browser never produces either row, because it puts the same name in both places. But every certificate-checking tool, uptime monitor and security scanner has to choose what to send, and a tool that gets it wrong will report a certificate problem that no visitor can reproduce.

5.6 On HTTP/2 and HTTP/3 It Is Not a Header at All

Everything so far used Host:, because it is readable and because HTTP/1.1 is the version you can type by hand. On HTTP/2 and HTTP/3 the same information travels as a pseudo-header named :authority. RFC 9110 says the familiar header is "in some cases, supplanted by" it.

The selection rule does not change - the server still picks the site by the name the client asked for - but the conflict rules are the opposite of section 7.1's. Over HTTP/1.1 the two ways of carrying a name are allowed to disagree, and one of them wins. Over HTTP/2, RFC 9113 removes the possibility:

The recipient of an HTTP/2 request MUST NOT use the Host header field to determine the target URI if ":authority" is present. [...] Clients MUST NOT generate a request with a Host header field that differs from the ":authority" pseudo-header field.

The appendix listing what changed since the previous HTTP/2 specification puts it in eight words: "Host and ':authority' are no longer permitted to disagree." RFC 9114 imposes the same rule on HTTP/3 - if both are present, "they MUST contain the same value".

You can watch a client obey it. Overriding the header with curl routes identically on both versions, because on HTTP/2 curl puts the override into :authority instead of sending a contradictory header:

$ curl -s --http2 -H 'Host: bogus.example' -o /dev/null \
      -w 'http=%{http_version} code=%{http_code} redirect=%{redirect_url}\n' https://petermartin.nl/
http=2 code=200 redirect=

$ curl -s --http1.1 -H 'Host: bogus.example' -o /dev/null \
      -w 'http=%{http_version} code=%{http_code} redirect=%{redirect_url}\n' https://petermartin.nl/
http=1.1 code=200 redirect=

$ curl -s --http2 -o /dev/null \
      -w 'http=%{http_version} code=%{http_code} redirect=%{redirect_url}\n' https://petermartin.nl/
http=2 code=301 redirect=https://petermartin.nl/en/

Both overridden requests fell through to the default site and received its page with a 200, exactly as the hand-typed HTTP/1.1 experiment in 5.5 did. The third run is the control: left alone, the same request reaches petermartin.nl and redirects. So read "the Host header selects the site" as "the name the client asked for selects the site", and let the protocol version decide which mechanism carries that name.

5.7 Testing a Site Before DNS Points at It

Because the name travels in the request rather than in the routing, you can point a single request at any server you like. This is the most useful practical consequence of the whole section, and it is how you test a migration before you change a single DNS record.

# send the request to a specific machine, but ask for the real hostname
$ curl -sI --resolve petermartin.nl:443:23.88.98.40 https://petermartin.nl/
HTTP/2 301

# plain HTTP, no certificate involved: just override the Host header
$ curl -sI -H 'Host: petermartin.nl' http://23.88.98.40/ | head -1
HTTP/1.1 301 Moved Permanently

# and what the bare address gets without a name
$ curl -sI http://23.88.98.40/ | head -1
HTTP/1.1 200 OK        <- the default site answering, not petermartin.nl

Prefer --resolve over -H 'Host: ...' for HTTPS. It sets the hostname for DNS, for SNI and for the Host header at once, so the certificate check still runs properly. The -H form changes only the header, which is exactly the mismatch from row two above.

Back to top

6. Handing the Work to an Application

Everything so far returned a file or a fixed string. Real sites run code, and the web server does not run it.

6.1 The Interface Between Two Programs

A web server speaks HTTP. Your application speaks its own language. Between them sits an interface whose job is to turn one into the other, and the shape of it has barely changed since the first web servers learned to run programs.

RFC 3875, published in October 2004 to document what everybody was already doing, calls it "a simple interface for running external programs, software or gateways under an information server in a platform-independent manner". The server converts the request into named values - "meta-variables" in the RFC's language - and hands them over.

You have read those variables a thousand times without noticing where they came from:

REQUEST_METHOD    GET
QUERY_STRING      page=2&sort=name
SCRIPT_NAME       /index.php
SERVER_NAME       example.com
REMOTE_ADDR       203.0.113.55

In PHP those arrive as $_SERVER['REQUEST_METHOD'] and the rest. The names are CGI meta-variables from a specification older than most of the web, and they survive because every later interface kept them.

What changed is the plumbing. Classic CGI started a fresh process for every single request, which is correct, simple and slow. FastCGI keeps a pool of processes running and passes requests to them over a socket. PHP's implementation of that is FPM, the FastCGI Process Manager, and it is how most PHP is served today.

6.2 Two Process Trees, Not One

A server running PHP this way is two separate programs. They can be restarted separately, they fail separately, and they can run as different users. On the local stack used for this article:

$ ps -o pid,ppid,user,args        # in the web server container
  PID  PPID USER     COMMAND
    1     0 root     httpd -DFOREGROUND
    8     1 daemon   httpd -DFOREGROUND
    9     1 daemon   httpd -DFOREGROUND
   10     1 daemon   httpd -DFOREGROUND
   92     1 daemon   httpd -DFOREGROUND

$ ps -o pid,ppid,user,args        # in the PHP container
  PID  PPID USER     COMMAND
    1     0 dockerus php-fpm: master process (/usr/local/etc/php-fpm.conf)
    7     1 dockerus php-fpm: pool www
    8     1 dockerus php-fpm: pool www

Each tree is a parent that manages and children that work. Apache's parent runs as root here because binding to port 443 requires it, and hands every request to an unprivileged child. PHP-FPM needs no privilege at all in this setup and runs entirely as an ordinary user, parent included. The pattern is deliberate in both cases: if a worker is compromised, it is not root.

Now the arithmetic, which is where sites fall over. Two pool www processes were running at that moment, but that is the starting size rather than the limit. The pool in this configuration is:

pm                   = dynamic
pm.start_servers     = 2      # how many exist when nothing is happening
pm.max_children      = 5      # the actual ceiling
pm.min_spare_servers = 1
pm.max_spare_servers = 3

So this server runs at most five PHP requests at a time. A sixth visitor does not get an error; the request waits in a queue until a worker is free. If that queue fills too, the connection is refused and the web server turns it into a gateway error, which is section 6.4. This is the most common cause of a site that is fine at 10 visitors and unusable at 30, and no amount of database tuning fixes it, because the requests never reach the database. The numbers above are a development default - a production pool is sized against the memory a single request uses, not guessed.

6.3 What "The Site Is Down" Usually Means

Stop PHP-FPM and leave the web server running. Here is what a visitor sees:

$ docker stop phpfpm

$ curl -skI https://local.test.nl/media/system/js/core.js | head -2
HTTP/1.1 200 OK                      # static files: perfectly fine
Date: Wed, 16 Sep 2026 15:40:27 GMT

$ curl -skI https://local.test.nl/en/ | head -4
HTTP/1.1 500 Proxy Error             # anything dynamic: gone
Date: Wed, 16 Sep 2026 15:40:27 GMT
Server: Apache/2.4.66 (Unix) OpenSSL/3.5.5
Connection: close

The web server is up. It accepted the connection, negotiated TLS, matched the virtual host, found the file, and served it. Only the handoff failed, and the error log says precisely that:

[proxy:error] AH00898: DNS lookup failure for: phpfpm returned by /index.php

Which is worth reading twice, because it is a good example of an error message telling the truth in an unhelpful way. Nothing is wrong with your site's DNS. The web server could not resolve the internal name of its own PHP backend, because the container serving that name had stopped.

The lesson generalises. "The site is down" almost never means the web server is down. It means one of the things behind it is, and the status code tells you which.

6.4 502 and 504 Are Two Different Sentences

When the backend fails, the error depends on how it failed. A purpose-built nginx with two broken routes makes the difference visible - one pointing at a port where nothing listens, one at a backend that accepts the connection and then says nothing, with proxy_read_timeout 3s:

$ curl -s -o /dev/null -w '%{http_code}  %{time_total}s\n' http://.../dead/
502  0.000941s

$ curl -s -o /dev/null -w '%{http_code}  %{time_total}s\n' http://.../slow/
504  3.004037s

The elapsed time is the diagnosis. 502 came back in under a millisecond, because the connection was refused immediately: the backend is not running. 504 took 3.004 seconds, which is the configured timeout to three decimal places: the backend is running and is too slow, or is stuck.

nginx's error log names both causes without ambiguity:

connect() failed (111: Connection refused) while connecting to upstream
upstream timed out (110: Operation timed out) while reading response header from upstream
StatusTimingWhat actually happenedWhere to look
502 Instant Nothing is listening, or it crashed mid-response Is the application process running?
504 Exactly the timeout The backend accepted and did not answer in time Slow query, external API, deadlock, exhausted pool
500 Varies Usually the application's own error, but Apache also uses it for some proxy failures The application log first, then the server log
503 Varies Deliberate: maintenance mode, rate limiting, or no worker available Configuration before code

Do not memorise this as a rule across all servers. The measurement in 6.3 returned 500 Proxy Error from Apache for a failure that nginx would have called 502. The timing distinction holds everywhere; the exact code does not.

6.5 Behind a Proxy, Your Application Sees the Proxy

Put anything in front of your web server - a CDN, a load balancer, nginx in front of Apache, a container ingress - and the connection your application sees comes from that thing, not from the visitor. Two nginx configurations proxying to the same backend, which simply reports what it received:

# proxy_pass with nothing else configured
remote_addr       = 172.19.0.3        <- the proxy, not the visitor
x_forwarded_for   =                   <- empty
x_forwarded_proto =                   <- empty
host              = app               <- the UPSTREAM name, not the site

# the same proxy_pass, with three headers set
remote_addr       = 172.19.0.3
x_forwarded_for   = 172.19.0.1        <- the visitor
x_forwarded_proto = http
host              = correct.example   <- the site the visitor asked for

The three lines that fix it:

proxy_set_header Host              $host;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

Every symptom of getting this wrong looks like an application bug:

  • Every visitor has the same IP address in your logs and analytics. That address is the proxy.
  • Rate limiting and brute-force protection lock out everybody at once, because to the application there is only one client.
  • Canonical URLs, redirects and generated links point at an internal name like app, because the Host header was replaced.
  • An infinite redirect loop on an HTTPS site. The proxy terminates TLS and speaks plain HTTP to the backend, so the application sees http, redirects to https, and the proxy sends it round again. X-Forwarded-Proto exists precisely for this, and the application has to be told to trust it.

RFC 7239 standardised all of this into one Forwarded header in June 2014, noting that the common practice was "non-standard header fields such as X-Forwarded-For, X-Forwarded-By, and X-Forwarded-Proto". Twelve years on, the non-standard three are still what you will find in almost every configuration, alongside X-Forwarded-Host, which some setups use to carry the original hostname when the proxy overwrites Host with the upstream name instead of preserving it.

One warning: these headers are supplied by whoever connects. A client can send X-Forwarded-For: 1.2.3.4 directly and your application will believe it unless the proxy overwrites the header and the application only trusts it from known proxy addresses. Treat an unfiltered X-Forwarded-For as user input, because that is what it is.

6.6 The Cost of Per-Directory Configuration

Apache can be configured from files inside the document root. It is the feature that made shared hosting usable, because a customer with no server access can still write rewrite rules, and it is the reason .htaccess exists in every Joomla and WordPress install.

It is not free, and Apache's own documentation says so:

When AllowOverride is set to allow the use of .htaccess files, httpd will look in every directory for .htaccess files. Thus, permitting .htaccess files causes a performance hit, whether or not you actually even use them! Also, the .htaccess file is loaded every time a document is requested.

And it does not look in one directory, it walks the whole chain. For a file in /www/htdocs/example, the documentation lists four lookups that happen on every single request, "even if none of those files are present":

/.htaccess
/www/.htaccess
/www/htdocs/.htaccess
/www/htdocs/example/.htaccess

nginx has no equivalent and never will, which is the single largest architectural difference between the two. Directives live in the main configuration, are read once at startup, and cost nothing per request. The trade is flexibility for speed, and it is why moving a site from Apache to nginx means porting its .htaccess rules by hand.

If you control the main configuration, move the rules there and set AllowOverride None. If you are on shared hosting, you cannot, and that is the honest price of the convenience.

Back to top

7. Something Most Users Do Not Know

7.1 The Request Line Can Override the Host Header

Everybody learns that the Host header selects the site. There is an older form of the request line that carries the whole URL, and when a server receives it, the Host header stops counting. RFC 9112 is categorical:

When an origin server receives a request with an absolute-form of request-target, the origin server MUST ignore the received Host header field (if any) and instead use the host information of the request-target.

Against the three-site configuration from section 5.2, with the two names deliberately disagreeing:

$ printf 'GET http://beta.example/ HTTP/1.1\r\nHost: alpha.example\r\nConnection: close\r\n\r\n' \
  | nc 127.0.0.1 18080 | tail -1
you reached BETA

The Host header said alpha.example and beta.example answered. The form exists because proxies have always needed it, and origin servers are required to handle it.

The consequence is worth knowing if you ever write host-based filtering: a rule that inspects only the Host header does not see the name the server will actually use. Any such check belongs in the server's own virtual host matching, which handles both forms, rather than in an application-level comparison of one header.

7.2 The Default Site's Certificate Names a Stranger

Ask a shared server for a certificate without telling it which site you want, and it has to present something. It presents the default site's certificate - and a certificate is a public document with a hostname in it.

$ echo | openssl s_client -connect 23.88.98.40:443 -servername petermartin.nl 2>/dev/null \
  | openssl x509 -noout -subject -dates
subject=CN = petermartin.nl
notBefore=Sep 10 09:52:53 2026 GMT
notAfter=Dec  9 09:52:52 2026 GMT

$ echo | openssl s_client -connect 23.88.98.40:443 2>/dev/null \
  | openssl x509 -noout -subject -dates
subject=CN = essen.db8.dev
notBefore=Sep 17 07:59:48 2026 GMT
notAfter=Dec 16 07:59:47 2026 GMT

Drop the -servername flag and the same address, in the same second, identifies itself as a completely different website. Nothing is broken and nothing is leaking that was ever secret - certificates are published to public transparency logs by design - but it surprises people the first time, and it is the fastest way to find out which site on a server is the catch-all.

It also explains a support ticket you will eventually receive. An old client, a scanner or a monitoring tool that does not send SNI will report "wrong certificate" for your site, and the certificate it names will belong to whichever site is the catch-all - your own second domain if you are lucky, a stranger's if you are on shared hosting. The tool is not wrong; it just asked the question without giving its name.

7.3 The Default Site Can Have a Different Configuration Entirely

Because the default virtual host is not a site, it is the one that misses whatever you applied to your sites. The server used for this article hides its software thoroughly: there is no Server header on any response, from any hostname, over either protocol.

$ curl -sI  https://petermartin.nl/ | grep -ic '^server:'
0
$ curl -skI https://23.88.98.40/    | grep -ic '^server:'
0

That is more than most sites do, and it is still not the whole story. Ask the default site for a page that does not exist:

$ curl -sk https://23.88.98.40/no-such-page
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>openresty</center>
</body>
</html>

The header is gone and the software signed the page instead. Every web server ships built-in error pages, every one of them carries the product name in the body, and no header setting touches them, because they are not headers. Ask a configured site the same question and you get that site's own error page, which ends without a signature:

$ curl -s https://petermartin.nl/no-such-page-xyz | tail -3
        </div>

    </body>

Two lessons, and the second is the general one.

Hiding the Server header is not the same as hiding the server. The name still travels in the body of every error page you did not write yourself. And it is not vague: openresty names a specific nginx distribution, which is more than a generic Server header would have given away. Closing that gap means defining your own error_page for the 4xx and 5xx range, on the default server as well as on the sites.

And the shape of the mistake is the thing to remember. The default site got the header setting because that was applied server-wide. It did not get a custom error page, because error pages are configured per site and the default is not a site. Hardening, security headers, redirects, logging and rate limits are nearly always written per site, and the block that catches everything else quietly gets none of them.

Which is why the test that matters is the one nobody runs. Request your own hostname and you see what you configured. Request the bare IP address, or any name the server does not recognise, and you see what you forgot. Test the way a scanner does, not the way a visitor does.

7.4 PHP-FPM Refuses to Execute Your Images

Many servers decide "this is PHP" by looking for .php anywhere sensible in the path. A pattern like ^/(.*\.php(/.*)?)$ matches /index.php/some/path on purpose, because applications use trailing path segments as routes. It also matches this:

$ curl -skI https://local.test.nl/media/system/js/core.js/x.php | head -4
HTTP/1.1 403 Forbidden
Date: Wed, 16 Sep 2026 15:44:54 GMT
Server: Apache/2.4.66 (Unix) OpenSSL/3.5.5
X-Powered-By: PHP/8.3.26

Look at the X-Powered-By header: PHP answered. The web server did forward it. PHP-FPM then worked out that the real file on disk is core.js, not x.php, and refused:

NOTICE: Access to the script '/usr/local/apache2/htdocs/media/system/js/core.js'
        has been denied (see security.limit_extensions)

This is the surviving defence against a genuine class of attack: upload something that is not an image but is named like one, then request it with /x.php appended, and a naive configuration executes it. FPM's security.limit_extensions setting refuses to run anything whose real extension is not on a short list, and the default list is short.

Two things follow. If you have ever seen advice to set security.limit_extensions = to an empty value to fix an odd routing problem, that advice removes the defence. And if you write the rewrite rules yourself, do not rely on FPM alone - match the executable path exactly rather than "contains .php", and never let an upload directory be executable.

7.5 There Is a Status Code for "You Asked the Wrong Server"

HTTP/2 lets a browser reuse one connection for several hostnames, if the certificate covers them and they resolve to the same address. It saves handshakes, and it means a request can arrive on a connection that was opened for a different site.

RFC 9110 gives the server a way to say so. Status 421 Misdirected Request "indicates that the request was directed at a server that is unable or unwilling to produce an authoritative response for the target URI", and section 7.4 spells out when it applies: when "the information within a received Host header field differs from the connection's host or port", which "might indicate an attempt to bypass security filters, trick the server into delivering non-public content, or poison a cache".

You will meet it as a mystery. A page loads perfectly on its own and fails with 421 when reached from another of your sites, or works in one browser and not another. The client is allowed to retry on a fresh connection, and most do, which is why the failure is intermittent. It is almost always a certificate covering more names than the server is configured to serve on that connection.

7.6 A Request With No Host Header Is a Protocol Error

The rule from section 3.1 has teeth. Send a malformed Host value and you do not reach any site at all:

$ printf 'GET / HTTP/1.1\r\nHost: al pha\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 18080 | head -1
HTTP/1.1 400 Bad Request

The space makes it an invalid field value, and RFC 9112 requires a 400. No virtual host was selected, no document root was consulted, nothing was logged in your site's access log. This is why a burst of 400s in a server-wide log with nothing in the site logs is normal rather than alarming: it is scanners and broken clients being rejected before any site sees them.

7.7 Your Application Trusts a Name Anyone Can Choose

Section 5.1 quoted Apache's documentation: the server "relies on the client to report the hostname". That is safe for the server, which compares the name against a list it configured and falls back to the default when nothing matches. It is not safe for the application behind it, which usually takes the name and builds things out of it.

Generated absolute URLs, canonical tags and password-reset links are the common cases. If an application constructs a reset link from the incoming hostname, a request carrying a Host header the attacker chose can produce an email containing a link to the attacker's server, with a valid token in it. The application never validated the name because the server appeared to have validated it already. It did not: it only decided which site should answer.

RFC 9110 flags this in the same paragraph that defines the field:

Since the host and port information acts as an application-level routing mechanism, it is a frequent target for malware seeking to poison a shared cache or redirect a request to an unintended server.

Two defences, and you want both. Configure a default virtual host that answers nothing useful, so an unrecognised name never reaches an application at all. And inside the application, build absolute URLs from a configured site address rather than from the request - almost every framework has a setting for exactly this, and it is usually empty.

7.8 Knowing Where This Article Stops

  • It stops at one server. Load balancers, failover, session affinity and shared storage change every answer about "which machine answered".
  • It stops at name-based virtual hosting. IP-based virtual hosting still exists, and so does serving different sites on different ports.
  • It stops before the application. What your CMS does with the request - routing, database, template, cache - is a subject of its own.
  • It stops at the reverse proxy in front of you. A CDN edge may answer without your server ever hearing about the request.
  • It stops at Apache, nginx and PHP-FPM. LiteSpeed, Caddy, IIS, Node and Python application servers make the same five decisions with different vocabulary.
Back to top

8. Best Practices

  • Configure a default virtual host deliberately. Give it a blank page or a 404 and no site content. If you skip this, one of your real sites becomes the catch-all for every scanner and every misconfigured domain pointed at your address.
  • Test the bare IP address as well as the hostname. It is the fastest way to find out what your default site actually is, and whether your hardening reached it.
  • Use curl --resolve to test before DNS moves. It sets DNS, SNI and Host together, so the whole request is realistic including the certificate check.
  • Compare a static file against a page when a site is slow. If static is fast and pages are slow, stop looking at the web server.
  • Read the elapsed time on a gateway error. Instant means refused, which is a dead process. Exactly the timeout means running and stuck, which is a different investigation.
  • Check X-Powered-By, or whatever your stack sets, before debugging application code. If it is absent, the request never reached your application.
  • Set Host, X-Forwarded-For and X-Forwarded-Proto on every proxy_pass, and configure the application to trust them only from your proxy's address.
  • Count your PHP workers against your busiest minute. A pool of two serves two requests at a time no matter how fast the database is.
  • Keep secrets above the document root. The boundary is enforced for free; a file placed inside it is one misconfigured handler away from being downloadable.
  • Move .htaccess rules into the main configuration when you can, and set AllowOverride None. Apache's own documentation recommends it, and it removes a filesystem lookup per directory per request.
  • Read the primary documentation rather than a tutorial. Virtual host selection is fully specified in Apache's name-based virtual hosts page and nginx's server names page, and the protocol rules behind them are in RFC 9110 and RFC 9112. All four are free and all four are shorter than the blog post you were about to read.
Back to top

9. Common Mistakes

9.1 Myth Versus Reality

MythReality
Each website needs its own IP address It has not since January 1997. The Host header carries the name, and SNI carries it again for the certificate
The server knows which site you wanted because of DNS DNS finished before the connection opened. The server never sees the lookup, only the name the client repeats in the request
A wrong certificate means the site is misconfigured It can equally mean the client did not send SNI and got the default site's certificate
"The server is down" Usually the application is down and the server is dutifully reporting it. Static files still work
502 and 504 are both "backend broken" 502 is refused instantly; 504 is a backend that answered too slowly. Different causes, different fixes
A 500 is always the application's fault Apache also returns 500 Proxy Error when it cannot reach the backend at all, before any code runs
Behind a proxy the visitor's IP is in REMOTE_ADDR It is the proxy's. The visitor is in X-Forwarded-For, if the proxy was configured to put it there
X-Forwarded-For can be trusted It is a header. Anyone can send one. Trust it only from addresses you control
The Host header always decides which site answers An absolute-form request line overrides it, by requirement of RFC 9112
More RAM or CPU will fix the concurrency limit If the worker pool is the limit, requests queue no matter how idle the hardware is
.htaccess is free because the file is small Enabling it costs a lookup in every parent directory on every request, whether or not the files exist

9.2 Other Traps to Avoid

  • Letting configuration file order decide your default virtual host. In Apache the first one listed wins, so adding an Include can silently move the catch-all to a different customer.
  • Hardening every site and forgetting the fall-through. Security headers, redirects, custom error pages and logging applied per site do not apply to the block that is not a site.
  • Testing HTTPS with -H 'Host: ...' instead of --resolve. It changes the header but not SNI, so you test a combination no browser produces.
  • Copying Apache rewrite rules into nginx. There is no .htaccess to copy them into, and the directives are not equivalent.
  • Leaving a debugger enabled in production. The local stack used for this article logged Xdebug: [Step Debug] Time-out connecting to debugging client, waited: 200 ms for the requests measured here, because no debugger was listening. That is a fixed wait added before any application work begins, and it is why the local timings in this article are not comparable with the live ones. It belongs in development only.
  • Assuming an upload directory is safe because it only holds images. It holds whatever was uploaded. Make sure nothing in it can be executed, rather than trusting the file extension.
  • Reading a gateway error as a network problem. A 502 reached you, which means DNS, TCP, TLS and the web server all worked.
  • Debugging the application when X-Powered-By is missing. The request never got there.
  • Deploying to an origin behind a CDN and declaring it live. The edge is entitled to keep serving the old copy, and your server never hears the request.
Back to top

10. Summary

  • A web server is a program, not a machine. RFC 9110 defines it as a role held for one connection, which is why a reverse proxy can be a server and a client at the same time.
  • Every request is five decisions: which certificate, which site, which path, who produces the bytes, what goes back.
  • The IP address stops mattering the moment the packets arrive. The hostname is carried in the request, not in the routing.
  • SNI picks the certificate and the Host header picks the site. Measured on one live server, a request with the right SNI and the wrong Host was served by a different website, and a request with no SNI and the right Host was served correctly under the default site's certificate.
  • Every server has a default virtual host, chosen deliberately or by accident, and it answers for the bare IP, unknown names and every scanner on the internet.
  • The default site is the one your hardening misses. On the server measured here the Server header is suppressed everywhere, and the default site's built-in 404 page still signs itself openresty in the body. Hiding the header is not hiding the server.
  • Static files are nearly free. On the live site a static file arrived in 85 to 132 ms and a CMS page in 231 to 306 ms over the same connection; the roughly 145 ms difference is entirely the application.
  • A 404 with X-Powered-By and a 404 without it came from two different programs. That header tells you whether your code ran.
  • "The site is down" usually means the application is down. With PHP-FPM stopped, static files still returned 200 and only dynamic pages failed.
  • 502 is instant and 504 takes exactly the timeout - measured at 0.0009 s and 3.004 s against a 3-second limit. The clock is the diagnosis.
  • Behind a proxy your application sees the proxy, including its hostname, unless three headers are set - and those headers are user input unless you filter them.
  • An absolute-form request line overrides the Host header, by requirement, so host filtering that reads only the header is incomplete. On HTTP/2 and HTTP/3 the name travels as the :authority pseudo-header instead, and the two are "no longer permitted to disagree".
  • The hostname is chosen by the client, and the server only routes on it. An application that builds password-reset links or canonical URLs from the incoming name is trusting a value anyone can set.
  • A rewrite is internal and free; a redirect is a whole extra round trip. Same configuration file, completely different cost.
  • PHP-FPM refuses to execute a file whose real extension is not PHP, which is the last line of defence against an uploaded image being run as code.
  • Enabling .htaccess costs a filesystem lookup per parent directory per request, present or not, by Apache's own documentation.
THE FIVE DECISIONS  (in order, on every request)
 1 certificate   ├→ chosen by SNI, before encryption exists
 2 which site    ├→ chosen by Host: / :authority, after it
 3 which path    ├→ document root + path + index + rewrites
 4 who answers   ├→ a file, or an application behind FastCGI
 5 what goes out └→ status + headers + body

ONE IP, MANY SITES
DNS  name → address        then the name is GONE from the connection
TLS  SNI names the host     → picks the CERTIFICATE
HTTP Host: names the host   → picks the SITE
     they can disagree; nothing requires a server to cross-check
     HTTP/2 + HTTP/3: the name is the :authority pseudo-header
     └→ there, Host and :authority may NOT disagree (RFC 9113/9114)

  SNI          Host         certificate        site that answers
  site A       site A       site A             site A
  site A       unknown      site A             THE DEFAULT SITE
  (none)       site A       the default's      site A

DEFAULT VIRTUAL HOST  answers for:
  the bare IP address / unknown hostnames / no Host header
  nginx   listen 80 default_server;     (a listen param, NOT server_name)
  apache  the FIRST <VirtualHost> listed for that address:port
  └→ if you did not choose one, a real customer site is it

NAME MATCHING ORDER
  nginx   exact → longest *.name wildcard → longest name.* → first regex
  apache  best address:port → ServerName → ServerAlias → first listed

TESTING WITHOUT CHANGING DNS
  curl --resolve name:443:IP https://name/    sets DNS + SNI + Host  (use this)
  curl -H 'Host: name' http://IP/             sets the header only
  openssl s_client -connect IP:443 -servername name
  openssl s_client -connect IP:443            no SNI → the DEFAULT cert

WHICH LAYER ANSWERED?
  X-Powered-By present   your application ran
  X-Powered-By absent    it did not; stop debugging the code
  Server:                the web server - when it is not suppressed
  built-in error page    signs itself in the BODY; no header setting hides it
  bare IP vs hostname    compare them: the default vhost is a different config

WHEN THE BACKEND FAILS  (the clock is the diagnosis)
  502  instant              connection refused   → process is not running
  504  exactly the timeout  accepted, no answer  → slow query / stuck / pool full
  500  varies               app error, OR apache's proxy failure
  503  varies               deliberate: maintenance, rate limit, no worker free
  static file still 200?    the web server is fine; only the app is gone

BEHIND A REVERSE PROXY  (set all three, trust none of them blindly)
  proxy_set_header Host              $host;
  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  missing Host   → links and canonicals point at the upstream name
  missing XFF    → every visitor shares one IP; rate limits lock out everyone
  missing XFP    → https redirect loop, because the app only ever sees http
  RFC 7239 standardised "Forwarded"; nobody uses it

A REQUEST IS JUST TEXT
  GET /en/ HTTP/1.1          origin-form: Host: decides the site
  GET http://b/ HTTP/1.1     absolute-form: the URL WINS, Host: is ignored
  no Host / invalid Host     400, before any site is selected
  the name is CLIENT-SUPPLIED: route on it, never build links from it
  └→ reset links + canonicals come from configured site URL, not Host:

REWRITE vs REDIRECT  (same config file, different cost)
  rewrite   /news/x → /index.php internally   1 request,  URL unchanged
  redirect  /old    → 301 + Location: /new    2 requests, URL changes
  └→ a 3-hop redirect chain can cost more than the page it leads to

SERVER-SIDE COSTS
  .htaccess     one lookup per PARENT DIRECTORY per request, present or not
  FPM workers   pool size = max concurrent PHP requests; the rest queue
  static file   size, mtime and range all come free from the filesystem

Verified 16 September 2026 against RFC 9110, RFC 9112, RFC 9113 and RFC
9114 (all June 2022), RFC 6066 (January 2011), RFC 3875 (October 2004),
RFC 7239 (June 2014), RFC 2068 (January 1997) and RFC 1945 (May 1996),
plus the Apache 2.4 and nginx documentation.
Measurements: the live server behind petermartin.nl (17 Sep 2026), a
local Apache 2.4.66 with PHP-FPM 8.3.26, and a purpose-built nginx 1.29
configuration (both 16 Sep 2026).

The next time a site returns the wrong page, or the wrong certificate, or a gateway error that nobody can reproduce, the question to ask is not "is the server up". It is which of the five decisions went somewhere unexpected - and when a visitor swears they reached a completely different company's website by typing your domain, they probably did: they found the default virtual host, and somebody never configured one.

Back to top
What Happens on the Server When a Web Request Arrives
Peter Martin
Peter Martin
Joomla Specialist

Peter is a Joomla specialist and a Linux admin for fast, secure and scalable websites.