Skip to content

About

High performance HTTP server for exposing static web files.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

static-web

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.

Table of Contents


Quick Start

# 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 init

CLI

For the full flag reference, subcommand documentation, and installation options, see CLI.md.

static-web --help

Features

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

Architecture

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                  │
└─────────────────────────────────────────────────┘

Request path through the cache

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 earlier stat.

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.


Performance

End-to-end HTTP benchmarks

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.

Micro-benchmarks

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

Key design decisions

  • 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.
  • tcp4 listener: 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 = true reads 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-formatted Content-Type and Content-Length headers are assigned directly.
  • Custom Range implementation: parseRange()/serveRange() handle byte-range requests without http.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 = 400 reduces garbage collection frequency — the hot path avoids all formatting allocations, with only minimal byte-to-string conversions from fasthttp's []byte API.
  • Cache-before-stat: os.Stat is never called on a cache hit — the hot path is pure memory.
  • Zero-alloc AcceptsEncoding: walks the Accept-Encoding header byte-by-byte without strings.Split.
  • Pre-computed ETagFull: the W/"..." string is built when the file is cached.

Security Model

Path Safety (internal/security)

Every request URL is validated through PathSafe before any filesystem access:

  1. Null byte rejection — prevents C-level path truncation.
  2. path.Clean normalisation — collapses /../, //, etc.
  3. Prefix check — ensures the resolved path starts with the absolute root (separator-aware to prevent /rootsuffix collisions).
  4. EvalSymlinks re-verification — resolves the canonical real path and re-checks the prefix. Symlinks pointing outside root return ErrPathTraversal. Non-existent paths (ENOENT) fall back to the already-checked candidate.
  5. Dotfile blocking — each path segment is checked for a leading ..

HTTP Security Headers

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)

Method Whitelist

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.

CORS

  • Wildcard (["*"]): emits the literal string * on every response that passes the method and path checks, with or without an Origin header. The request Origin is never reflected, and since the response does not depend on it, Vary: Origin is not added (correct per RFC 6454).
  • Specific origins: each allowed origin is compared exactly. Matching origins receive Access-Control-Allow-Origin: <origin>. Vary: Origin is sent on every response that passes the method and path checks in this mode — including requests with no or a disallowed Origin — so a shared cache never serves one origin's variant to another.
  • Preflight (OPTIONS): returns 204 with Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Max-Age: 86400.

DoS Mitigations

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)

Configuration Reference

Copy config.toml.example to config.toml and edit as needed. The server starts without a config file using sensible defaults.

[server]

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)

[files]

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.

[cache]

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)

[compression]

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

[headers]

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

[security]

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

Environment Variables

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.


TLS / HTTPS

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 addr are automatically redirected to HTTPS. Set redirect_host when tls_addr listens on all interfaces (for example :443) so redirects use a canonical host instead of the incoming Host header.
  • 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.

Pre-compressed Files

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 ./public

Note: On-the-fly brotli encoding is not implemented. Only .br sidecar files are served with brotli encoding. Zstandard comes from .zst sidecar files, or is generated once when a file is cached (as is gzip). With the cache disabled only .zst sidecars are served as zstd, and on-the-fly compression is gzip only; files larger than max_file_size are streamed unencoded.


HTTP Signals

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.


Building & Development

Prerequisites

  • Go 1.26+
  • GNU Make

Commands

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/

Running Tests

go test -race ./...                       # full suite with race detector
go test -run TestPathSafe ./internal/security/...  # specific test
go test -bench=BenchmarkCacheGet -benchtime=10s ./internal/cache/

Code Quality Gates

All PRs must pass:

go build ./...    # clean compile
go vet ./...      # static analysis
go test -race ./... # all tests, race-free

Known Limitations

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.

About

High performance HTTP server for exposing static web files.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages