Build a Static File HTTP Server in C

Write a C HTTP server that serves static files over raw POSIX sockets.

Introduction

30 Second Summary

Every time you open a website, a server somewhere reads your request and sends back the files your browser needs. Frameworks like Next.js hide this process behind layers of abstraction, but underneath it all, the same fundamental steps happen every single time.

In this project, you will build an HTTP server from scratch in C that serves static files to your browser. No frameworks, no libraries. Just raw sockets and bytes.

What You'll Build

A compiled C binary that listens on port 8080, parses incoming HTTP requests, and serves HTML and CSS files from a local directory.

By the end of this project, you'll have:

  • A working HTTP server binary that serves static files from a local directory to any web browser.
  • Hands-on experience with POSIX sockets, pointers, memory buffers, and file I/O in C.
  • A mental model of what happens beneath frameworks like Next.js when a browser makes a request.
  • Secret Mission: Add automatic directory listing so visiting a folder path renders its contents as clickable HTML links.

Are there any prerequisites?

You should be comfortable with basic programming concepts like variables, functions, loops, and conditionals. Familiarity with HTTP concepts (requests, responses, status codes) from web development is helpful but not required.

Set Up the Project and First Compile

Every web framework you have ever used sits on top of raw network code that reads bytes from a socket and writes bytes back. In this project, you will write that layer yourself in C.

Before you write any networking code, you need a working compile-and-run cycle. In this step, you will confirm your compiler works, create the project structure, and set up the static files your server will eventually serve.

In this step, get ready to:
  • Confirm GCC is ready to compile C code.
  • Create and run a minimal C program.
  • Set up the static files your server will serve.
Verify GCC and create your project directory

GCC is the compiler that turns your C source code into a runnable binary. A quick version check confirms everything is wired up correctly.

  • In your WSL terminal, confirm GCC is available by running:
gcc --version

✔️ I see a version number

GCC is ready to go. Continue to the next instruction below.

ⓧ Command not found

GCC is not installed yet. Install it by running:

sudo apt update && sudo apt install gcc -y

Once the install finishes, run gcc --version again to confirm it prints a version number.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir http-server
cd http-server
  • Confirm the folder was created by running:
pwd

You should see /home/<your-username>/Desktop/http-server printed in the terminal.

Don't see the expected path?

Make sure you ran both cd ~/Desktop and cd http-server as separate commands. If the mkdir command said the directory already exists, that is fine.

Still stuck? Help me navigate to my project directory in WSL.

Create and compile server.c

Every C program starts with a main function. You will create a minimal one that prints a startup message, then compile and run it to prove the toolchain works.

  • Open a new file called server.c in Neovim by running:
nvim server.c
  • Press i to enter Insert mode, then type the following code:
#include <stdio.h>

int main(void) {
    printf("Server starting...\n");
    return 0;
}

What does this code do?

  • #include <stdio.h> pulls in the standard I/O library so you can use printf.
  • int main(void) declares the entry point of your program. The OS calls this function when you run the binary.
  • printf("Server starting...\n") prints text to the terminal. The \n adds a newline at the end.
  • return 0 tells the OS the program finished successfully.
  • Save and exit Neovim by pressing Esc, then typing :wq and pressing Enter.
  • Compile the file by running:
gcc -Wall -Wextra -o server server.c

What does this command do?

  • gcc invokes the compiler.
  • -Wall -Wextra enables extra warnings so the compiler catches potential mistakes for you.
  • -o server names the output binary server instead of the default a.out.
  • server.c is the source file to compile.
  • Run your compiled program:
./server

You should see Server starting... printed in your terminal.

Seeing an error when compiling?

If you see a syntax error, open the file again with nvim server.c and check for missing semicolons, mismatched braces, or a misspelled #include.

If you see permission denied when running ./server, run chmod +x server to make it executable.

Help me fix a compile error in my server.c file.

✔️ Awesome, I've got everything!

Great. You have a working compile-and-run cycle. Every change you make from now on follows the same pattern: edit, compile, run.

ⓧ I'd like to double check the full code

Here is the complete server.c file at this point:

#include <stdio.h>

int main(void) {
    printf("Server starting...\n");
    return 0;
}
Create the static files your server will serve

