Abstract flowing gradient in deep indigo and blue tones, smooth and luminous, evoking a modern digital learning atmosphere

Computer Science and programming articles. We do not sell courses.

How to Build a Simple HTTP Server in C from Scratch

Writing an HTTP server from the ground up is one of the most rewarding exercises a programmer can attempt. It strips away the abstractions of frameworks like Express, Flask, or Django and exposes the raw mechanics of how browsers, mobile apps, and APIs exchange data. By the end of the project, you will have touched sockets, byte buffers, parsing logic, and the request-response cycle — concepts that transfer directly into systems programming, embedded development, and performance-critical backend work.

This walkthrough targets developers comfortable with C syntax who want a deeper grasp of network programming. The code samples use POSIX sockets, so the server compiles on Linux and macOS without modification. Whether you are studying at the University of Melbourne, working out of a co-working space in Brisbane, or tinkering on a weekend project in Perth, the principles apply wherever a C compiler is available.

Foundations of HTTP and the socket API

HTTP is a plain-text protocol layered on top of TCP. A client opens a TCP connection, sends an ASCII request that looks like GET /index.html HTTP/1.1, and waits for a response consisting of a status line, headers, and an optional body. The same pattern underlies many machine learning services: a server receives input, runs inference, and returns a structured reply. The text classification guide on Hello ML demonstrates this flow with a Naive Bayes model served over HTTP.

Below HTTP sits the socket interface, standardised as POSIX sys/socket.h. A socket is a file descriptor that supports read, write, close, and a handful of specialised calls. The lifecycle of a server socket follows a familiar pattern: socket(), bind(), listen(), accept(), then a loop of recv() and send(). Each call returns an integer file descriptor, which keeps the design aligned with the rest of Unix I/O — a harmony that makes C such a productive language for this work.

Setting up the project and headers

Start with a fresh directory and a single source file called server.c. The headers you need are modest: <stdio.h> for formatted output, <stdlib.h> for exit, <string.h> for memset and strcmp, <unistd.h> for read and write, <sys/socket.h> for the socket primitives, and <netinet/in.h> for the sockaddr_in structure. Most Australian university courses, from UNSW to Monash, teach exactly this set of headers in their second-year systems programming units.

Compile with the modern C standard and warning flags turned on:

gcc -Wall -Wextra -std=c11 -O2 server.c -o server

Running the binary with ./server and then visiting http://localhost:8080 from any browser on the same machine is enough for the first round of testing. If you want to expose the service to other devices on your home network in Adelaide or Canberra, your router needs port forwarding configured, which is a useful exercise on its own.

Creating and binding the listening socket

The first system call is socket(AF_INET, SOCK_STREAM, 0), which asks the kernel for a TCP endpoint. The first argument selects IPv4, the second selects a streaming socket, and the third lets the kernel pick TCP. Storing the returned descriptor in a global variable makes later cleanup straightforward.

Binding a socket to an address requires a sockaddr_in initialised with memset, the address family, the port in network byte order via htons, and the interface set to INADDR_ANY so the server listens on every available network interface. Choosing a port above 1024 avoids the privilege requirement of well-known ports; 8080 is a popular choice for local development, although in production you would consult the Australian Cyber Security Centre's guidance on exposed services.

struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080);

Calling bind() with a length of sizeof(address) connects the descriptor to the port. A common pitfall is forgetting htons, which leaves the port in host byte order and produces confusing bind: Invalid argument errors on little-endian machines.

Listening for and accepting connections

Once bound, the socket transitions into passive mode with listen(server_fd, 10). The backlog of 10 is small but adequate for a learning project. After this call, the kernel queues incoming connection attempts; nothing happens on the user-space side until accept() is called.

accept() blocks until a client connects, then returns a new file descriptor dedicated to that conversation. The original listening descriptor stays open, ready to accept more clients. Reading and writing happen on the new descriptor, while the listening socket keeps doing its job. This dual-descriptor pattern is the same approach used by production servers such as Nginx and Apache, so the habit carries forward.

For interactive debugging, log the IP address of every connection using inet_ntoa on the address filled in by accept. Seeing the loopback address 127.0.0.1 while testing locally is reassuring, and watching connections from a real public address arrive from somewhere in Parramatta or Hobart is a satisfying milestone.

Parsing the HTTP request

Parsing begins with recv() into a fixed buffer. A size of 4096 bytes is generous enough for most headers and small bodies. The first line of an HTTP request contains the method, the path, and the protocol version, separated by spaces. Splitting on the space character using strchr yields the three components without allocating dynamic memory.

After the request line, headers follow the pattern Key: Value and a carriage return-line feed until a blank line marks the end of the header block. Walking the buffer line by line and isolating each header into a temporary copy lets you populate a small array of key-value pairs. For a minimal server, handling only GET and returning the path as the response body is enough to demonstrate the flow.

Error handling matters here. A malformed request should produce a 400 Bad Request response, while an unknown method deserves a 405 Method Not Allowed. Many Australian businesses that expose internal HTTP services — including small agencies in Surry Hills and Fortitude Valley — add a Connection: close header to their responses to keep things simple during early development.

Crafting and sending the response

The response format mirrors the request: a status line, headers, a blank line, then the body. A successful response might look like:

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 13
Connection: close

Hello, world!

Content-Length must match the byte count of the body exactly. Sending fewer bytes leaves the client waiting, while sending more produces protocol errors. Use snprintf to build the header section into a stack buffer, then a second send() for the body. Calling send() twice is allowed because TCP treats each call as an independent stream segment.

A simple improvement is to read the requested path and serve files from a public directory. Opening the file with fopen, reading it with fread, and sending it in chunks keeps memory usage flat. Adding a Content-Type lookup based on file extension — .html, .css, .js, .png — turns the toy server into something useful for static site hosting on a Raspberry Pi in your garage.

Serving multiple clients and shutting down gracefully

The simplest concurrency model is one connection at a time. Once you reach this point, extending the server to handle several clients concurrently is a logical next step. Forking with fork() after each accept() is the easiest approach and works on every Unix-like system. Each child process gets its own copy of the accepted descriptor and can serve the request independently.

Signal handling becomes essential at this stage. SIGPIPE fires when writing to a closed socket, so installing a handler that ignores it prevents the server from crashing mid-response. SIGINT arrives when you press Ctrl-C in the terminal; the handler can close the listening descriptor and wait for outstanding child processes with waitpid. Closing every socket you opened is a discipline that pays off when the server runs as a long-lived service.

When the project is stable and you want feedback from other developers, the Hello ML team reads submissions from contributors around Australia and overseas. Sharing your code there is a quick way to catch edge cases you missed.

Practical tips for a robust minimal server

A few habits dramatically improve reliability even at this small scale: