Building an HTTP Server in C (Linux)


Creating a basic HTTP server in C is a fantastic learning experience for anyone studying computer science or simply curious about how the web works under the hood. Through this project, you’ll explore core concepts like networking, TCP/IP protocols, sockets, and how browsers communicate with servers.
⚠️ Note: This implementation is built specifically for Linux. It uses system calls and APIs that aren’t portable to Windows systems.
What is an HTTP Server?
At its core, an HTTP server is a TCP server that understands the HTTP protocol. It listens for incoming connections, interprets HTTP requests, and sends appropriate responses (HTML, CSS, images, etc.).
In Unix-like systems (like Linux), everything is treated as a file, including sockets. That’s why you can use familiar functions like read()
and write()
on socket file descriptors.
Setting Up the Server (Code + Explanation)
Here’s the core implementation of the server:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h> // For sockaddr_in
#include <unistd.h> // For close(), read(), write()
#include <sys/socket.h> // For socket functions
// Forward declaration
void start_accepting_incoming_connections(int server_fd);
int main() {
int port = 8000;
// Step 1: Create a socket (IPv4, TCP)
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("Socket creation failed");
exit(EXIT_FAILURE);
}
// Step 2: Define server address structure
struct sockaddr_in *server_address = malloc(sizeof(struct sockaddr_in));
server_address->sin_family = AF_INET;
server_address->sin_port = htons(port); // Convert to network byte order
server_address->sin_addr.s_addr = INADDR_ANY; // Listen on all interfaces
// Step 3: Bind the socket to the specified IP and port
int bind_result = bind(server_fd, (struct sockaddr*)server_address, sizeof(*server_address));
if (bind_result < 0) {
perror("Bind failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// Step 4: Start listening for incoming connections
int listen_result = listen(server_fd, 10); // Allow up to 10 queued connections
if (listen_result < 0) {
perror("Listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
printf("🚀 Server is listening on port %d\n", port);
// Step 5: Accept and handle incoming client connections
start_accepting_incoming_connections(server_fd);
// Step 6: Cleanup
shutdown(server_fd, SHUT_RDWR);
close(server_fd);
return 0;
}
🔍 Code Walkthrough
Let’s break this down step by step:
Socket Creation
We callsocket(AF_INET, SOCK_STREAM, 0)
which returns a file descriptor (server_fd
).AF_INET
= IPv4SOCK_STREAM
= TCP0
means the default protocol for the given type (TCP in this case)
Server Address Configuration
We initialize asockaddr_in
structure and set:sin_family
toAF_INET
sin_port
usinghtons(port)
: This function converts the port from host byte order to network byte order (big-endian). Most computers store data in little-endian, so this conversion ensures compatibility over the network.sin_addr.s_addr
toINADDR_ANY
: This tells the server to accept connections on any network interface (localhost, Wi-Fi, etc.)
Binding the Socket
We associate the socket with the IP and port usingbind()
. If this fails, maybe the port is already in use.Listening for Connections
Thelisten()
call sets the socket in passive mode, meaning it will now wait for incoming client connections. We allow up to 10 connections to queue up.Handling Clients
We pass the server’s file descriptor tostart_accepting_incoming_connections()
(you’ll define this separately), which will accept clients and respond to their HTTP requests.
Handling Multiple Clients
Since handling a client is blocking, meaning it waits until a client sends/receives data, we typically offload this to a separate thread or process. This allows the server to continue accepting new connections without waiting for one client to finish.
You can use fork()
for process-based concurrency or pthread
for thread-based handling. We will be using pthread
In this project
Here goes the code for sending and accepting connections/data
struct accepted_socket {
int socket_fd;
struct sockaddr_in address;
int error;
int is_successfull;
};
struct accepted_socket* accept_incoming_connection(int fd) {
struct sockaddr_in client_address;
socklen_t client_address_size = sizeof(struct sockaddr_in);
int client_fd = accept(fd, (struct sockaddr*) &client_address, &client_address_size);
struct accepted_socket* client_socket = malloc(sizeof(struct accepted_socket));
client_socket->address = client_address;
client_socket->socket_fd = client_fd;
client_socket->is_successfull = client_fd > 0;
if(!client_socket->is_successfull) {
client_socket->error = client_fd;
}
return client_socket;
}
First, we created a accept_incoming_connetions(int fd)
A function that takes the fd
i.e** file descriptor** as parameter. We have to create this function before start_accepting_incoming_connections
because defined a global array i.e,
struct accepted_socket* accepted_sockets[DEFAULT_BACKLOG];
int accepted_sockets_cnt = 0;
this holds all the collections in a single array with the count of accepted sockets, which we use for iterating through the accepted_sockets array. Now, for each fd
we create a client_socket of accepted_socket
which is a struct we defined above that holds information related to a particular socket. After assigning the values, we return the client_socket
.
void start_accepting_incoming_connections(int fd) {
while(1) {
struct accepted_socket* client_socket = accept_incoming_connection(fd);
accepted_sockets[accepted_sockets_cnt++] = client_socket;
send_data_in_seperate_thread(client_socket);
}
}
We continuously accept the socket and then store it in the global array. After this, we call the function send_data_in_seperate_thread(client_socket)
. This function takes the current client_socket struct as a parameter. Here is where we do the multi-threading, where to call the function clinet_handler
that takes the client_socket
client as a parameter, which will be the client to whom the data needs to be sent.
void send_data_in_seperate_thread(struct accepted_socket* client_socket) {
pthread_t thread_id;
pthread_create(&thread_id, NULL, client_handler, client_socket);
}
This function i.e client_handler, is the part which does all the file handling and checking to decide what type of data is need to be send to the client. In this case, we have only implemented the GET request.
void* client_handler(void* args) {
struct accepted_socket* client_socket = (struct accepted_socket*)args;
char buffer[4096];
ssize_t bytes_read = recv(client_socket->socket_fd, buffer, sizeof(buffer) - 1, 0);
if(bytes_read <= 0) {
close(client_socket->socket_fd);
free(client_socket);
return NULL;
}
buffer[bytes_read] = '\0';
printf("Client Request: %s\n", buffer);
if(strncmp(buffer, "GET ", 4) == 0){
char method[16], path[256];
sscanf(buffer,"%s %s", method, path);
if(strcmp(path, "/") == 0) {
strcpy(path, "/index.html");
}
char local_file[512];
snprintf(local_file, sizeof(local_file), "serverroot/%s", path);
FILE *fp = fopen(local_file, "rb");
if(!fp) {
const char* not_found = "<h1>404 Not Found<h1>";
char header[256];
snprintf(header, sizeof(header),
"HTTP/1.1 404 Not Found\r\n"
"Content-Type: text/html\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n",
strlen(not_found));
send(client_socket->socket_fd, header, strlen(header),0);
send(client_socket->socket_fd, not_found, strlen(not_found),0);
} else {
fseek(fp, 0L, SEEK_END);
long file_size = ftell(fp);
rewind(fp);
char* body = malloc(file_size);
fread(body, 1, file_size, fp);
fclose(fp);
const char* content_type = "text/plain";
if(strstr(path, ".html")) { content_type = "text/html"; }
else if (strstr(path, ".css")) { content_type = "text/css"; }
else if (strstr(path, ".js")) { content_type = "application/javascript"; }
else if (strstr(path, ".png")) { content_type = "image/png"; }
else if (strstr(path, ".jpg")) { content_type = "image/jpeg"; }
else if (strstr(path, ".svg")) { content_type = "image/svg+xml"; }
else if (strstr(path, ".txt")) { content_type = "text/plain"; }
char header[256];
snprintf(header, sizeof(header),
"HTTP/1.1 200 OK\r\n"
"Content-Type: %s\r\n"
"Content-Length: %ld\r\n"
"Connection: close\r\n"
"\r\n",
content_type, file_size);
send(client_socket->socket_fd, header, strlen(header), 0);
send(client_socket->socket_fd, body, file_size, 0);
free(body);
}
}
else {
const char* body = "<html><body>501 Not Implemented</body></html>";
char response[4096];
snprintf(response, sizeof(response),
"HTTP/1.1 501 Not Implemented\r\n"
"Content-Type: text/html\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
strlen(body), body);
send(client_socket->socket_fd, response, sizeof(response), 0);
}
printf("client %d disconnected.\n", client_socket->socket_fd);
close(client_socket->socket_fd);
free(client_socket);
return NULL;
}
Let's break down this function
Cast and Prepare
struct accepted_socket* client_socket = (struct accepted_socket*)args;
The function receives a void* argument because that's how
pthread_create()
works.We cast it back to our accepted_socket structure, which holds the socket file descriptor (socket_fd) and the client's address.
Receive Client Request
char buffer[4096];
ssize_t bytes_read = recv(client_socket->socket_fd, buffer, sizeof(buffer) - 1, 0);
We allocate a buffer of 4096 bytes to store the client's request.
recv()
reads data sent by the client (usually an HTTP request likeGET /index.html HTTP/1.1
).We subtract 1 from the buffer size to leave space for a null terminator (
\0
).
Handle Disconnection or Read Error
if(bytes_read <= 0) {
close(client_socket->socket_fd);
free(client_socket);
return NULL;
}
If no data is received or something goes wrong, we:
Close the socket.
Free the dynamically allocated memory.
Exit the thread cleanly.
Null-Terminate and Print the Request
buffer[bytes_read] = '\0';
printf("Client Request: %s\n", buffer);
We null-terminate the string to make it safe for string operations.
Then, we print out the request to the console (great for debugging and understanding client behaviour).
Check for a GET
Request
if(strncmp(buffer, "GET ", 4) == 0) {
/*.....*/
}
We check whether the request is an HTTP GET (which asks for a file).
If yes, we continue processing. If not, we jump to the
else
block.
Parse Method and Path
char method[16], path[256];
sscanf(buffer,"%s %s", method, path);
- We extract the HTTP method (
GET
,POST
, etc.) and the requested path (like/index.html
) from the request line.
Handle Root /
as Homepage
if(strcmp(path, "/") == 0) {
strcpy(path, "/index.html");
}
- If the user requested the root path (
/
), we assume they meant to visit the homepage and serveindex.html
.
Construct File Path
char local_file[512];
snprintf(local_file, sizeof(local_file), "serverroot/%s", path);
- We prepend the base folder
serverroot/
to the requested path.
How do we send specific files that your client needs ??
When the browser requests a file, we must respond with an HTTP header that tells the browser how to handle the content it is about to receive.
This header includes metadata like:
The status code (e.g.,
200 OK
),The Content-Type (which tells the browser what kind of file it's receiving — HTML, CSS, image, etc.),
The Content-Length (how many bytes to expect), and
Whether the connection should remain open or close after the response.
The browser reads this header before it reads the actual file content. That's why we send the HTTP header first, and then the file content afterwards.
To determine the correct Content-Type
, we check the file's extension using string matching. Based on the extension (e.g., .html
, .png
, .css
), we assign the appropriate MIME type.
Finally, we construct the full HTTP response:
First, we format the header string using
snprintf()
,Then we send the header using
send()
,After that, we send the actual file content (also using
send()
), andWe close the connection and free any allocated memory.
This process ensures that the browser knows how to correctly display the file, whether it's rendering HTML, applying CSS, or showing an image.
const char* content_type = "text/plain";
if(strstr(path, ".html")) { content_type = "text/html"; }
else if (strstr(path, ".css")) { content_type = "text/css"; }
else if (strstr(path, ".js")) { content_type = "application/javascript"; }
else if (strstr(path, ".png")) { content_type = "image/png"; }
else if (strstr(path, ".jpg")) { content_type = "image/jpeg"; }
else if (strstr(path, ".svg")) { content_type = "image/svg+xml"; }
else if (strstr(path, ".txt")) { content_type = "text/plain"; }
char header[256];
snprintf(header, sizeof(header),
"HTTP/1.1 200 OK\r\n"
"Content-Type: %s\r\n"
"Content-Length: %ld\r\n"
"Connection: close\r\n"
"\r\n",
content_type, file_size);
send(client_socket->socket_fd, header, strlen(header), 0);
send(client_socket->socket_fd, body, file_size, 0);
free(body);
📚 Further Learning
Here are some amazing resources to deepen your understanding:
📘 Beej's Guide to Network Programming – The Gold standard for beginners in socket programming.
📄 Linux Man Page: socket(2) – Official documentation for system calls.
🌐 RFC 2616 - HTTP/1.1 Specification – Dive into the protocol HTTP itself.
The final project is available in the GitHub repository. Visit to see the final results.
Subscribe to my newsletter
Read articles from Abhinab Choudhury directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