Your server needs files to serve. You will create a public/ directory with an HTML page and a CSS stylesheet that will be the content your browser displays once the server is running.

  • Create the public/ directory by running:
mkdir public
  • Open a new file called index.html inside the public/ directory:
nvim public/index.html
  • Press i to enter Insert mode, then type the following:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My C Server</title>
    <link rel="stylesheet" href="/style.css">
</head>
<body>
    <h1>Hello from C!</h1>
    <p>This page is being served by an HTTP server you built from scratch in C.</p>
    <p>No frameworks. No libraries. Just sockets and raw bytes.</p>
</body>
</html>

What does this file do?

This is a standard HTML page that will be the "home page" of your server. The <link rel="stylesheet" href="/style.css"> tag tells the browser to request a second file from your server for styling.

When your server is finished, visiting http://localhost:8080 will display this page.

  • Save and exit by pressing Esc, typing :wq, and pressing Enter.
  • Open a new file called style.css inside the public/ directory:
nvim public/style.css
  • Press i to enter Insert mode, then type the following:
body {
    font-family: system-ui, -apple-system, sans-serif;
    max-width: 600px;
    margin: 80px auto;
    padding: 0 20px;
    background-color: #1a1a2e;
    color: #e0e0e0;
}

h1 {
    color: #00d4aa;
    font-size: 2.5rem;
}

p {
    font-size: 1.2rem;
    line-height: 1.6;
    color: #b0b0b0;
}

What does this file do?

This stylesheet gives the page a dark theme with a teal accent on the heading. It centers the content and applies readable spacing.

When the browser loads index.html, it sees the <link> tag and makes a second HTTP request to your server for /style.css. Your server will need to handle both requests.

  • Save and exit by pressing Esc, typing :wq, and pressing Enter.
  • Confirm all your files are in place by running:
ls public/

You should see index.html and style.css listed in the output.

Missing a file?

If ls public/ shows only one file or an error, check that you saved each file with :wq in Neovim. If you accidentally saved to the wrong location, use ls in your project root to see if the file landed there instead.

Help me find my missing files in the public directory.

✔️ Awesome, I've got everything!

Your project structure is complete. You have the static content ready and waiting for a server to serve it.

ⓧ I'd like to double check the full code

Here are all three files as they should look right now:

#include <stdio.h>

int main(void) {
    printf("Server starting...\n");
    return 0;
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My C Server</title>
    <link rel="stylesheet" href="/style.css">
</head>
<body>
    <h1>Hello from C!</h1>
    <p>This page is being served by an HTTP server you built from scratch in C.</p>
    <p>No frameworks. No libraries. Just sockets and raw bytes.</p>
</body>
</html>
body {
    font-family: system-ui, -apple-system, sans-serif;
    max-width: 600px;
    margin: 80px auto;
    padding: 0 20px;
    background-color: #1a1a2e;
    color: #e0e0e0;
}

h1 {
    color: #00d4aa;
    font-size: 2.5rem;
}

p {
    font-size: 1.2rem;
    line-height: 1.6;
    color: #b0b0b0;
}

Your toolchain is working and your static files are ready. Next up, you will turn this program into a real TCP server that listens for incoming connections.

Create a TCP Socket and Listen for Connections

Your server.c can print a message, but it has no way to communicate over a network.

To serve web pages, your program needs to open a connection point that browsers can reach. In this step, you'll use the POSIX socket API to create a TCP socket, bind it to port 8080, and start listening for browser connections.

In this step, get ready to:
  • Create a TCP socket and bind it to port 8080.
  • Accept incoming connections from a web browser.
  • Configure the socket for smooth restarts during development.
Add socket headers and create the listening socket

A socket is your program's endpoint for network communication. Think of it as a phone line your server picks up when a browser "calls" on a specific port.

  • Open server.c in Neovim and replace all of its contents with the following code:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8080

int main(void) {
    // Create a TCP socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        return 1;
    }

    // Configure the address struct for port 8080
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    // Bind the socket to the address and port
    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
        perror("bind");
        close(server_fd);
        return 1;
    }

    // Start listening for incoming connections
    if (listen(server_fd, 10) == -1) {
        perror("listen");
        close(server_fd);
        return 1;
    }

    printf("Server listening on http://localhost:%d\n", PORT);

    close(server_fd);
    return 0;
}

