Skip to main content
Networking · lesson 5 of 8
Networking intermediate Lesson 5 of 8

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

networking http nginx curl netcat docker

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

  1. Leave the connection open. Send the api.lab.test request without Connection: close, and give nc an idle timeout of three seconds with -w 3. What does the response’s Connection header say, and why doesn’t nc exit 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.

  1. Two requests, one connection. Send a request for api.lab.test and one for docs.lab.test back to back in a single printf, with Connection: close only 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.

  1. Headers only. Send the docs.lab.test request with the method HEAD instead of GET. Compare the Content-Length with 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.

  1. Make it a check. Using curl’s -w '%{http_code}', print just the status code for the Hosts api.lab.test, docs.lab.test and nope.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.

Frequently Asked Questions

What does an HTTP/1.1 request look like on the wire?
It is plain text sent over a TCP connection: a request line such as GET / HTTP/1.1, then header lines like Host: example.test, each ending in a carriage return and line feed, then an empty line that marks the end of the headers. A body, if any, follows the empty line, and its size is given by the Content-Length header.
Why does my HTTP request return 400 Bad Request without a Host header?
HTTP/1.1 requires every request to carry a Host header, because one IP address and port commonly serve many websites and the server needs to know which one you mean. A server such as nginx rejects an HTTP/1.1 request with no Host header with 400 Bad Request. HTTP/1.0 did not require it, so a 1.0 request without Host goes to the default site instead.
What is name-based virtual hosting?
It is serving several websites from one IP address and port, choosing the site from the request's Host header. In nginx, each server block lists its names in server_name, and the request goes to the block whose name matches Host. A request whose Host matches nothing goes to the default_server block, which is why a misconfigured client can get the wrong site or an unexpected 404.
Why does nc hang after the HTTP response?
HTTP/1.1 connections are persistent by default, so after answering, the server keeps the TCP connection open for further requests and nc keeps waiting. Send a Connection: close header to ask the server to close it after the response, or give nc an idle timeout with -w. HTTP clients reuse these open connections to avoid paying for a new TCP handshake on every request.
What do the > and < lines in curl -v output mean?
Lines starting with > are the request headers curl sent, and lines starting with < are the response headers it received. Lines starting with * are curl's own notes about name resolution, connecting and TLS. The body is printed without a prefix. Together they show exactly the text exchange you could type yourself over nc.