Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HTTP/1.1 Server from Scratch

Welcome to the HTTP/1.1 Server from Scratch course!

In this course, you will build an HTTP/1.1 server entirely from scratch, strictly following the official RFC specifications (RFC 9110 and RFC 9112).

By the end of this course, you will understand TCP sockets, request parsing, routing, chunked encoding, concurrent connection handling and much more!

Tip

If you’ve never looked under the hood of HTTP before, it might sound intimidating. But don’t worry! You’ll quickly discover that at its core, HTTP is surprisingly simple and elegant. It’s mostly just reading and writing formatted text over a socket. You’ve got this!

The Rules of the Game

You can write your server in any programming language you want. However, to get the most out of this course, you must follow one rule: No HTTP libraries.

You may use your language’s standard library for network sockets (e.g., net in Node.js, std::net in Rust, socket in Python) and concurrency. You may NOT use frameworks (like Express, Django, or Actix) or standard library HTTP modules (like Go’s net/http).

Prerequisites

To complete this course, you will need:

  • A programming language and runtime of your choice installed locally.
  • Docker installed and running (to run the evaluation suite).

How the Evaluation Suite Works

As you progress through the stages, you will test your code using our official test runner.

The setup works like this:

  1. You run your custom server locally, binding it to 0.0.0.0 on port 8080.
  2. You run our test suite via Docker.
  3. The Docker container fires a series of strict, edge-case HTTP requests at your server and evaluates the responses.

To run the entire evaluation suite against your server, open a separate terminal and run:

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080

What this command does:

  • --rm: Cleans up the container after the tests finish.
  • --add-host host.docker.internal:host-gateway: Ensures the container can communicate with your local machine’s localhost.
  • --addr: Tells the tester where to find your server.

Note

Instead of running the entire suite of tests every time, the runner uses an --until flag. This flag tells the tester to run all previous stages up to, and including, the stage you are currently working on. This ensures you haven’t broken any past functionality while building the new feature. At the end of every lesson, you will be provided with the exact docker run command required to evaluate that specific stage.

Course Syllabus

The course is broken down into a series of gradually advancing stages. You must complete them in order, as each stage builds upon the last.

Ready to begin? Head over to the first stage: Accept a Connection.

Accept a Connection

Every HTTP request begins with a client connecting to a server over a transport layer. For HTTP, the standard transport layer is the Transmission Control Protocol (TCP).

TCP provides a reliable, ordered, and error-checked delivery of a stream of bytes between applications. Without TCP, HTTP wouldn’t know if the data it sent actually arrived!

Your Task

Bind a TCP listener to port 8080 on 0.0.0.0, wait for an incoming connection, and successfully accept it.

Warning

Why 0.0.0.0 and not localhost? If you bind your server to 127.0.0.1 (localhost), it will only accept connections from inside your host machine. Because our test suite runs inside a Docker container, it connects to your server via a virtual network bridge. Binding to 0.0.0.0 ensures your server listens on all network interfaces, allowing the Docker container to successfully reach it.


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until accept_connection

Reference: RFC 9110 Section 3.3

Receive Data

Now that you have accepted a connection, you must read the HTTP Request being sent by the client.

HTTP is a text-based protocol. A client request is simply a stream of raw bytes containing ASCII text, formatted according to strict grammar rules defined in the RFCs.

The Anatomy of an HTTP Request

According to RFC 9112 Section 2.1, an HTTP message consists of:

HTTP-message   = start-line
                *( field-line CRLF )
                CRLF
                [ message-body ]

Let’s break down this ABNF (Augmented Backus-Naur Form) syntax:

  • start-line: A single line detailing the request (method, target, version).
  • *( field-line CRLF ): Zero or more field lines (commonly known as headers), each ending with a CRLF. Semantically, these “field lines” carry the metadata of the HTTP message as key-value pairs. While developers usually call them “headers”, the specification formally refers to them as field lines because they are parsed line-by-line before being interpreted as logical fields.
  • CRLF: An empty line signaling the end of the headers.
  • [ message-body ]: An optional body payload.

Important

CRLF stands for Carriage Return (\r) and Line Feed (\n). You must look for \r\n to separate lines, not just \n. The end of the header section is explicitly marked by a double CRLF sequence (\r\n\r\n).

Your Task

The tester will connect to your server and slowly stream an HTTP request in multiple chunks.

You must read data from the connection until you encounter the \r\n\r\n sequence, which tells you that the HTTP headers are finished.

For this stage, you don’t need to send a valid HTTP response back yet. However, your server must not close the connection until it has fully received the \r\n\r\n boundary.

Warning: If your server uses a blocking read without checking for the \r\n\r\n boundary, it might hang indefinitely waiting for more data!


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until receive_data

Send a Response Line

You are successfully reading incoming requests. Now it’s time to send an HTTP Response back!

An HTTP Response, much like an HTTP Request, has a strict structure defined in RFC 9112 Section 4. It begins with a Status Line (often called a Response Line).

status-line = HTTP-version SP status-code SP reason-phrase CRLF

Example: HTTP/1.1 200 OK\r\n

  • HTTP-version: For this course, always use HTTP/1.1.
  • SP: A single space character.
  • status-code: A 3-digit integer indicating the result of the request (e.g., 200).
  • reason-phrase: A short textual description of the status code (e.g., OK).
  • CRLF: The carriage return and line feed (\r\n).

Your Task

Read the incoming HTTP request (wait for the \r\n\r\n boundary). Once received, regardless of what the request asked for, you must respond with a 200 OK status line.

Because you are not sending any headers or a body yet, your response must consist of exactly the status line, followed by the empty line that terminates the header section.

Your exact response must be: HTTP/1.1 200 OK\r\n\r\n

Warning

Notice the two \r\n sequences at the end! The first terminates the status line. The second acts as the empty line terminating the headers block. If you only send one \r\n, the client will hang indefinitely waiting for the headers to finish!


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until send_response_line

Parse the Request Line

Right now, you are blindly returning a 200 OK response to every connection. A real web server routes requests based on what the client actually asked for!

To do this, you must parse the very first line of the incoming HTTP request, called the Request Line.

According to RFC 9112 Section 3, the Request Line has the following grammar:

request-line   = method SP request-target SP HTTP-version CRLF

Example: GET /index.html HTTP/1.1\r\n

  • method: The action to be performed (e.g., GET, POST). It tells the server what the client wants to do.
  • request-target: Often called the URI or path (e.g., /index.html), it tells the server which resource the action applies to.

Your Task

You must parse the Request Line of the incoming HTTP request and extract the request-target.

  • If the request target is exactly /, respond with HTTP/1.1 200 OK\r\n\r\n.
  • If the request target is anything else (e.g., /some/random/value), respond with HTTP/1.1 404 Not Found\r\n\r\n.

Tip

To parse the Request Line safely, read the incoming bytes until you find the first \r\n. That entire string is the Request Line. You can then split it by the space character ( ) to extract the method, target, and version.


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until parse_request_line

Parse Request Headers

After the Request Line, an HTTP request contains zero or more Fields (colloquially known as Headers).

Headers are metadata key-value pairs that provide additional context about the request (e.g., what kind of device the client is using, what data formats they accept).

According to RFC 9112 Section 5, these fields are transmitted as a series of field lines following this grammar:

field-line   = field-name ":" OWS field-value OWS

Let’s break this down:

  • field-name: The name of the header (e.g., User-Agent). Header names are case-insensitive. It means User-Agent, user-agent, USER-AGENT, uSeR-aGeNt are all the same.
  • :: A literal colon character.
  • OWS: Optional Whitespace. This means there might be zero spaces, one space, or multiple spaces before or after the value!
  • field-value: The actual value of the header.

Example:

Host: localhost\r\n
user-agent:   curl/7.68.0  \r\n

Caution

A very common pitfall is assuming there is always exactly one space after the colon. The RFC says OWS (Optional Whitespace), meaning Host:localhost and Host: localhost are both perfectly valid and must be parsed identically. You must trim leading and trailing whitespace from the value!

Your Task

Create a new route: GET /ping.

When a client makes a request to this new endpoint, the tester will send requests containing a custom header: X-Test-Ping.