What does this code do?

This code sets up a listening socket in four stages:

  • socket(AF_INET, SOCK_STREAM, 0) creates a new TCP socket. AF_INET means IPv4. SOCK_STREAM means reliable, ordered byte streams (TCP).
  • struct sockaddr_in addr holds the address info. INADDR_ANY binds to all network interfaces, and htons(PORT) converts port 8080 to network byte order.
  • bind() attaches the socket to that address and port so the OS knows where to route incoming traffic.
  • listen() marks the socket as ready to accept connections. The 10 is the backlog (how many pending connections can queue up).

What are pointers doing here?

In C, functions like bind() need the memory address of your struct, not a copy of it. The (struct sockaddr *)&addr cast gives bind() a pointer to your address struct in the generic format it expects.

This is how C handles polymorphism. One function signature works for IPv4, IPv6, and Unix sockets by accepting a generic pointer type.

  • Save server.c and compile it by running:
gcc -Wall -Wextra -o server server.c
  • Run the server:
./server

You should see "Server listening on http://localhost:8080" printed, then the program exits immediately. That is expected. There is no loop to keep it running yet.

Seeing "bind: Address already in use"?

Something else is using port 8080. Either close the other program, or wait 60 seconds and try again. You will fix this annoyance properly in the last substep.

Help me debug this

Add the accept loop to handle connections

Your socket is listening, but the program exits before any browser can connect. You need an infinite loop that waits for connections and handles them one at a time.

  • In server.c, find the close(server_fd); line near the bottom of the file.
  • Add the following accept loop above close(server_fd);:
    while (1) {
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);

        if (client_fd == -1) {
            perror("accept");
            continue;
        }

        printf("New connection received\n");
        close(client_fd);
    }

What does this code do?

  • accept() blocks (pauses) the program until a browser connects. When one does, it returns a new file descriptor (client_fd) representing that specific connection.
  • The while (1) loop means the server keeps running forever, handling one connection after another.
  • close(client_fd) shuts down the connection after printing. The server then loops back and waits for the next one.
  • Save server.c and compile by running:
gcc -Wall -Wextra -o server server.c
  • Run the server:
./server

The terminal now shows "Server listening on http://localhost:8080" and stays running.

  • Open your web browser and visit http://localhost:8080.

Your browser shows an error page (the server is not sending any response yet). Switch back to your terminal. You should see "New connection received" printed.

  • Press Ctrl+C in the terminal to stop the server.

Not seeing the connection message?

Make sure your browser address bar shows exactly http://localhost:8080 (not https). Some browsers default to HTTPS which will not connect to your HTTP-only server.

Also confirm the server is still running. If it crashed after a compile error, check for typos and recompile.

Help me debug my C socket server

Enable instant server restarts with SO_REUSEADDR

If you stopped the server with Ctrl+C and immediately tried to run it again, you may have hit an "Address already in use" error. The kernel holds the port in a TIME_WAIT state for about 60 seconds after the server stops.

The SO_REUSEADDR socket option tells the kernel to let you rebind immediately.

  • In server.c, find this block right after the socket error check:
    // Configure the address struct for port 8080
    struct sockaddr_in addr;
  • Add the following two lines directly above that comment:
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

Why is this needed?

When a TCP connection closes, the OS keeps the port reserved briefly to handle any straggling packets. During development you restart constantly. SO_REUSEADDR skips that wait so you can rebind the port instantly after stopping the server.

  • Save server.c and compile by running:
gcc -Wall -Wextra -o server server.c
  • Run the server with ./server.
  • Stop it with Ctrl+C, then immediately run ./server again.

It starts without any "Address already in use" error. You can now restart freely during development.

Still getting "Address already in use"?

Make sure the setsockopt line is placed after the socket() call but before bind(). If it comes after bind(), the option has no effect.

Help me fix SO_REUSEADDR in my C server

✔️ Awesome, I've got everything!

Great. Double check you have saved server.c and that compiling produces no errors or warnings.

ⓧ I'd like to double check the full code

Here is the complete server.c at the end of this step:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8080

