Networking · lesson 5 of 8
Beginner
4Intermediate
4Also part of Networking
11- + Linux Networking: Routes, Interfaces, and Bind Addresses from Linux
- + Bash Networking from Bash
- + Docker Networking from Docker
- + FastAPI HTTP Methods and Status Codes from FastAPI
- + FastAPI Headers and Cookies from FastAPI
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + VPC and Networking Basics from AWS
- + GCP VPC Networking Basics from Google Cloud
- + Azure Virtual Network Basics from Azure
- + The TLS Handshake, Run Locally with openssl s_server from Cryptography
- + Load Balancing: Round Robin vs Least Connections from System Design
Also part of: API Testing
HTTP on the Wire: a Request Is Just Text
Type raw HTTP/1.1 requests into nc against a local nginx: a missing Host header gets a 400, Host picks the site, keep-alive holds the connection open.
By SkillByExample · Updated
HTTP looks complicated from inside a framework, but on the wire an HTTP/1.1 request is a few lines of
text sent over the TCP connection you watched being opened in the
handshake lesson. This lesson types that text by hand with nc,
gets the server to reject it, fixes it, and then compares the result with curl -v. Tested with Docker
Engine 29.8.2, nginx 1.29.8 (nginx:1.29-alpine), Alpine 3.22.6 and curl 8.14.1.
The lab: one server, two sites
nginx serves two sites on the same address and port, plus a default for anything it doesn’t recognise:
# site.conf
server {
listen 80 default_server;
default_type text/plain;
return 404 "no site for this Host\n";
}
server {
listen 80;
server_name api.lab.test;
default_type text/plain;
return 200 "api: hello\n";
}
server {
listen 80;
server_name docs.lab.test;
default_type text/plain;
return 200 "docs: read me\n";
}
The client is Alpine with curl added. Busybox nc is already there.
FROM alpine:3.22
RUN apk add --no-cache curl
docker build -t http-lab .
docker network create --internal http-lab
docker run -d --name web --network http-lab -v "$PWD/site.conf":/etc/nginx/conf.d/default.conf:ro nginx:1.29-alpine
The smallest request, rejected
An HTTP/1.1 request is a request line, then headers, then an empty line. Lines end in \r\n. Here’s
the request line and the empty line, with no headers at all:
$ docker run --rm --network http-lab http-lab sh -c "printf 'GET / HTTP/1.1\r\n\r\n' | nc web 80"
HTTP/1.1 400 Bad Request
Server: nginx/1.29.8
Date: Tue, 13 Oct 2026 19:11:34 GMT
Content-Type: text/html
Content-Length: 157
Connection: close
<html>
<head><title>400 Bad Request</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<hr><center>nginx/1.29.8</center>
</body>
</html>
The TCP connection worked and nginx answered, so this isn’t a network problem. It’s a protocol one.
The response has the same shape as a request: a status line, headers, an empty line, then exactly
Content-Length: 157 bytes of body. nginx also sent Connection: close and hung up, which is why nc
returned.
The fix: say which site you mean
HTTP/1.1 makes the Host header mandatory. Add it, along with Connection: close so the server hangs up
after answering:
$ docker run --rm --network http-lab http-lab sh -c "printf 'GET / HTTP/1.1\r\nHost: api.lab.test\r\nConnection: close\r\n\r\n' | nc web 80"
HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Tue, 13 Oct 2026 19:11:35 GMT
Content-Type: text/plain
Content-Length: 11
Connection: close
api: hello
$ docker run --rm --network http-lab http-lab sh -c "printf 'GET / HTTP/1.1\r\nHost: docs.lab.test\r\nConnection: close\r\n\r\n' | nc web 80"
HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Tue, 13 Oct 2026 19:11:36 GMT
Content-Type: text/plain
Content-Length: 14
Connection: close
docs: read me
Both requests went to the same IP address and port, web:80, and got different sites. That’s why Host
is required: one address serves many names, and the TCP connection carries no name at all. DNS turned
the name into an address and then forgot it, so HTTP has to send the name again. nginx matches Host
against each server_name.
When Host matches nothing
$ docker run --rm --network http-lab http-lab sh -c "printf 'GET / HTTP/1.1\r\nHost: web\r\nConnection: close\r\n\r\n' | nc web 80"
HTTP/1.1 404 Not Found
Server: nginx/1.29.8
Date: Tue, 13 Oct 2026 19:11:37 GMT
Content-Type: text/plain
Content-Length: 22
Connection: close
no site for this Host
$ docker run --rm --network http-lab http-lab sh -c "printf 'GET / HTTP/1.0\r\n\r\n' | nc web 80"
HTTP/1.1 404 Not Found
Server: nginx/1.29.8
Date: Tue, 13 Oct 2026 19:11:37 GMT
Content-Type: text/plain
Content-Length: 22
Connection: close
no site for this Host
Host: web is valid, but no server_name matches, so the request lands on the default_server block.
This is a common surprise behind a load balancer or proxy that rewrites Host: the request reaches the
right machine and the wrong site. The HTTP/1.0 request had no Host at all and still wasn’t
rejected, because 1.0 predates the requirement. It also fell through to the default site. A 404 from
the default server and a 404 from your application look alike, so read the body.
The same exchange through curl
curl -v prints what it sends with > and what it receives with <:
$ docker run --rm --network http-lab http-lab sh -c "curl -sv http://web/ -H 'Host: api.lab.test'"
* Host web:80 was resolved.
* IPv6: (none)
* IPv4: 172.21.0.2
* Trying 172.21.0.2:80...
* Connected to web (172.21.0.2) port 80
* using HTTP/1.x
> GET / HTTP/1.1
> Host: api.lab.test
> User-Agent: curl/8.14.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Server: nginx/1.29.8
< Date: Tue, 13 Oct 2026 19:11:38 GMT
< Content-Type: text/plain
< Content-Length: 11
< Connection: keep-alive
<
{ [11 bytes data]
api: hello
* Connection #0 to host web left intact
It’s the same text you typed, plus User-Agent and Accept. The * lines are the layers below HTTP,
from the earlier lessons: name resolution, then the TCP connect. curl normally builds Host from the
URL, and -H overrides it, which is how you test a virtual host before its DNS exists. curl didn’t send
Connection: close, so the server answered keep-alive, and curl reports the connection “left intact”
for reuse. Methods and status codes in a real API are covered in
HTTP methods and status codes, and headers and cookies in
Headers and cookies. Clean up with docker rm -f web and
docker network rm http-lab.
Practice
- Leave the connection open. Send the
api.lab.testrequest withoutConnection: close, and givencan idle timeout of three seconds with-w 3. What does the response’sConnectionheader say, and why doesn’tncexit straight away?
Solution
$ docker run --rm --network http-lab http-lab sh -c "printf 'GET / HTTP/1.1\r\nHost: api.lab.test\r\n\r\n' | nc -w 3 web 80; echo exit=\$?"
HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Tue, 13 Oct 2026 19:11:53 GMT
Content-Type: text/plain
Content-Length: 11
Connection: keep-alive
api: hello
exit=0
Connection: keep-alive. HTTP/1.1 connections are persistent by default, so nginx waits for another
request, and nc sits there until its three-second idle timeout ends the session. Without -w, it would
wait for nginx’s own keep-alive timeout. A client that reads “until the server closes” instead of
reading Content-Length bytes hangs in exactly this way.
- Two requests, one connection. Send a request for
api.lab.testand one fordocs.lab.testback to back in a singleprintf, withConnection: closeonly on the second.
Solution
$ docker run --rm --network http-lab http-lab sh -c "printf 'GET / HTTP/1.1\r\nHost: api.lab.test\r\n\r\nGET / HTTP/1.1\r\nHost: docs.lab.test\r\nConnection: close\r\n\r\n' | nc web 80"
HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Tue, 13 Oct 2026 19:11:54 GMT
Content-Type: text/plain
Content-Length: 11
Connection: keep-alive
api: hello
HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Tue, 13 Oct 2026 19:11:54 GMT
Content-Type: text/plain
Content-Length: 14
Connection: close
docs: read me
One TCP handshake, two answers, in order. Content-Length is what lets the client know where the first
response ends and the second begins. Note that Host is per request, not per connection, so even two
sites can share one connection.
- Headers only. Send the
docs.lab.testrequest with the methodHEADinstead ofGET. Compare theContent-Lengthwith what you actually receive.
Solution
$ docker run --rm --network http-lab http-lab sh -c "printf 'HEAD / HTTP/1.1\r\nHost: docs.lab.test\r\nConnection: close\r\n\r\n' | nc web 80"
HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Tue, 13 Oct 2026 19:11:54 GMT
Content-Type: text/plain
Content-Length: 14
Connection: close
HEAD returns the headers a GET would, including Content-Length: 14, but no body. Health checks and
link checkers use it to ask “is it there, and how big is it?” without downloading anything.
- Make it a check. Using curl’s
-w '%{http_code}', print just the status code for the Hostsapi.lab.test,docs.lab.testandnope.lab.test, one per line.
Solution
$ docker run --rm --network http-lab http-lab sh -c 'for h in api.lab.test docs.lab.test nope.lab.test; do echo $h $(curl -s -o /dev/null -w "%{http_code}" -H "Host: $h" http://web/); done'
api.lab.test 200
docs.lab.test 200
nope.lab.test 404
-o /dev/null throws the body away, -s hides the progress meter, and -w prints the status after the
transfer. That one line is most of a smoke test for a virtual-hosting setup, and it’s the tool the
troubleshooting lesson uses to confirm each fix.