Skip to content

Build a Static File Server

A static file server is a program that reads files from a directory and sends them to a browser over HTTP.

A static file server is a program that reads files from a directory and sends them to a browser over HTTP.

For example, suppose you have this directory:

public/
  index.html
  style.css
  app.js

A browser can request:

/

The server returns:

public/index.html

A browser can request:

/style.css

The server returns:

public/style.css

This project teaches file paths, networking, HTTP basics, error handling, and safe input handling. A file server looks simple, but it has one serious rule: never let a request escape the public directory.

The Goal

We will build a small server that supports:

GET /
GET /index.html
GET /style.css
GET /app.js

It will return common content types:

.html -> text/html
.css  -> text/css
.js   -> application/javascript
.txt  -> text/plain
.json -> application/json
.png  -> image/png
.jpg  -> image/jpeg

It will reject unsafe paths like:

/../secret.txt

This first version will stay deliberately small. It will handle one connection at a time. That is enough to understand the structure.

Create the Project

Create a new Zig project:

mkdir static-server
cd static-server
zig init

Create a directory for files:

mkdir public

Create public/index.html:

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Zig Static Server</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <h1>Hello from Zig</h1>
  <p>This file was served by a small Zig program.</p>
  <script src="/app.js"></script>
</body>
</html>

Create public/style.css:

body {
  font-family: sans-serif;
  max-width: 720px;
  margin: 40px auto;
  line-height: 1.5;
}

Create public/app.js:

console.log("Hello from Zig static server");

The Shape of an HTTP Request

When a browser asks for /style.css, it sends text like this:

GET /style.css HTTP/1.1
Host: localhost:8080
User-Agent: ...

The first line is the most important part for our first server:

GET /style.css HTTP/1.1

It has three pieces:

method path version

For this project, we only accept GET.

Start With a TCP Server

Put this in src/main.zig:

const std = @import("std");

pub fn main() !void {
    const address = try std.net.Address.parseIp("127.0.0.1", 8080);

    var server = try address.listen(.{
        .reuse_address = true,
    });
    defer server.deinit();

    std.debug.print("listening on http://127.0.0.1:8080\n", .{});

    while (true) {
        var connection = try server.accept();
        defer connection.stream.close();

        try handleConnection(connection.stream);
    }
}

fn handleConnection(stream: std.net.Stream) !void {
    _ = stream;
    std.debug.print("received connection\n", .{});
}

Run it:

zig build run

Open this in your browser:

http://127.0.0.1:8080

The browser may show an error because we are not sending an HTTP response yet. But your terminal should print:

received connection

That proves the TCP server is accepting browser connections.

Reading the Request

Now replace handleConnection:

fn handleConnection(stream: std.net.Stream) !void {
    var buffer: [4096]u8 = undefined;
    const n = try stream.read(&buffer);

    const request = buffer[0..n];

    std.debug.print("request:\n{s}\n", .{request});
}

Run the server again and open:

http://127.0.0.1:8080/style.css

You should see an HTTP request printed in the terminal.

The request is plain text. HTTP starts simple. It gets complex later, but the first line is easy to parse.

Parsing the Request Line

We need a function that extracts the method and path.

Add this struct:

const Request = struct {
    method: []const u8,
    path: []const u8,
};

Add this function:

fn parseRequest(request: []const u8) !Request {
    const line_end = std.mem.indexOf(u8, request, "\r\n") orelse return error.InvalidRequest;
    const first_line = request[0..line_end];

    var parts = std.mem.splitScalar(u8, first_line, ' ');

    const method = parts.next() orelse return error.InvalidRequest;
    const path = parts.next() orelse return error.InvalidRequest;
    _ = parts.next() orelse return error.InvalidRequest;

    return Request{
        .method = method,
        .path = path,
    };
}

This function reads only the first line.

For this input:

GET /style.css HTTP/1.1

It returns:

method = GET
path   = /style.css

Sending a Basic HTTP Response

An HTTP response also starts with text:

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 12

Hello World

There is an empty line between headers and body.

Add this helper:

fn sendText(stream: std.net.Stream, status: []const u8, body: []const u8) !void {
    var writer_buffer: [4096]u8 = undefined;
    var writer = stream.writer(&writer_buffer);
    const out = &writer.interface;

    try out.print(
        "HTTP/1.1 {s}\r\n" ++
            "Content-Type: text/plain\r\n" ++
            "Content-Length: {d}\r\n" ++
            "Connection: close\r\n" ++
            "\r\n" ++
            "{s}",
        .{ status, body.len, body },
    );

    try out.flush();
}

Now update handleConnection:

fn handleConnection(stream: std.net.Stream) !void {
    var buffer: [4096]u8 = undefined;
    const n = try stream.read(&buffer);
    const request_text = buffer[0..n];

    const request = parseRequest(request_text) catch {
        try sendText(stream, "400 Bad Request", "bad request\n");
        return;
    };

    if (!std.mem.eql(u8, request.method, "GET")) {
        try sendText(stream, "405 Method Not Allowed", "method not allowed\n");
        return;
    }

    try sendText(stream, "200 OK", "hello from zig\n");
}

