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.jsA browser can request:
/The server returns:
public/index.htmlA browser can request:
/style.cssThe server returns:
public/style.cssThis 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.jsIt 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/jpegIt will reject unsafe paths like:
/../secret.txtThis 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 initCreate a directory for files:
mkdir publicCreate 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.1It has three pieces:
method path versionFor 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 runOpen this in your browser:
http://127.0.0.1:8080The browser may show an error because we are not sending an HTTP response yet. But your terminal should print:
received connectionThat 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.cssYou 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.1It returns:
method = GET
path = /style.cssSending a Basic HTTP Response
An HTTP response also starts with text:
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 12
Hello WorldThere 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 zigNow we have a real HTTP response.
Mapping URLs to Files
The browser asks for paths like:
/or:
/style.cssWe need to map them to files under public.
Rules:
/ -> public/index.html
/style.css -> public/style.css
/app.js -> public/app.jsWe also need to reject path traversal:
/../secret.txtA 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.cssWe need:
public/style.cssSince 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 runOpen:
http://127.0.0.1:8080/You should see your HTML page.
Open:
http://127.0.0.1:8080/style.cssYou should see the CSS file.
Open:
http://127.0.0.1:8080/app.jsYou 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.txtOutput:
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 10
Connection: close
not foundTest an unsafe path:
curl -i http://127.0.0.1:8080/../secret.txtYou should get:
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Connection: close
unsafe pathWhy 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.