You must extract the value of this header for the /ping route only, taking care to handle case-insensitivity and whitespace trimming.

  • If the request target is /ping and the request contains the header X-Test-Ping with the exact value ping, respond with 200 OK.
  • If the request target is /ping and the header is missing, or has any other value, respond with 400 Bad Request.
  • For other routes, maintain your existing logic (e.g., / returns 200 OK regardless of headers).

Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until headers_parsing

Validate Headers

Now that you can parse headers, let’s implement a strict rule from the HTTP/1.1 specification.

In the early days of the web (HTTP/1.0), a server assumed that any connection to its IP address was meant for the one and only website it hosted. But as the web grew, servers started hosting multiple websites on the same IP address (a technique called Virtual Hosting).

To make Virtual Hosting possible, HTTP/1.1 introduced the mandatory Host header. The client uses this header to tell the server which website they are trying to reach.

The Strict RFC Requirement

According to RFC 9112 Section 3.2:

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.

This is not optional; it is a fundamental security and routing requirement for modern HTTP!

Your Task

Inspect the headers of the incoming request.

  • If the Host header is missing entirely, you MUST respond with 400 Bad Request.
  • If the Host header is present (with any value), respond with 200 OK.

Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until headers_validation

Send Response Headers

Just as clients send request headers to tell the server about themselves, servers send Response Headers to provide metadata about the response.

For example, if a server returns the string "hello", the client needs to know if that string is plain text, a JSON object, or a piece of HTML. The server communicates this using the Content-Type header.

Your Task

The tester will send a simple GET request. You must respond with a 200 OK status line, and include a Content-Type: text/plain header.

Remember the grammar we learned for headers: field-name ":" OWS field-value OWS CRLF

So, your response should look exactly like this:

HTTP/1.1 200 OK\r\n
Content-Type: text/plain\r\n
\r\n

Caution

Do not forget the empty \r\n line at the very end of your response! According to RFC 9112 Section 2.1, the header section must be terminated by a double CRLF. Even if you only send one header, you still need that terminating blank line.

Reference: RFC 9110 Section 8.3


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until send_response_headers

Routing

Real HTTP servers host many different endpoints, and often, these endpoints include dynamic parameters in the URL path.

For example, if you visit a GitHub repository at https://github.com/torvalds/linux, GitHub doesn’t have a static file for that exact path. Instead, they have a route matching /github.com/{username}/{repo} and they extract torvalds and linux from the URL dynamically!

Your Task

Set up a dynamic route: /echo/{string}.

When a client sends a GET request to /echo/something, you must extract the something part of the path, and return it to the client.

Since you haven’t learned how to send Response Bodies yet, you will return the extracted string inside a custom response header called X-Echo.

Example Request: GET /echo/hello_world HTTP/1.1

Example Expected Response:

HTTP/1.1 200 OK\r\n
X-Echo: hello_world\r\n
\r\n

Tip

You can check if the request path starts with /echo/ and then extract the substring that follows it to get the dynamic parameter! Most languages provide string methods like startsWith, substring, split, or slicing to make this easy. Just be sure to handle the case where the client requests a different path (continue returning 404 Not Found for unknown paths).


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until routing

Send a Response Body

You’ve successfully routed requests and sent headers. Now let’s send actual data!

The Message Body is the payload of an HTTP message. It comes immediately after the empty line (\r\n\r\n) that terminates the headers.

According to RFC 9110 Section 8.6, when a server sends a message body, it MUST tell the client exactly how many bytes the body contains using the Content-Length header.

If you omit this header, the client has no way to know when the response ends, and it will just hang there waiting for more data until the connection times out!

Your Task

Create a new route: /greet/{name}.

Extract the {name} from the URL, and return the greeting string Hello, {name}! as the Response Body. You must also include a Content-Length header set to the exact length of the greeting string in bytes.

Example Request: GET /greet/bob HTTP/1.1

Example Expected Response:

HTTP/1.1 200 OK\r\n
Content-Type: text/plain\r\n
Content-Length: 11\r\n
\r\n
Hello, bob!

Caution