int main(void) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        return 1;
    }

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
        perror("bind");
        close(server_fd);
        return 1;
    }

    if (listen(server_fd, 10) == -1) {
        perror("listen");
        close(server_fd);
        return 1;
    }

    printf("Server listening on http://localhost:%d\n", PORT);

    while (1) {
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);

        if (client_fd == -1) {
            perror("accept");
            continue;
        }

        printf("New connection received\n");
        close(client_fd);
    }

    close(server_fd);
    return 0;
}

Your server can now accept connections from any browser. Next up, you'll read the raw HTTP request bytes the browser sends and parse them to figure out what file it wants.

Read and Parse HTTP Requests

Your server can accept connections, but it immediately closes them without reading what the browser actually sent.

When a browser connects to your server, it sends a structured text message called an HTTP request. In this step, you will read those raw bytes off the socket, parse out the method and path, and start rejecting requests your server does not support.

In this step, get ready to:
  • Read raw HTTP request bytes into a memory buffer using recv().
  • Parse the HTTP method and path from the request line.
  • Send a 405 error response for non-GET requests.
Read and parse incoming HTTP requests

When a browser visits a URL, it sends a plain-text message over the TCP connection. The first line looks like GET /index.html HTTP/1.1, followed by headers and a blank line. Your server needs to read these bytes, then extract the method and path so it knows what to do.

  • In Neovim, open server.c.
  • Find the line #define PORT 8080 near the top of the file.
  • Add a new define directly below it for the buffer size:
#define BUFFER_SIZE 1024
  • Now add the handle_client function above main by copying the following code between the defines and the int main(void) line:
void handle_client(int client_fd) {
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read = recv(client_fd, buffer, sizeof(buffer) - 1, 0);

    if (bytes_read <= 0) {
        close(client_fd);
        return;
    }

    buffer[bytes_read] = '\0';

    char method[16];
    char path[256];
    sscanf(buffer, "%s %s", method, path);

    printf("%s %s\n", method, path);

    close(client_fd);
}

What does this code do?

This function handles a single client connection from start to finish.

  • recv(client_fd, buffer, sizeof(buffer) - 1, 0) reads raw bytes from the socket into your buffer. The - 1 reserves space for a null terminator.
  • buffer[bytes_read] = '\0' turns the raw bytes into a proper C string. Without this, string functions like sscanf would read past the data into garbage memory.
  • sscanf(buffer, "%s %s", method, path) reads the first two space-separated tokens from the buffer. For a browser request, that gives you the method (GET) and the path (/index.html).

What does a raw HTTP request look like?

When a browser connects to your server, it sends plain ASCII text like this:

GET /index.html HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 ...
Accept: text/html

The first line contains the method, the path, and the protocol version, separated by spaces. Your sscanf grabs the first two tokens (GET and /index.html) and ignores the rest.

  • Now update your accept loop inside main. Find these two lines:
        printf("New connection received\n");
        close(client_fd);
  • Replace both lines with a single call to your new function:
        handle_client(client_fd);
  • Save server.c.
  • Compile and run your server by running these commands:
gcc -Wall -Wextra -o server server.c
./server
  • Open your browser and visit http://localhost:8080/index.html.
  • Switch back to your terminal. You should see GET /index.html printed.

Not seeing the parsed request?

  • If you get a compile error about implicit declaration of function, make sure handle_client is defined above main in the file. C reads top to bottom.
  • If the server prints nothing when you visit the page, check that you removed the old printf("New connection received\n"); and close(client_fd); lines from the accept loop.
  • Make sure you stopped the old server with Ctrl+C before restarting. Only one process can use port 8080 at a time.

Help me debug why my server isn't printing the parsed HTTP request.

Reject non-GET requests with a 405 error

Your server only needs to handle GET requests. If something else comes in (like a POST), you should send back an HTTP error telling the client that method is not allowed.

  • In server.c, add the send_error function above handle_client by copying this code:
void send_error(int client_fd, int status_code, const char *status_text) {
    char body[256];
    int body_len = snprintf(body, sizeof(body),
        "<html><body><h1>%d %s</h1></body></html>",
        status_code, status_text);
    char header[BUFFER_SIZE];
    int header_len = snprintf(header, sizeof(header),
        "HTTP/1.1 %d %s\r\nContent-Type: text/html\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",
        status_code, status_text, body_len);
    send(client_fd, header, header_len, 0);
    send(client_fd, body, body_len, 0);
}

What does this code do?

This function builds and sends a complete HTTP error response in two parts.

  • snprintf works like printf but writes into a buffer instead of printing to the screen. The sizeof(body) argument prevents writing past the end of the array.
  • The header string follows the HTTP/1.1 response format: a status line, then headers separated by \r\n, then a blank line (\r\n\r\n) to mark the end of headers.
  • Content-Length tells the browser exactly how many bytes of body to expect. Connection: close tells it the server will hang up after this response.
  • Now add the method check inside handle_client. Find the close(client_fd); line at the bottom of handle_client.
  • Add this block directly above that close call:
    if (strcmp(method, "GET") != 0) {
        send_error(client_fd, 405, "Method Not Allowed");
        close(client_fd);
        return;
    }

What does this code do?

strcmp compares two C strings character by character. It returns 0 when they match. So != 0 means "the method is NOT equal to GET". If that is the case, the server sends a 405 error, closes the connection, and returns early.

  • Save server.c.
  • Stop the old server with Ctrl+C, then recompile and run:
gcc -Wall -Wextra -o server server.c
./server
  • Visit http://localhost:8080/index.html in your browser.
  • Check your terminal. You should see GET /index.html printed. The browser will show an empty page because you are not yet sending file contents back.

Seeing a compile error about strcmp?

  • Make sure you have #include <string.h> at the top of your file. This header provides strcmp.
  • If you see a warning about send_error being undeclared, make sure it is defined above handle_client in the file.

Help me fix compile errors in my HTTP server code.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.c and that it compiles without warnings.

ⓧ I'd like to double check the full code

Here is the complete server.c at this point:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8080
#define BUFFER_SIZE 1024

void send_error(int client_fd, int status_code, const char *status_text) {
    char body[256];
    int body_len = snprintf(body, sizeof(body),
        "<html><body><h1>%d %s</h1></body></html>",
        status_code, status_text);
    char header[BUFFER_SIZE];
    int header_len = snprintf(header, sizeof(header),
        "HTTP/1.1 %d %s\r\nContent-Type: text/html\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",
        status_code, status_text, body_len);
    send(client_fd, header, header_len, 0);
    send(client_fd, body, body_len, 0);
}

void handle_client(int client_fd) {
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read = recv(client_fd, buffer, sizeof(buffer) - 1, 0);

    if (bytes_read <= 0) {
        close(client_fd);
        return;
    }

    buffer[bytes_read] = '\0';

    char method[16];
    char path[256];
    sscanf(buffer, "%s %s", method, path);

    printf("%s %s\n", method, path);

    if (strcmp(method, "GET") != 0) {
        send_error(client_fd, 405, "Method Not Allowed");
        close(client_fd);
        return;
    }

    close(client_fd);
}

int main(void) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        return 1;
    }

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
        perror("bind");
        close(server_fd);
        return 1;
    }

    if (listen(server_fd, 10) == -1) {
        perror("listen");
        close(server_fd);
        return 1;
    }

    printf("Server listening on http://localhost:%d\n", PORT);

    while (1) {
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);

        if (client_fd == -1) {
            perror("accept");
            continue;
        }

        handle_client(client_fd);
    }

    close(server_fd);
    return 0;
}

Your server can now read and understand what the browser is asking for. Next up, you will use that parsed path to find files on disk and send them back as real HTTP responses.

Serve Static Files

Your server can accept connections and parse requests, but it still sends nothing back. A browser asking for /index.html gets silence.

In this step, you will teach the server to find files on disk, read their bytes into memory, and send them back as proper HTTP responses. By the end, your browser will render a fully styled HTML page served entirely by your C code.

In this step, get ready to:
  • Write helper functions for building HTTP responses and detecting MIME types.
  • Write a serve_file function that maps URL paths to files, reads them into memory, and sends them back.
  • Compile and test by loading your styled HTML page in a browser.
Build the response helper functions

Before the server can serve files, it needs two utility functions. The first detects what type of file is being served (HTML, CSS, image) so the browser knows how to render it. The second constructs a properly formatted HTTP response with headers and a body.

  • In server.c, find the #define BUFFER_SIZE 1024 line near the top of the file.
  • Add the following line directly below it:
#define RESPONSE_SIZE 65536
  • Now add the get_content_type function below the #define lines and above the existing send_error function. Copy this code:
const char *get_content_type(const char *path) {
    /* Find the last '.' in the filename to get the extension */
    const char *ext = strrchr(path, '.');
    if (!ext) return "application/octet-stream";
    if (strcmp(ext, ".html") == 0) return "text/html";
    if (strcmp(ext, ".css") == 0) return "text/css";
    if (strcmp(ext, ".js") == 0) return "application/javascript";
    if (strcmp(ext, ".png") == 0) return "image/png";
    if (strcmp(ext, ".jpg") == 0) return "image/jpeg";
    if (strcmp(ext, ".gif") == 0) return "image/gif";
    return "application/octet-stream";
}

What does this code do?

Browsers use the Content-Type header to decide how to render a response. Without it, your CSS file might display as raw text instead of being applied as a stylesheet.

  • strrchr(path, '.') searches backward through the string to find the last dot character. This gives you the file extension.
  • Each strcmp call matches the extension to its corresponding MIME type.
  • If no extension is found, it falls back to application/octet-stream (generic binary data).
  • Next, add the send_response function directly below get_content_type and above send_error:
void send_response(int client_fd, int status_code, const char *status_text,
                   const char *content_type, const char *body, size_t body_len) {
    char header[BUFFER_SIZE];
    /* Build the HTTP response header with status, content type, and length */
    int header_len = snprintf(header, sizeof(header),
        "HTTP/1.1 %d %s\r\n"
        "Content-Type: %s\r\n"
        "Content-Length: %zu\r\n"
        "Connection: close\r\n"
        "\r\n",
        status_code, status_text, content_type, body_len);

    /* Send the header first, then the body */
    send(client_fd, header, header_len, 0);
    if (body && body_len > 0) {
        send(client_fd, body, body_len, 0);
    }
}

What does this code do?

This function builds a complete HTTP/1.1 response from its parts. Every HTTP response has the same structure: a status line, headers, a blank line, then the body.

  • Content-Length tells the browser exactly how many bytes to expect. Without it, the browser does not know when the response body ends.
  • Connection: close tells the browser this socket will close after the response. This simplifies our server because HTTP/1.1 defaults to keeping connections open.
  • The \r\n\r\n at the end of the header is the blank line separator that marks where headers end and the body begins.
  • Now replace your existing send_error function with this refactored version that uses send_response:
void send_error(int client_fd, int status_code, const char *status_text) {
    char body[256];
    int body_len = snprintf(body, sizeof(body),
        "<html><body><h1>%d %s</h1></body></html>",
        status_code, status_text);
    send_response(client_fd, status_code, status_text, "text/html", body, body_len);
}

Why refactor send_error?

The old send_error built headers manually. Now that send_response exists, send_error becomes a thin wrapper. One place to build headers means fewer bugs.

  • Save server.c.

Seeing errors when you compile?

Make sure get_content_type is above send_response, which is above send_error. In C, functions must be defined before they are called.

help me fix a compile error in my C server code

Write the serve_file function

With the helpers in place, you can now write the core function that maps a URL path to a file on disk, reads it into memory, and sends it back as a response.

  • Add the serve_file function below send_error and above handle_client:
void serve_file(int client_fd, const char *path) {
    char filepath[512];

    /* Map URL path to filesystem path inside public/ */
    if (strcmp(path, "/") == 0) {
        snprintf(filepath, sizeof(filepath), "public/index.html");
    } else {
        snprintf(filepath, sizeof(filepath), "public%s", path);
    }

    /* Check if the file exists and get its size */
    struct stat st;
    if (stat(filepath, &st) == -1) {
        send_error(client_fd, 404, "Not Found");
        return;
    }

    /* Open the file in binary mode */
    FILE *file = fopen(filepath, "rb");
    if (!file) {
        send_error(client_fd, 404, "Not Found");
        return;
    }

    /* Allocate a buffer to hold the file contents */
    char *body = malloc(st.st_size);
    if (!body) {
        fclose(file);
        send_error(client_fd, 500, "Internal Server Error");
        return;
    }

    /* Read the entire file into the buffer */
    fread(body, 1, st.st_size, file);
    fclose(file);

    /* Determine content type and send the response */
    const char *content_type = get_content_type(filepath);
    send_response(client_fd, 200, "OK", content_type, body, st.st_size);

    free(body);
}

