A production-grade, high-performance static web file server written in Go. Built on fasthttp for maximum throughput — ~143k req/sec, 51% faster than Bun's native static server.
- Quick Start
- CLI
- Features
- Architecture
- Performance
- Security Model
- Configuration Reference
- Environment Variables
- TLS / HTTPS
- Pre-compressed Files
- HTTP Signals
- Building & Development
- Package Layout
- Known Limitations
# Go install (requires Go 1.26+)
go install github.com/BackendStack21/static-web/cmd/static-web@latest
# Serve the current directory
static-web .
# Serve a build output directory on port 3000
static-web --port 3000 ./dist
# Scaffold a config file
static-web initFor the full flag reference, subcommand documentation, and installation options, see CLI.md.
static-web --help| Feature | Detail |
|---|---|
| In-memory LRU cache | Size-bounded, byte-accurate; ~27 ns/op lookup with 0 allocations. Optional startup preload for instant cache hits. |
| Compression | On-the-fly gzip; pre-compressed .gz/.br/.zst sidecar support; priority: br > zstd > gzip |
| HTTP/1.1 | Served by fasthttp, over TLS 1.2+ when a certificate is configured (no HTTP/2) |
| Conditional requests | ETag, 304 Not Modified, If-Modified-Since, If-None-Match |
| Range requests | Byte ranges via custom parseRange/serveRange implementation for video and large files |
| TLS 1.2 / 1.3 | Modern cipher suites; configurable cert/key paths |
| Security headers | X-Content-Type-Options, X-Frame-Options, Content-Security-Policy, Referrer-Policy, Permissions-Policy |
| HSTS | Strict-Transport-Security on all HTTPS responses; configurable max-age |
| HTTP→HTTPS redirect | 301 to HTTPS on the HTTP port when TLS is active and redirect_host is set (or tls_addr names a host); otherwise the HTTP port answers 400 |
| Method whitelist | Only GET, HEAD, OPTIONS are accepted (TRACE/PUT/POST blocked) |
| Dotfile protection | Blocks .env, .git/, etc. by default |
| Directory listing | Optional HTML directory index with breadcrumb navigation, sorted entries, human-readable sizes, and dotfile filtering |
| Symlink escape prevention | EvalSymlinks re-verified against root; symlinks pointing outside root are blocked, and files are opened through an os.Root, so a path swapped for a symlink after validation cannot lead outside the root |
| CORS | Configurable per-origin or wildcard (* emits literal *, never reflected) |
| Graceful shutdown | SIGTERM/SIGINT drains in-flight requests with configurable timeout |
| Live cache flush | SIGHUP flushes both the in-memory file cache and the path-safety cache without downtime |
HTTP request
│
▼
┌─────────────────┐
│ recoveryMiddleware │ ← panic → 500, log stack
└────────┬────────┘
│
┌────────▼────────┐
│ loggingMiddleware │ ← logs method/path/status/duration
└────────┬────────┘
│
┌────────▼────────────────────────────────────────┐
│ security.Middleware │
│ • Method whitelist (GET/HEAD/OPTIONS only) │
│ • Security headers (set BEFORE path check) │
│ • PathSafe: null bytes, path.Clean, EvalSymlinks│
│ • Path-safety cache (bounded LRU, pre-warmed) │
│ • Dotfile blocking │
│ • CORS (preflight + per-origin or wildcard *) │
│ • Injects validated path into ctx.SetUserValue │
└────────┬────────────────────────────────────────┘
│
┌────────▼────────────────────────────────────────┐
│ handler.FileHandler │
│ • Cache hit → direct ctx.SetBody() fast path │
│ • Range/conditional → custom serveRange() │
│ • Cache miss → os.Stat → disk read → cache put │
│ • Large files (> max_file_size) bypass cache │
│ • Encoding negotiation: brotli > zstd > gzip > plain │
│ • Preloaded files served instantly on startup │
│ • Custom 404 page (path-validated) │
└─────────────────────────────────────────────────┘
│
┌────────▼────────────────────────────────────────┐
│ compress.Middleware (post-processing) │
│ • Compresses response body after handler runs │
│ • Skips 1xx/204/304, non-compressible types │
│ • Respects q=0 explicit denial │
└─────────────────────────────────────────────────┘
GET /app.js
│
├─ cache.Get("/app.js") hit?
│ YES → 304 if validators match, else serveFromCache (direct ctx.SetBody, no syscall) → done
│
└─ NO → re-validate path (EvalSymlinks) → resolveIndexPath (os.Stat) → cache.Get(canonicalURL) hit?
YES → 304 or serveFromCache → done
NO → size-checked read → cache.Put → 304 or serveFromCache
(bigger than max_file_size: streamed from disk, not cached)
Lexical checks (null bytes, traversal, dotfile segments) run on every request. The path-safety cache lets a cache hit skip EvalSymlinks; a cache miss always re-validates the path against the root before touching disk.
Validation and the open are separate steps, so a deploy process (or anyone with write access inside the root) could swap a path for a symlink in between. Every file the server reads or lists is therefore opened through an os.Root for the resolved root, after an Lstat of the validated path, and must be the file that Lstat named:
- a component that now leads outside the root is refused (403);
- a final component swapped for any symlink (even one to a dotfile inside the root) is refused;
- a large file is described by the opened handle (length,
Last-Modified, ETag), never by an earlierstat.
A path that does not exist (yet) is validated by its deepest existing ancestor, so a file created later under an in-root link to a dot-directory is refused too.
What remains: os.Root follows symlinks that stay inside the root, so a parent directory swapped for a link to another directory inside the root (a dot-directory, say) is still followed. That needs write access inside the root and cannot leave it.
Because os.Root opens each directory it walks for reading, every directory from the root down must be readable by the server user — a traverse-only (--x) directory makes the files below it return 403, and a traverse-only root stops the server from starting.
When preload = true, every eligible file is loaded into cache at startup with the same encoded variants lazy serving builds (pre-compressed .gz/.br/.zst sidecars, generated gzip and zstd), and the path-safety cache is pre-warmed, so the very first request for any preloaded file skips both filesystem I/O and EvalSymlinks. Symlink targets are validated against the root during the preload walk — symlinks pointing outside root (or to dotfiles when they are blocked) are skipped.
Measured in November 2026 on an Apple M2 Pro (macOS, localhost, no Docker) with Go 1.27.3 and Bun 1.4.4. Each run requests the 3 KB public/index.html with bombardier -c 100 -n 100000 after a 50,000-request warm-up. Servers ran one at a time, interleaved; each figure is the median of 8 runs (preload) or 3 runs (the others). benchmark/baremetal.sh reproduces the preload and Bun rows.
| Server | Avg Req/sec | p50 Latency | p99 Latency | Throughput |
|---|---|---|---|---|
static-web (--quiet --preload --gc-percent 400 --no-etag --no-compress) |
~143,000 | 647 µs | 1.23 ms | 491 MB/s |
static-web (default config, no preload; --quiet) |
~146,000 | 623 µs | 1.95 ms | 505 MB/s |
| Bun (native static serve) | ~95,000 | 1.03 ms | 2.06 ms | 335 MB/s |
With preload = true, static-web delivers ~143k req/sec — 51% faster than Bun's native static serving, while offering full security headers, TLS, and compression out of the box.
After warm-up both static-web configurations answer every request from memory, so their steady-state throughput is the same within run-to-run noise; preload removes the cold-miss cost of the first request for each file. Before 1.7.1 the default configuration re-resolved symlinks on every cache hit and reached only ~71k req/sec in the same session.
Measured on an Apple M2 Pro with Go 1.27.3 (go test -bench; the cache rows with -benchtime=5s):
| Benchmark | ops/s | ns/op | allocs/op |
|---|---|---|---|
BenchmarkCacheGet |
~36 M | 27 | 0 |
BenchmarkCacheGetParallel |
6–7 M | 144–159 | 0 |
BenchmarkPathFlow_Hit/PathCache/identity (full middleware chain, cache hit) |
~0.75 M | ~1,330 | 2 |
BenchmarkPathFlow_NotFound/PathCache (full middleware chain, 404) |
~0.07 M | ~13,400 | 56 |
- fasthttp engine: Built on fasthttp — pre-allocated per-connection buffers with near-zero allocation hot path. Cache hits bypass all string formatting; headers are pre-computed at cache-population time.
tcp4listener: when the host is empty or an IPv4 address, an IPv4-only listener eliminates dual-stack overhead on macOS/Linux — a 2× throughput difference vs"tcp". IPv6 literals (e.g.--host ::1, or::for dual-stack on all interfaces) and hostnames use a"tcp"listener.- Preload at startup:
preload = truereads all eligible files into RAM before the first request — eliminating cold-miss latency. - Direct
ctx.SetBody()fast path: cache hits bypass range/conditional logic entirely; pre-formattedContent-TypeandContent-Lengthheaders are assigned directly. - Custom Range implementation:
parseRange()/serveRange()handle byte-range requests withouthttp.ServeContent. - Post-processing compression: compress middleware runs after the handler, compressing the response body in a single pass.
- Path-safety cache: Bounded LRU cache (default 10,000 entries), built whenever the file cache is enabled, lets cache hits skip
filepath.EvalSymlinks. Cache misses always re-validate. Pre-warmed from preload. - GC tuning:
gc_percent = 400reduces garbage collection frequency — the hot path avoids all formatting allocations, with only minimal byte-to-string conversions from fasthttp's[]byteAPI. - Cache-before-stat:
os.Statis never called on a cache hit — the hot path is pure memory. - Zero-alloc
AcceptsEncoding: walks theAccept-Encodingheader byte-by-byte withoutstrings.Split. - Pre-computed
ETagFull: theW/"..."string is built when the file is cached.
Every request URL is validated through PathSafe before any filesystem access:
- Null byte rejection — prevents C-level path truncation.
path.Cleannormalisation — collapses/../,//, etc.- Prefix check — ensures the resolved path starts with the absolute root (separator-aware to prevent
/rootsuffixcollisions). EvalSymlinksre-verification — resolves the canonical real path and re-checks the prefix. Symlinks pointing outside root returnErrPathTraversal. Non-existent paths (ENOENT) fall back to the already-checked candidate.- Dotfile blocking — each path segment is checked for a leading
..
Set on every response of the file-serving listener, including its 4xx/5xx errors and fasthttp's own 400/408/431. Not set on the HTTP→HTTPS redirect listener's 301/400 or on fasthttp's per-IP-limit 429:
| Header | Default Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
SAMEORIGIN |
Content-Security-Policy |
default-src 'self' |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
geolocation=(), microphone=(), camera=() |
Strict-Transport-Security |
max-age=31536000 (HTTPS only) |
Only GET, HEAD, and OPTIONS are accepted. All other methods (including TRACE, PUT, POST, DELETE, PATCH) receive 405 Method Not Allowed. This means TRACE-based XST attacks are impossible by design.
- Wildcard (
["*"]): emits the literal string*on every response that passes the method and path checks, with or without anOriginheader. The requestOriginis never reflected, and since the response does not depend on it,Vary: Originis not added (correct per RFC 6454). - Specific origins: each allowed origin is compared exactly. Matching origins receive
Access-Control-Allow-Origin: <origin>.Vary: Originis sent on every response that passes the method and path checks in this mode — including requests with no or a disallowedOrigin— so a shared cache never serves one origin's variant to another. - Preflight (
OPTIONS): returns204withAccess-Control-Allow-Methods,Access-Control-Allow-Headers, andAccess-Control-Max-Age: 86400.
| Mitigation | Value |
|---|---|
ReadTimeout |
10 s (covers full read phase including headers — Slowloris protection) |
WriteTimeout |
10 s |
IdleTimeout |
75 s (keep-alive) |
MaxRequestBodySize |
1024 bytes (static file server needs no large request bodies) |
MaxConnsPerIP |
Configurable (default 0 = unlimited) |
Copy config.toml.example to config.toml and edit as needed. The server starts without a config file using sensible defaults.
| Key | Type | Default | Description |
|---|---|---|---|
addr |
string | :8080 |
HTTP listen address |
tls_addr |
string | :8443 |
HTTPS listen address |
redirect_host |
string | — | Canonical host used for HTTP→HTTPS redirects |
tls_cert |
string | — | Path to TLS certificate (PEM) |
tls_key |
string | — | Path to TLS private key (PEM) |
read_timeout |
duration | 10s |
Full request read deadline (covers headers; Slowloris protection) |
write_timeout |
duration | 10s |
Response write deadline. For large files streamed from disk (bigger than cache.max_file_size) it is a progress deadline instead: each chunk sent extends it, and the download must average at least 32 KiB/s |
idle_timeout |
duration | 75s |
Keep-alive idle timeout |
shutdown_timeout |
duration | 15s |
Graceful drain window |
max_conns_per_ip |
int | 0 |
Max concurrent connections per IP (0 = unlimited) |
| Key | Type | Default | Description |
|---|---|---|---|
root |
string | ./public |
Directory to serve |
index |
string | index.html |
Index file for directory requests |
not_found |
string | — | Custom 404 page (relative to root) |
max_serve_file_size |
int | 1073741824 |
Max file size to serve in bytes (0 = unlimited; default 1 GB). Files exceeding this limit receive 413. |
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Toggle in-memory LRU cache |
preload |
bool | false |
Load all eligible files into cache at startup |
max_bytes |
int | 268435456 |
Cache size cap (bytes). Counts the memory each entry holds, including its compressed variants |
max_file_size |
int | 10485760 |
Max file size to cache (bytes) |
ttl |
duration | 0 |
Entry TTL (0 = no expiry; flush with SIGHUP) |
gc_percent |
int | 0 |
Go GC target percentage (0 = use Go default of 100) |
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Enable compression |
min_size |
int | 1024 |
Minimum bytes to compress |
level |
int | 5 |
gzip level (1–9) |
precompressed |
bool | true |
Serve .gz/.br/.zst sidecar files |
max_compress_size |
int | 10485760 |
Max body size the middleware gzips on the fly, in bytes (0 = unlimited; default 10 MB). Files cached in memory are compressed once when cached, whatever this limit says |
| Key | Type | Default | Description |
|---|---|---|---|
immutable_pattern |
string | — | Glob for immutable assets |
static_max_age |
int | 3600 |
Cache-Control max-age for non-HTML (seconds) |
html_max_age |
int | 0 |
Cache-Control max-age for HTML (seconds) |
enable_etags |
bool | true |
Enable ETag generation and If-None-Match validation for cache revalidation |
| Key | Type | Default | Description |
|---|---|---|---|
block_dotfiles |
bool | true |
Block .-prefixed path components |
directory_listing |
bool | false |
Enable directory index listing |
cors_origins |
[]string | [] |
Allowed CORS origins (["*"] for wildcard) |
csp |
string | default-src 'self' |
Content-Security-Policy value |
referrer_policy |
string | strict-origin-when-cross-origin |
Referrer-Policy value |
permissions_policy |
string | geolocation=(), microphone=(), camera=() |
Permissions-Policy value |
hsts_max_age |
int | 31536000 |
HSTS max-age in seconds (HTTPS only; 0 disables) |
hsts_include_subdomains |
bool | false |
Add includeSubDomains to HSTS header |
All environment variables override the corresponding TOML setting. Useful for containers. compression.precompressed, headers.immutable_pattern, headers.static_max_age, headers.html_max_age, security.referrer_policy and security.permissions_policy have no environment variable and are config-file only.
| Variable | Config Field |
|---|---|
STATIC_SERVER_ADDR |
server.addr |
STATIC_SERVER_TLS_ADDR |
server.tls_addr |
STATIC_SERVER_REDIRECT_HOST |
server.redirect_host |
STATIC_SERVER_TLS_CERT |
server.tls_cert |
STATIC_SERVER_TLS_KEY |
server.tls_key |
STATIC_SERVER_READ_TIMEOUT |
server.read_timeout |
STATIC_SERVER_WRITE_TIMEOUT |
server.write_timeout |
STATIC_SERVER_IDLE_TIMEOUT |
server.idle_timeout |
STATIC_SERVER_SHUTDOWN_TIMEOUT |
server.shutdown_timeout |
STATIC_SERVER_MAX_CONNS_PER_IP |
server.max_conns_per_ip |
STATIC_FILES_ROOT |
files.root |
STATIC_FILES_INDEX |
files.index |
STATIC_FILES_NOT_FOUND |
files.not_found |
STATIC_FILES_MAX_SERVE_FILE_SIZE |
files.max_serve_file_size |
STATIC_CACHE_ENABLED |
cache.enabled |
STATIC_CACHE_PRELOAD |
cache.preload |
STATIC_CACHE_MAX_BYTES |
cache.max_bytes |
STATIC_CACHE_MAX_FILE_SIZE |
cache.max_file_size |
STATIC_CACHE_TTL |
cache.ttl |
STATIC_CACHE_GC_PERCENT |
cache.gc_percent |
STATIC_COMPRESSION_ENABLED |
compression.enabled |
STATIC_COMPRESSION_MIN_SIZE |
compression.min_size |
STATIC_COMPRESSION_LEVEL |
compression.level |
STATIC_COMPRESSION_MAX_COMPRESS_SIZE |
compression.max_compress_size |
STATIC_HEADERS_ENABLE_ETAGS |
headers.enable_etags |
STATIC_SECURITY_BLOCK_DOTFILES |
security.block_dotfiles |
STATIC_SECURITY_CSP |
security.csp |
STATIC_SECURITY_CORS_ORIGINS |
security.cors_origins (comma-separated) |
STATIC_SECURITY_DIRECTORY_LISTING |
security.directory_listing |
STATIC_SECURITY_HSTS_MAX_AGE |
security.hsts_max_age |
STATIC_SECURITY_HSTS_INCLUDE_SUBDOMAINS |
security.hsts_include_subdomains |
Values are validated at startup after all sources (file, env, flags) are merged: an unparsable environment value (e.g. STATIC_CACHE_MAX_BYTES=256MB or STATIC_CACHE_ENABLED=yes) or an out-of-range setting (negative sizes/timeouts, compression.level outside 1–9 (or -1 for the gzip default) while compression is enabled, only one of tls_cert/tls_key) makes the server print every problem and exit 1. Booleans accept true/false/1/0.
Set tls_cert and tls_key to enable HTTPS:
[server]
addr = ":80"
tls_addr = ":443"
redirect_host = "static.example.com"
tls_cert = "/etc/ssl/certs/server.pem"
tls_key = "/etc/ssl/private/server.key"When TLS is configured:
- HTTP requests on
addrare automatically redirected to HTTPS. Setredirect_hostwhentls_addrlistens on all interfaces (for example:443) so redirects use a canonical host instead of the incomingHostheader. - Connections use HTTP/1.1 (fasthttp has no HTTP/2 support). For HTTP/2 or HTTP/3, put a reverse proxy such as nginx, Caddy or a CDN in front of static-web.
- HSTS (
Strict-Transport-Security) is added to all HTTPS responses (configurable max-age). - Minimum TLS version is 1.2. TLS 1.2 is limited to ECDHE key exchange with AES-GCM (128/256) or ChaCha20-Poly1305 (no CBC, no RSA key exchange); Go chooses the order.
Place .gz, .br, and .zst sidecar files alongside originals. The server serves them automatically when the client signals support:
public/
app.js
app.js.gz ← served for Accept-Encoding: gzip
app.js.br ← served for Accept-Encoding: br (preferred)
app.js.zst ← served for Accept-Encoding: zstd (fastest decompress)
style.css
style.css.gz
style.css.br
style.css.zst
Generate sidecars from the Makefile:
make precompress # runs gzip, brotli and zstd (those installed) on every file in ./publicNote: On-the-fly brotli encoding is not implemented. Only
.brsidecar files are served with brotli encoding. Zstandard comes from.zstsidecar files, or is generated once when a file is cached (as is gzip). With the cache disabled only.zstsidecars are served as zstd, and on-the-fly compression is gzip only; files larger thanmax_file_sizeare streamed unencoded.
| Signal | Action |
|---|---|
SIGTERM |
Graceful shutdown (drains in-flight requests up to shutdown_timeout) |
SIGINT |
Graceful shutdown |
SIGHUP |
Flush the in-memory file cache and path-safety cache; re-read and validate the config |
Note: On SIGHUP the config is rebuilt exactly as at startup (config file → env vars → CLI flags and the positional
[directory]→ validation), so CLI overrides are preserved. The rebuilt config only replaces the copy used for the shutdown timeout: listeners, TLS, and the middleware/handler chain keep the settings they were started with, so a restart is still required for config changes to take effect. If the new config is invalid the old one is kept and caches are not flushed. SIGHUP's main use is flushing both caches after deploying new files, without downtime.
- Go 1.26+
- GNU Make
make build # compile → bin/static-web
make release # compile stripped binary → bin/static-web
make install # install to $(GOPATH)/bin
make run # go run ./cmd/static-web (uses ./config.toml if present)
make test # go test ./...
make bench # go test -bench=. -benchmem ./...
make lint # go vet ./...
make precompress # generate .gz/.br/.zst sidecars for public/
make clean # remove bin/go test -race ./... # full suite with race detector
go test -run TestPathSafe ./internal/security/... # specific test
go test -bench=BenchmarkCacheGet -benchtime=10s ./internal/cache/All PRs must pass:
go build ./... # clean compile
go vet ./... # static analysis
go test -race ./... # all tests, race-free| Limitation | Detail |
|---|---|
| Brotli on-the-fly | Not implemented. Only pre-compressed .br sidecar files are served. |
| SIGHUP config reload | SIGHUP re-reads and validates the config (keeping CLI overrides) but only the shutdown timeout picks it up. Listeners and the middleware chain keep their startup config — full restart required for config changes to propagate. |