The Content-Length header measures the number of bytes, not characters! For plain ASCII text this is the same, but if your server ever sends UTF-8 emojis, string.length() in many languages will return the wrong number. Always measure the byte array length!


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until response_body

Read Request Body

You know how to send a body, but what if the client sends one to you?

When a client sends data to the server (typically using a POST or PUT request), they will include a Content-Length header in their request.

According to RFC 9112 Section 6, the length of the message body is determined by the Content-Length header (unless Transfer-Encoding is present, which we will cover later).

Your Task

Implement a new route: POST /uppercase.

When this route is called, you must:

  1. Parse the Content-Length header from the request.
  2. After you find the \r\n\r\n boundary separating headers and body, read exactly Content-Length bytes from the socket.
  3. Convert those bytes to uppercase.
  4. Send them back to the client as the response body (don’t forget your own Content-Length header in the response!).

Warning

A common mistake is reading the entire socket stream until EOF (End Of File). In HTTP/1.1, connections are kept alive! The client will not close the socket after sending the body. If you read until EOF, your server will hang forever. You MUST read exactly Content-Length bytes and stop!


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until request_body

HTTP Methods

You are currently handling a POST request to /uppercase. But what if a client sends a GET request to that same path?

Endpoints often only support specific HTTP Methods (GET, POST, PUT, DELETE, etc.).

According to RFC 9110 Section 9.3:

The method token indicates the request method to be performed on the target resource. The request method is the primary source of semantics for a request message.

If a client attempts to use a method that the server does not support for a specific resource, the server should respond with a 405 Method Not Allowed status code.

Your Task

Protect your /uppercase route!

  • If it receives a POST request, function normally (read the body, uppercase it, return it).
  • If it receives any other method (like GET), respond immediately with 405 Method Not Allowed (and no response body).

Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until http_methods

Concurrent Connections

Up until now, we’ve only sent one request at a time. But a real HTTP server must handle multiple clients simultaneously!

Imagine a slow client connects to your server and starts sending a request, but pauses halfway through. If your server processes connections synchronously (blocking on one client until they finish before accepting the next), that one slow client just froze your entire server!

To handle real-world traffic, your server must be able to juggle multiple connections at once.

Your Task

Ensure your server can handle concurrent connections. You can achieve this using multi-threading (e.g., spawning a new thread per accepted socket connection) or asynchronous I/O (e.g., using epoll/kqueue or an async runtime like Tokio or Node.js).

How we test this: The tester will open two connections at the exact same time. It will send a slow, incomplete request on Connection 1. While Connection 1 is sitting idle waiting for more data, the tester will send a complete, fast request on Connection 2.

Your server must respond to Connection 2 immediately, without waiting for Connection 1 to finish!


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until concurrent_connections

Chunked Encoding (Read)

You know how to read a request body using Content-Length. But what if a client wants to stream data to you, and they don’t know the final size before they start sending?

In HTTP/1.1, this is solved using Chunked Transfer Encoding (RFC 9112 Section 7.1).

When a request contains the header Transfer-Encoding: chunked, the Content-Length header is omitted. Instead, the body is sent in a series of chunks.

Each chunk follows this strict ABNF grammar:

chunk          = chunk-size [ chunk-ext ] CRLF chunk-data CRLF
chunk-size     = 1*HEXDIG

  • chunk-size: A hexadecimal number indicating the size of the following chunk data.
  • [ chunk-ext ]: Optional extensions that you can safely ignore for this course.
  • chunk-data: The actual payload bytes of the specified size.

Example of a chunked body:

4\r\n        (Chunk size: 4 bytes in hex)
Wiki\r\n     (Chunk data: 4 bytes)
5\r\n        (Chunk size: 5 bytes in hex)
pedia\r\n    (Chunk data: 5 bytes)
0\r\n        (Final zero-length chunk terminating the stream)
\r\n         (End of chunked body)

Your Task

Create a new /lowercase route to support Transfer-Encoding: chunked.

If the client sends a chunked request, you must parse the chunk sizes (hexadecimal!), read exactly that many bytes of chunk data, skip the \r\n, and repeat until you encounter a chunk of size 0.