What does this code do?

This function is the heart of your file server. It converts a URL path into a filesystem path, reads the file, and sends it back over the socket.

  • Path mapping: A request to / becomes public/index.html. A request to /style.css becomes public/style.css.
  • stat(): This system call fills a struct stat with file metadata. The st.st_size field gives you the file size in bytes, which you need for both malloc and Content-Length.
  • malloc(st.st_size): Allocates exactly enough memory to hold the file. This is dynamic memory allocation. You own this memory until you call free(body).
  • fread(): Reads the file contents into the buffer. The arguments are: destination buffer, size of each element (1 byte), number of elements (file size), and the file handle.

What is struct stat?

In C, struct stat is a data structure the operating system fills with information about a file. It includes the file size (st_size), permissions, and whether it is a file or directory.

If stat() returns -1, the file does not exist. You use this check to decide whether to send a 200 response with file contents or a 404 error.

  • Now update handle_client to call serve_file for GET requests. Find the end of handle_client where you see this code:
    close(client_fd);
}
  • Add serve_file(client_fd, path); on the line directly above close(client_fd); so the end of the function looks like this:
    serve_file(client_fd, path);
    close(client_fd);
}
  • Save server.c.

Implicit declaration warning?

If you see a warning about implicit declaration of serve_file, make sure it is defined above handle_client in the file. C reads top to bottom.

help me fix function ordering in my C file

Compile, run, and test in your browser
  • If your server is still running from earlier, stop it by pressing Ctrl+C in your terminal.
  • Compile the updated server by running:
gcc -Wall -Wextra -o server server.c
  • Start the server by running:
./server

You should see Server listening on http://localhost:8080 printed in the terminal.

  • Open your web browser and visit http://localhost:8080.

You should see your styled HTML page with a dark background, a green heading that says "Hello from C!", and two lines of grey text.

  • Open the browser's DevTools by pressing F12 or Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Network tab.
  • Refresh the page. You should see two requests: one for / (the HTML) and one for /style.css (the stylesheet). Both should show status 200.

Why two requests?

Your HTML file contains a <link rel="stylesheet" href="/style.css"> tag. When the browser receives the HTML, it parses it and sees this tag, then makes a second request back to your server for the CSS file.

Your server handles these sequentially. The browser sends one request, your server responds, then the browser sends the next. Each one goes through the full accept, recv, parse, serve_file cycle.

Page loads but no styling?

Check the Network tab in DevTools. If style.css shows a 404 status, make sure the file is at public/style.css (not public/styles.css or any other name). The filename must match what index.html references in its link tag.

If the CSS loads but styles look wrong, check that get_content_type returns text/css for .css files. Without the correct Content-Type, some browsers refuse to apply the stylesheet.

help me debug why my CSS is not loading from my C HTTP server

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete server.c at this point:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8080
#define BUFFER_SIZE 1024
#define RESPONSE_SIZE 65536

const char *get_content_type(const char *path) {
    const char *ext = strrchr(path, '.');
    if (!ext) return "application/octet-stream";
    if (strcmp(ext, ".html") == 0) return "text/html";
    if (strcmp(ext, ".css") == 0) return "text/css";
    if (strcmp(ext, ".js") == 0) return "application/javascript";
    if (strcmp(ext, ".png") == 0) return "image/png";
    if (strcmp(ext, ".jpg") == 0) return "image/jpeg";
    if (strcmp(ext, ".gif") == 0) return "image/gif";
    return "application/octet-stream";
}

void send_response(int client_fd, int status_code, const char *status_text,
                   const char *content_type, const char *body, size_t body_len) {
    char header[BUFFER_SIZE];
    int header_len = snprintf(header, sizeof(header),
        "HTTP/1.1 %d %s\r\n"
        "Content-Type: %s\r\n"
        "Content-Length: %zu\r\n"
        "Connection: close\r\n"
        "\r\n",
        status_code, status_text, content_type, body_len);

    send(client_fd, header, header_len, 0);
    if (body && body_len > 0) {
        send(client_fd, body, body_len, 0);
    }
}