Open:

http://127.0.0.1:8080/

You should see:

hello from zig

Now we have a real HTTP response.

Mapping URLs to Files

The browser asks for paths like:

/

or:

/style.css

We need to map them to files under public.

Rules:

/           -> public/index.html
/style.css  -> public/style.css
/app.js     -> public/app.js

We also need to reject path traversal:

/../secret.txt

A path traversal attack tries to escape the public directory. Do not ignore this. File servers must handle it.

Add this function:

fn isSafePath(path: []const u8) bool {
    if (path.len == 0) return false;
    if (path[0] != '/') return false;

    if (std.mem.indexOf(u8, path, "..") != null) {
        return false;
    }

    if (std.mem.indexOf(u8, path, "\\") != null) {
        return false;
    }

    return true;
}

This is a simple safety check. It is not a full URL normalizer, but it is enough for this beginner server.

Now add:

fn pathToFile(path: []const u8) []const u8 {
    if (std.mem.eql(u8, path, "/")) {
        return "public/index.html";
    }

    return path[1..];
}

This function is not finished yet, because for /style.css it returns:

style.css

We need:

public/style.css

Since that requires building a new string, we need an allocator.

Use this version instead:

fn pathToFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    if (std.mem.eql(u8, path, "/")) {
        return try allocator.dupe(u8, "public/index.html");
    }

    return try std.fmt.allocPrint(allocator, "public{s}", .{path});
}

Now the result is allocated memory, so the caller must free it.

Reading and Serving Files

Add a function to detect the content type:

fn contentType(path: []const u8) []const u8 {
    if (std.mem.endsWith(u8, path, ".html")) return "text/html; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".css")) return "text/css; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".js")) return "application/javascript; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".json")) return "application/json; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".txt")) return "text/plain; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".png")) return "image/png";
    if (std.mem.endsWith(u8, path, ".jpg")) return "image/jpeg";
    if (std.mem.endsWith(u8, path, ".jpeg")) return "image/jpeg";

    return "application/octet-stream";
}

Now add a function that sends bytes:

fn sendBytes(
    stream: std.net.Stream,
    status: []const u8,
    mime: []const u8,
    body: []const u8,
) !void {
    var writer_buffer: [4096]u8 = undefined;
    var writer = stream.writer(&writer_buffer);
    const out = &writer.interface;

    try out.print(
        "HTTP/1.1 {s}\r\n" ++
            "Content-Type: {s}\r\n" ++
            "Content-Length: {d}\r\n" ++
            "Connection: close\r\n" ++
            "\r\n",
        .{ status, mime, body.len },
    );

    try out.writeAll(body);
    try out.flush();
}

Now we can read a file and send it:

fn serveFile(allocator: std.mem.Allocator, stream: std.net.Stream, path: []const u8) !void {
    if (!isSafePath(path)) {
        try sendText(stream, "400 Bad Request", "unsafe path\n");
        return;
    }

    const file_path = try pathToFile(allocator, path);
    defer allocator.free(file_path);

    const file = std.fs.cwd().openFile(file_path, .{}) catch |err| {
        switch (err) {
            error.FileNotFound => {
                try sendText(stream, "404 Not Found", "not found\n");
                return;
            },
            else => return err,
        }
    };
    defer file.close();

    const body = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
    defer allocator.free(body);

    try sendBytes(stream, "200 OK", contentType(file_path), body);
}

This version limits each file to 10 MiB:

file.readToEndAlloc(allocator, 10 * 1024 * 1024)

That prevents a huge file from consuming unlimited memory.

Complete Program

Replace src/main.zig with:

const std = @import("std");

const Request = struct {
    method: []const u8,
    path: []const u8,
};

pub fn main() !void {
    const address = try std.net.Address.parseIp("127.0.0.1", 8080);

    var server = try address.listen(.{
        .reuse_address = true,
    });
    defer server.deinit();

    std.debug.print("listening on http://127.0.0.1:8080\n", .{});

    while (true) {
        var connection = try server.accept();
        defer connection.stream.close();

        try handleConnection(connection.stream);
    }
}

fn handleConnection(stream: std.net.Stream) !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();

    const allocator = arena.allocator();

    var buffer: [4096]u8 = undefined;
    const n = try stream.read(&buffer);
    const request_text = buffer[0..n];

    const request = parseRequest(request_text) catch {
        try sendText(stream, "400 Bad Request", "bad request\n");
        return;
    };

    if (!std.mem.eql(u8, request.method, "GET")) {
        try sendText(stream, "405 Method Not Allowed", "method not allowed\n");
        return;
    }

    try serveFile(allocator, stream, request.path);
}