Finally, combine all the chunk data, lowercase it, and return it in the response (you can return it with a standard Content-Length response for now).


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until chunked_encoding_read

Chunked Encoding (Write)

Servers also need to send chunked responses when they generate data dynamically, or stream large files, and don’t know the final Content-Length in advance.

Imagine you are streaming a video, or returning rows from a slow database query. Instead of waiting 10 seconds to calculate the total length and buffering it all in memory, you can send the data in chunks as soon as it’s ready!

Your Task

Create a new route: GET /stream.

When a client requests this endpoint, respond with Transfer-Encoding: chunked (do NOT include a Content-Length header).

You must send the body in at least two separate chunks, followed by the zero-length terminating chunk (0\r\n\r\n).

The combined payload of your chunks must EXACTLY match this quote by Tim Berners-Lee: The Web is more a social creation than a technical one.

Tip

Make sure you format the chunk sizes as Hexadecimal strings! 10 bytes should be a\r\n, not 10\r\n.

Reference: RFC 9112 Section 7.1


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until chunked_encoding_write

Keep-Alive Connections

In HTTP/1.0, a new TCP connection was opened for every single request. The client would send a request, the server would send a response, and then the server would immediately close the TCP connection.

This was incredibly slow because TCP requires a 3-way handshake to establish a connection. If a webpage needed 50 images, it required 50 separate handshakes!

HTTP/1.1 introduced Persistent Connections (often called Keep-Alive) to solve this.

According to RFC 9112 Section 9.3:

HTTP/1.1 defaults to the use of “persistent connections”, allowing multiple requests and responses to be carried over a single connection.

By default, a server must NOT close the connection after sending a response. It should keep the connection open and wait for the client to send another request.

What about Connection: keep-alive?

You might have seen headers like Connection: keep-alive or Keep-Alive: timeout=5 in the wild.

In the older HTTP/1.0 protocol, connections were closed by default. To keep a connection open, clients had to explicitly send a Connection: keep-alive header.

However, in HTTP/1.1, persistent connections are the default. This means Connection: keep-alive is technically redundant (though many browsers still send it for backwards compatibility). Unless the client or server explicitly sends a Connection: close header, you should assume the connection remains open.

The Keep-Alive header (which provides timeout hints) is also an older extension and is not strictly required by the modern HTTP/1.1 spec. You do not need to parse or send these headers for this stage!

Your Task

Ensure your server keeps the connection open after sending a response.

The tester will open a single connection and send two separate HTTP requests sequentially over that same connection. Your server must respond to both of them.

Tip

You can implement this by wrapping your connection handling logic in a continuous loop! Just be sure to detect when the client closes the connection (typically when reading from the socket returns 0 bytes, or an EOF signal is received). Once that happens, you should safely break out of the loop and clean up.


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until keep_alive

File Serving

You have built an HTTP server capable of dynamic routing, concurrent processing, and chunked encoding! Now, let’s go back to the roots of the web: serving static files.

When a client requests an image, an HTML file, or a stylesheet, the server must read the file from the filesystem and return its contents in the Response Body.

Your Task

Because the tester runs inside a Docker container and your server runs on your host machine, they need a shared directory to test file serving. The tester will create a temporary file in this directory, and then ask your server to serve it!

Configure your server to serve files from a directory of your choice.

You must implement a new route: GET /files/{filename}.

When a client requests this endpoint, your server must:

  1. Combine your chosen base directory with the {filename} from the URL.
  2. Open the file on disk.
  3. If the file exists, return a 200 OK response with:
  • Content-Type: application/octet-stream
  • Content-Length set to the size of the file.
  • The file contents as the Response Body.
  1. If the file does NOT exist, return a 404 Not Found response.

Note

application/octet-stream is the standard MIME type for an arbitrary binary file.


Important

Run the below docker command to test your solution. Note the -v flag: you must replace <YOUR_DIRECTORY> with the absolute path to the directory your server is serving from!

This mounts your host directory to /file_serving inside the container. The tester is hardcoded to create and request test files from this specific /file_serving path!

docker run \
  --rm \
  -v <YOUR_DIRECTORY>:/file_serving \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until file_serving

Compression

