What is HTTP? A server admin's guide
HTTP explained for people who run servers: methods, status codes, the headers that matter, what nginx logs, and where HTTP/3 and TLS fit in.
What is HTTP?
HTTP (hypertext transfer protocol) is the set of rules a client and a web server use to ask for something and send it back. The client sends a request: a method such as GET, a path such as /pricing, a protocol version, a list of headers, and sometimes a body. The server answers with a status code such as 200, followed by its own headers and usually a body. Every page view and every API (application programming interface) call on your server is that one exchange, repeated.
HTTP keeps no state of its own. The server does not remember what you asked for a second ago, so anything that behaves like memory, a login session for example, is carried in a header on every single request. That one property explains much of what follows: caching is entirely header driven, and a load balancer can send your next request to a different backend without breaking anything.
Everything below is what that model looks like from the server side, in your access log and in your nginx config.
A raw request and response, annotated
Here is a complete HTTP/1.1 request. A blank line ends the headers, and anything after that line is the body. A GET normally has no body.
GET /pricing HTTP/1.1
Host: example.com
User-Agent: curl/8.5.0
Accept: */*
Accept-Encoding: gzipGETis the method, which says what you want done.GETreads,POSTsends data,PUTreplaces,DELETEremoves,HEADasks for the headers of aGETwithout the body./pricingis the path. The hostname is not part of the request line, which is why the next header exists.HTTP/1.1is the protocol version the client is speaking.Host: example.comnames the site the client wants. HTTP/1.1 requires it, so nginx answers a request without one with400 Bad Request.- The rest are preferences.
Accept-Encoding: gzipsays the client can decompress, so the server is allowed to compress the body.
The response has the same shape with a status line on top.
HTTP/1.1 200 OK
Date: Thu, 06 Aug 2026 09:12:44 GMT
Server: nginx
Content-Type: text/html; charset=utf-8
Content-Length: 5310
Cache-Control: public, max-age=300
<!doctype html>...200 OKis the status code with its reason phrase. The code is what matters. The phrase is decoration and clients ignore it.Content-Typetells the client how to treat the bytes that follow.Content-Lengthis the body size in bytes, so the client knows where the body ends. When the size is not known in advance the server sendsTransfer-Encoding: chunkedinstead and marks the end with a zero length chunk.Cache-Controltells the browser and any cache in between how long they may keep this response.- The blank line after the headers separates them from the body, in both directions.
Header names are case insensitive, and every line ends with a carriage return followed by a line feed rather than a bare newline. You will not type these by hand, but you will meet them in a packet capture.
To watch a real pair, run this against a site you own:
curl -sS -o /dev/null -D - https://example.com/-D - writes the response headers to your terminal and -o /dev/null throws the body away. Prefer this to curl -I, because -I sends a HEAD request. An application server that handles HEAD differently from GET, and many do, will then show you headers no browser ever receives. curl -v prints both sides, with request lines marked > and response lines marked <.
What the request line looks like in your nginx access log
nginx ships a combined log format, and this is its definition:
log_format combined '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';One line produced by it:
203.0.113.45 - - [06/Aug/2026:09:12:44 +0000] "GET /pricing HTTP/1.1" 200 5310 "https://example.com/" "Mozilla/5.0 (X11; Linux x86_64) Chrome/127.0.0.0 Safari/537.36"203.0.113.45is$remote_addr, the address that opened the TCP (transmission control protocol) connection. Behind a proxy this is the proxy, not the visitor.- The first
-is a fixed placeholder. The second is$remote_user, which is filled only when HTTP basic authentication is in use. "GET /pricing HTTP/1.1"is$request, the request line copied exactly as it arrived.200is the status your server returned, not the status the visitor perceived.5310is$body_bytes_sent, the body alone. Response headers are not counted, so this number is always smaller than the bytes actually sent.- The last two quoted fields are
RefererandUser-Agent. Both come from the client, so both can hold anything.
Because $request is copied verbatim, junk appears verbatim. A client that speaks TLS (transport layer security) to your plaintext port 80 leaves a 400 line whose request field starts with escaped bytes like "\x16\x03\x01\x02\x00\x01". \x16 is the TLS handshake record type, so those bytes are the start of a ClientHello and not a request line at all. Your server is behaving correctly. Something is pointing HTTPS at an HTTP port.
Add $server_protocol to your log format as well. It prints HTTP/1.1, HTTP/2.0 or HTTP/3.0, and it is the fastest way to prove that a protocol change actually took effect.
What the common status codes mean when your own site returns them
The first digit is the class, and the class is what you should read first.
2xx means it worked. 200 OK for a normal read. 201 Created after a POST that created something. 204 No Content for a success with nothing to send back, which is the usual answer to a DELETE.
3xx means look elsewhere. 301 is permanent and browsers cache it hard, sometimes until the user clears their profile, so a 301 pointing at the wrong hostname is painful to undo. Use 302 while you are still testing a redirect. 304 Not Modified is a success, not an error: the client sent If-None-Match carrying an ETag (entity tag) you still recognise, so you replied with headers and no body. A log full of 304s means caching is working.
4xx means the request was wrong. 400 Bad Request is malformed input. 401 Unauthorized really means unauthenticated, and it must carry a WWW-Authenticate header naming the scheme. 403 Forbidden means the request was understood and refused anyway. 404 Not Found is a path that does not exist. 405 Method Not Allowed is the right path with the wrong method, which is what a POST to a static file location returns. 413 is a body larger than nginx's client_max_body_size, which defaults to 1 megabyte, and the error log confirms it with client intended to send too large body.
A 403 on a static file is almost always the filesystem rather than an HTTP rule. Read /var/log/nginx/error.log before changing any config. open() "/srv/site/index.html" failed (13: Permission denied) means the nginx worker user cannot read the file, most often because a parent directory is missing execute permission for others. directory index of "/srv/site/" is forbidden means the path resolved to a directory that has no index file while autoindex is off.
5xx means your side broke. 500 is an unhandled error in your application. 502 Bad Gateway means nginx could not get a usable response from the upstream, and the error log names the cause: connect() failed (111: Connection refused) while connecting to upstream means nothing is listening on the address in proxy_pass. 504 Gateway Timeout means the upstream accepted the connection and then said nothing within proxy_read_timeout, 60 seconds by default, which the log records as upstream timed out (110: Connection timed out) while reading response header from upstream. 503 Service Unavailable is a deliberate refusal. Note that nginx's own rate limiter returns 503, because limit_req_status defaults to 503. If you are hunting for 429 Too Many Requests in your log and finding 503 instead, that is why. Set limit_req_status 429; to get the accurate code.
The headers that matter when you run the server
Host picks the site. One IP address can serve hundreds of hostnames, and nginx matches Host against server_name to decide which server block answers. If nothing matches, nginx uses the default server, which is the first block listening on that address and port unless another is marked default_server. Getting the wrong site back from a new virtual host is nearly always this: the name did not match, so the request fell through to the default. Test it without touching DNS:
curl -sS -o /dev/null -D - -H 'Host: app.example.com' http://127.0.0.1/User-Agent is a self description written by the client, and it is free text. Use it as a hint when reading logs. Never use it as a control, because a client that wants to lie about it simply does, so blocking a scraper by User-Agent filters out the polite ones only.
Content-Type decides how the bytes are interpreted: application/json for an API request, text/html; charset=utf-8 for a page. nginx maps file extensions to types with /etc/nginx/mime.types, and the packaged nginx.conf sets default_type application/octet-stream;, so a file with an extension nginx does not know is offered as a download instead of rendered. The visible symptom is a page that loads with no styling while the browser console prints Refused to apply style from ... because its MIME type ('text/plain') is not a supported stylesheet MIME type. MIME here is multipurpose internet mail extensions, the naming scheme those type strings come from.
Cache-Control is how you control every cache between your server and the reader. public, max-age=31536000, immutable suits assets whose filename contains a content hash, because the name changes when the content does. no-store belongs on anything user specific, because a shared cache that keeps a logged in page will hand it to the next person who asks for the same URL. private is the middle setting: the browser may keep it, a shared cache may not.
X-Forwarded-For exists because a proxy hides the visitor. Once a request passes through a reverse proxy, $remote_addr is the proxy's address, so your logs, your geolocation and your rate limiting all see one client. The proxy has to pass the original address along:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;The receiving server then has to be told to believe it, and told exactly who to believe:
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;List only ranges you control. X-Forwarded-For is plain text that any client can send, so set_real_ip_from 0.0.0.0/0; lets a visitor pick the address you log and the address your rate limiter counts.
X-Forwarded-Proto prevents a specific and very common failure. Your proxy terminates TLS and forwards the request to the application over plain HTTP. The application sees a plain request, decides the visitor should be on HTTPS, and answers 301 https://example.com/. The browser follows it, the proxy terminates TLS again and forwards plain HTTP again, and the loop repeats until the browser gives up with ERR_TOO_MANY_REDIRECTS. Sending X-Forwarded-Proto: https tells the application the visitor is already on HTTPS, so it stops redirecting.
HTTP/1.1 vs HTTP/2 vs HTTP/3: what changes for you
HTTP/1.1 is text, and it handles one request at a time per connection. Connection: keep-alive lets the next request reuse the same TCP connection, which saves the setup cost, but responses still come back in the order they were requested. One slow response blocks everything queued behind it. That is head-of-line blocking, and browsers work around it by opening several connections to the same hostname at once.
HTTP/2 keeps the same methods and the same status codes, and changes the framing to binary. Many requests share one connection as independent streams, and repeated header text is compressed, which matters because a modern request carries a lot of it. The connection is still TCP, so one lost packet stalls every stream on that connection until the retransmission arrives. The head-of-line blocking did not disappear. It moved down from HTTP to the transport. Server push was part of HTTP/2 and is gone in practice, because Chrome removed support for it in 2022.
HTTP/3 keeps the same semantics again and replaces TCP with QUIC, a transport built on UDP (user datagram protocol). QUIC streams are independent all the way down, so a lost packet stalls only the stream it belonged to. TLS 1.3 is built into the QUIC handshake rather than layered on top, so a new connection needs fewer round trips. Two practical consequences follow: UDP port 443 must be open in every firewall on the path, and any network that throttles or blocks UDP will push clients back to HTTP/2.
What changes for you, concretely. Browsers never start on HTTP/3. They connect over HTTP/2 or HTTP/1.1, see an Alt-Svc: h3=":443"; ma=86400 header on the response, and use HTTP/3 for later connections to that host. So the header is not optional decoration. It is the discovery mechanism. In nginx, HTTP/2 became its own directive in version 1.25.1 (http2 on; inside the server block, replacing the old listen ... http2 parameter), and QUIC arrived in mainline 1.25.0, where an HTTP/3 site needs listen 443 quic reuseport; alongside the ordinary listen 443 ssl;.
Proxies differ in maturity here, and this is worth checking against the version you actually run. As of August 2026, Caddy serves HTTP/3 by default with no configuration. nginx needs the explicit quic listener plus the Alt-Svc header described above. Traefik enables it per entry point through an explicit http3 option. If you terminate TLS at Traefik in front of several Docker apps, the protocol version your visitors get is decided there, and the hop from the proxy to your container is usually plain HTTP/1.1 whatever the browser negotiated.
Verify rather than assume. curl --http3 -sS -o /dev/null -D - https://example.com/ works only if curl -V lists HTTP3 among its features, and most distribution builds do not include it. The dependable check is your own log: add $server_protocol to the format and read what real browsers negotiate. Before any of that, confirm UDP 443 is actually open, because a firewall that permits TCP 443 only will let HTTP/3 fail quietly while the site keeps working over HTTP/2. Knowing which ports are open and listening on your Linux server is the first thing to check.
HTTPS: HTTP is the protocol, TLS is the wrapper
HTTPS is not a separate protocol. It is the same requests and the same status codes carried inside a TLS session. Port 80 carries them in the clear and port 443 carries them encrypted. The TLS handshake completes first, then the HTTP request travels inside the encrypted channel. That order is why a certificate problem never has a status code attached: the failure happens before a single HTTP byte is sent, so there is no response to number.
One ordering detail matters on a server hosting several sites. The certificate is chosen using SNI (server name indication), a field in the TLS handshake that carries the hostname in the clear before any HTTP header exists. So the server picks a certificate from SNI first, then picks a virtual host from the Host header second. They are two separate lookups that normally agree. When they do not, the browser shows a name mismatch such as NET::ERR_CERT_COMMON_NAME_INVALID and sends no request at all, because your default server's certificate was offered for a name it does not cover.
For a public site, get a real certificate and let it renew itself. Certbot with Let's Encrypt on nginx writes the certificate paths into your server block and installs the renewal timer for you. For a hostname no public authority can validate, such as an internal name or a bare IP address on your own network, a self-signed certificate on Ubuntu is the honest option, as long as you accept that every client has to be told to trust it.
Once TLS works, send everything on port 80 to port 443:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}Add Strict-Transport-Security only when you are certain. The header add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; tells browsers to refuse plain HTTP for that hostname for two years, and they obey it from their own cache, which means removing the header later does not undo it. Start with a max-age of a few hours, confirm every subdomain really is on HTTPS, then raise it.
FAQ
What is the difference between HTTP and HTTPS?
HTTPS is HTTP sent inside a TLS (transport layer security) session. The methods and the status codes are identical. What changes is that the bytes are encrypted between the client and whatever terminates TLS, and the default port moves from 80 to 443. Because the TLS handshake finishes before the first HTTP byte is sent, a certificate failure never produces a status code, which is why a browser certificate warning shows an error name such as NET::ERR_CERT_COMMON_NAME_INVALID instead of a number like 403.
Why does my site return 502 Bad Gateway?
A 502 from nginx means nginx could not get a usable response from the upstream it proxies to, so the visitor's request was fine and something behind nginx was not. Read /var/log/nginx/error.log. connect() failed (111: Connection refused) while connecting to upstream means nothing is listening on the address and port in proxy_pass, so check that the application is running and bound where you expect. no live upstreams while connecting to upstream means every server in the upstream block has been marked down after repeated failures. Compare with 504 Gateway Timeout, which means the upstream did accept the connection and then failed to answer within proxy_read_timeout.
Why does my access log show the same IP address for every visitor?
Because $remote_addr records the address that opened the TCP connection, and behind a reverse proxy or a content delivery network that address is the proxy. The visitor's address arrives in the X-Forwarded-For header instead. Set proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; on the proxy, then on the receiving nginx set set_real_ip_from to the proxy's address range and real_ip_header X-Forwarded-For;. List only ranges you control, because that header is text any client can send, so trusting it from the whole internet lets a visitor choose the address you log and the address you rate limit.
Do I need to turn on HTTP/2 or HTTP/3?
HTTP/2 is worth turning on, because it is one directive on a site that already has TLS and it removes the per connection request limit that makes a page with many small files slow. HTTP/3 is a smaller and less certain gain, and it costs you an open UDP port 443 plus a proxy build with QUIC support. Remember that browsers switch to HTTP/3 only after they see an Alt-Svc header on an earlier response, so without that header nothing changes no matter what your listen line says. Add $server_protocol to your log format and measure what visitors actually negotiate before spending time on it.
What does 403 Forbidden mean when the file exists?
On a static site a 403 is usually a filesystem permission rather than an HTTP rule. open() ... failed (13: Permission denied) in /var/log/nginx/error.log means the nginx worker user cannot read the file, most often because a parent directory lacks execute permission for others rather than because the file mode itself is wrong. directory index of ... is forbidden means the request resolved to a directory with no index file while autoindex is off. An explicit deny rule in the matching location block also returns 403, so read that block when the error log says nothing.