fn parseRequest(request: []const u8) !Request {
    const line_end = std.mem.indexOf(u8, request, "\r\n") orelse return error.InvalidRequest;
    const first_line = request[0..line_end];

    var parts = std.mem.splitScalar(u8, first_line, ' ');

    const method = parts.next() orelse return error.InvalidRequest;
    const path = parts.next() orelse return error.InvalidRequest;
    _ = parts.next() orelse return error.InvalidRequest;

    return Request{
        .method = method,
        .path = path,
    };
}

fn isSafePath(path: []const u8) bool {
    if (path.len == 0) return false;
    if (path[0] != '/') return false;

    if (std.mem.indexOf(u8, path, "..") != null) {
        return false;
    }

    if (std.mem.indexOf(u8, path, "\\") != null) {
        return false;
    }

    return true;
}

fn pathToFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    if (std.mem.eql(u8, path, "/")) {
        return try allocator.dupe(u8, "public/index.html");
    }

    return try std.fmt.allocPrint(allocator, "public{s}", .{path});
}

fn contentType(path: []const u8) []const u8 {
    if (std.mem.endsWith(u8, path, ".html")) return "text/html; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".css")) return "text/css; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".js")) return "application/javascript; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".json")) return "application/json; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".txt")) return "text/plain; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".png")) return "image/png";
    if (std.mem.endsWith(u8, path, ".jpg")) return "image/jpeg";
    if (std.mem.endsWith(u8, path, ".jpeg")) return "image/jpeg";

    return "application/octet-stream";
}

fn sendText(stream: std.net.Stream, status: []const u8, body: []const u8) !void {
    try sendBytes(stream, status, "text/plain; charset=utf-8", body);
}

fn sendBytes(
    stream: std.net.Stream,
    status: []const u8,
    mime: []const u8,
    body: []const u8,
) !void {
    var writer_buffer: [4096]u8 = undefined;
    var writer = stream.writer(&writer_buffer);
    const out = &writer.interface;

    try out.print(
        "HTTP/1.1 {s}\r\n" ++
            "Content-Type: {s}\r\n" ++
            "Content-Length: {d}\r\n" ++
            "Connection: close\r\n" ++
            "\r\n",
        .{ status, mime, body.len },
    );

    try out.writeAll(body);
    try out.flush();
}

fn serveFile(allocator: std.mem.Allocator, stream: std.net.Stream, path: []const u8) !void {
    if (!isSafePath(path)) {
        try sendText(stream, "400 Bad Request", "unsafe path\n");
        return;
    }

    const file_path = try pathToFile(allocator, path);

    const file = std.fs.cwd().openFile(file_path, .{}) catch |err| {
        switch (err) {
            error.FileNotFound => {
                try sendText(stream, "404 Not Found", "not found\n");
                return;
            },
            else => return err,
        }
    };
    defer file.close();

    const body = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);

    try sendBytes(stream, "200 OK", contentType(file_path), body);
}

Run:

zig build run

Open:

http://127.0.0.1:8080/

You should see your HTML page.

Open:

http://127.0.0.1:8080/style.css

You should see the CSS file.

Open:

http://127.0.0.1:8080/app.js

You should see the JavaScript file.

Testing With curl

You can also test with curl:

curl -i http://127.0.0.1:8080/

The -i flag shows response headers.

Example output:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 270
Connection: close

<!doctype html>
<html>
...

Test a missing file:

curl -i http://127.0.0.1:8080/missing.txt

Output:

HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 10
Connection: close

not found

Test an unsafe path:

curl -i http://127.0.0.1:8080/../secret.txt

You should get:

HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Connection: close

unsafe path

Why This Server Handles One Connection at a Time

The main loop does this:

while (true) {
    var connection = try server.accept();
    defer connection.stream.close();

    try handleConnection(connection.stream);
}

That means the server accepts one connection, handles it, closes it, then accepts the next one.

This is simple and good for learning. A production server would usually handle multiple connections at once using threads, an event loop, or async I/O.

The goal here is to understand the complete path from browser request to file response.

Why We Used an Arena

Each request may allocate temporary memory:

const file_path = try pathToFile(allocator, path);
const body = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);

Those allocations are only needed while handling that one request.

So handleConnection creates an arena:

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();

When the request is done, the whole arena is freed at once.

This keeps cleanup simple.

Important Security Notes

This server is for learning.

A production file server needs stricter path handling. It should decode URLs correctly, normalize paths, reject encoded traversal like %2e%2e, handle symbolic links carefully, set better headers, handle large files by streaming, and limit slow clients.

The key lesson still applies: never map a user-provided path directly to the filesystem without validation.

This is unsafe:

const file = try std.fs.cwd().openFile(request.path, .{});

The browser should never get direct control over the filesystem path. The server must decide what paths are allowed.

What You Learned

You built a working static file server.

You accepted TCP connections.

You read HTTP request text.

You parsed the request line.

You sent HTTP responses.

You mapped URLs to files.

You read files from disk.

You returned content types.

You handled missing files.

You rejected unsafe paths.

This is the core of many web servers. Larger servers add concurrency, routing, streaming, caching, compression, TLS, logging, and better HTTP support. The base idea remains the same: read a request, decide what it means, produce a response.