Bandwidth is expensive, and plain text formats (like HTML, CSS, and JSON) are highly compressible. To make the web faster, HTTP supports Content Encoding.

When a client makes a request, they can include the Accept-Encoding header to tell the server which compression algorithms they understand (e.g., gzip, deflate, br).

If the server supports one of those algorithms, it can compress the Response Body, and add a Content-Encoding header to the response to tell the client how to decompress it!

Your Task

Create a new route: /compress/{string}.

  1. Read the Accept-Encoding header from the request.
  2. If it contains gzip (it might be a comma-separated list like gzip, deflate), you must:
  • Compress the {string} payload using the GZIP algorithm.
  • Add the header Content-Encoding: gzip.
  • Send the compressed bytes as the response body.
  • Set Content-Length to the size of the compressed payload.
  1. If Accept-Encoding does not contain gzip, or is missing entirely, respond exactly as you did in Send a Response Body (uncompressed plain text).

Note

You will need a GZIP library in your language to compress the string! (e.g. flate2 in Rust, zlib in Node.js/Python, compress/gzip in Go).


Important

Run the below docker command to test your solution.

docker run \
  --rm \
  --add-host host.docker.internal:host-gateway \
  codeberg.org/level0/buildit/http-server:latest \
  --addr host.docker.internal:8080 \
  --until compression

🎉 Congratulations! 🎉

You have successfully built a working HTTP/1.1 server entirely from scratch!

You started with a raw TCP socket and progressively implemented the intricate details of the HTTP protocol. By the end, your server was capable of dynamic routing, concurrent connection handling, chunked transfer encoding, persistent connections, file serving, and even gzip compression!

Building foundational software like an HTTP server from scratch is one of the most effective ways to truly understand how the systems we rely on every day actually work under the hood.

What We Covered

Throughout this course, you tackled a massive slice of HTTP/1.1:

  • TCP Sockets: Binding, listening, and reading/writing raw bytes.
  • Protocol Parsing: Extracting the request line, parsing headers, and reading request bodies.
  • Routing & Methods: Handling GET and POST requests, and routing them to different endpoints.
  • Concurrency: Using threads or asynchronous tasks to handle multiple clients simultaneously without blocking.
  • Chunked Transfer Encoding: Implementing Transfer-Encoding: chunked to stream unknown-length payloads.
  • Persistent Connections: Implementing Keep-Alive to reuse TCP connections.
  • File Serving: Serving static files from disk with the correct MIME types.
  • Compression: Using GZIP via the Content-Encoding header to reduce bandwidth.

What We Didn’t Cover

HTTP is a massive, continuously evolving protocol. To keep this course focused, we intentionally omitted some advanced features:

  • Query Parameters: Parsing the ?key=value string from the URL path.
  • HTTPS / TLS: We only implemented plain-text HTTP. Adding TLS requires integrating cryptographic libraries and performing TLS handshakes over the TCP socket.
  • HTTP/2 and HTTP/3: These are fundamentally different protocols. HTTP/2 uses multiplexed binary framing over TCP, and HTTP/3 operates over UDP using QUIC.
  • Advanced Caching: ETags, Cache-Control, Last-Modified, and conditional requests (e.g., If-None-Match).
  • Cookies and Sessions: Setting Set-Cookie headers and managing server-side state.
  • WebSockets: The Upgrade header mechanism to turn an HTTP connection into a full-duplex WebSocket stream.

What’s Next?

If you enjoyed this, here are a few ideas for what you can do next:

  1. Explore HTTP/2: Read the RFCs for HTTP/2 to see how the protocol evolved to solve performance bottlenecks. It uses multiplexed binary framing over TCP, which is a completely different beast!
  2. Explore a Real Server: Look at the source code of a production-ready HTTP server in your language (like Hyper in Rust or net/http in Go). You’ll recognize many of the concepts you just built, and you can see how they handle edge cases and performance optimizations!
  3. Try another buildit course: If you enjoyed building an HTTP server from scratch, check out the other courses in the buildit project! Discover what other foundational technologies you can demystify by building them from the ground up.

Thanks for taking this journey with us. Now go out there and build something awesome!