void send_error(int client_fd, int status_code, const char *status_text) {
    char body[256];
    int body_len = snprintf(body, sizeof(body),
        "<html><body><h1>%d %s</h1></body></html>",
        status_code, status_text);
    send_response(client_fd, status_code, status_text, "text/html", body, body_len);
}

void serve_file(int client_fd, const char *path) {
    char filepath[512];

    if (strcmp(path, "/") == 0) {
        snprintf(filepath, sizeof(filepath), "public/index.html");
    } else {
        snprintf(filepath, sizeof(filepath), "public%s", path);
    }

    struct stat st;
    if (stat(filepath, &st) == -1) {
        send_error(client_fd, 404, "Not Found");
        return;
    }

    FILE *file = fopen(filepath, "rb");
    if (!file) {
        send_error(client_fd, 404, "Not Found");
        return;
    }

    char *body = malloc(st.st_size);
    if (!body) {
        fclose(file);
        send_error(client_fd, 500, "Internal Server Error");
        return;
    }

    fread(body, 1, st.st_size, file);
    fclose(file);

    const char *content_type = get_content_type(filepath);
    send_response(client_fd, 200, "OK", content_type, body, st.st_size);

    free(body);
}

void handle_client(int client_fd) {
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read = recv(client_fd, buffer, sizeof(buffer) - 1, 0);

    if (bytes_read <= 0) {
        close(client_fd);
        return;
    }

    buffer[bytes_read] = '\0';

    char method[16];
    char path[256];
    sscanf(buffer, "%s %s", method, path);

    printf("%s %s\n", method, path);

    if (strcmp(method, "GET") != 0) {
        send_error(client_fd, 405, "Method Not Allowed");
        close(client_fd);
        return;
    }

    serve_file(client_fd, path);
    close(client_fd);
}

int main(void) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        return 1;
    }

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
        perror("bind");
        close(server_fd);
        return 1;
    }

    if (listen(server_fd, 10) == -1) {
        perror("listen");
        close(server_fd);
        return 1;
    }

    printf("Server listening on http://localhost:%d\n", PORT);

    while (1) {
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);

        if (client_fd == -1) {
            perror("accept");
            continue;
        }

        handle_client(client_fd);
    }

    close(server_fd);
    return 0;
}

Your server now reads real files from disk and sends them to any browser that asks. Next up, you will tackle the Secret Mission and add dynamic directory listing.

Secret mission

Add Automatic Directory Listing

Your server handles files, but what happens when someone visits a folder path? Right now it returns a 404. Real web servers like Apache and Nginx can generate a clickable index of directory contents on the fly. Can you teach your C server to do the same?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about.

Resources you used:

  • The running server process (if still active in your terminal).
  • Your project folder on the Desktop containing server.c, the compiled server binary, and the public/ directory.

Keep everything running

No action needed. Choose this if you want to keep experimenting with your server or add more features.

  • Your project files stay on disk and take up minimal space.
  • If the server is still running, you can leave it or stop it with Ctrl+C whenever you like.

Pause - I'll come back to this later

Stop the running server process to free up your terminal. Your files stay on disk for next time.

  • Switch to the terminal where ./server is running.
  • Press Ctrl+C to stop the server.
  • To restart later, navigate back to your project folder and run ./server again.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes everything you built in this project.

  • If the server is still running, press Ctrl+C to stop it.
  • Navigate to your Desktop by running:
cd ~/Desktop
  • Delete your project folder by running:
rm -rf c-http-server
  • Confirm the folder is gone by running:
ls
  • You should no longer see your project folder in the output.

Nice Work!

Nice Work!

You just built a working HTTP server from scratch in C. When you type http://localhost:8080 into your browser, your own code handles the connection, reads the request, finds the file, and sends it back with proper headers.

You've learned how to:

  • Create a TCP socket server using the POSIX socket API (socket, bind, listen, accept) to handle incoming browser connections.
  • Parse raw HTTP request bytes using C string functions and map request paths to files on disk with fopen/fread.
  • Construct proper HTTP/1.1 responses with correct status codes, Content-Type headers, and Content-Length values to serve static files to any browser.
  • Secret Mission: Generated dynamic directory listings using opendir/readdir to render clickable HTML links for folder contents.

Ready to quiz yourself?