A zero-config development web server in Rust, with CGI and .htaccess support.
Point it at a directory and you get static files, directory listings, classic CGI
scripts (Perl/PHP/TypeScript via bun), URL rewriting, basic auth, custom error
pages, and self-signed HTTPS — all driven by familiar Apache-style .htaccess
files.
Built for local development and quick prototyping. Not a production server.
- Static file serving with automatic MIME detection
- Directory listings (toggleable per-request and via
Options ±Indexes) - CGI/1.1 execution for
.pland.php(always on);.tsand.jsopt-in via--cgi .htaccesssupport with per-directory overrides:DirectoryIndex,Options ±IndexesErrorDocumentfor custom error pagesAddType,AddDefaultCharsetRedirect(301/302)RedirectMatch(regex-based redirect with$Ncapture substitution; supports status-only forms likeRedirectMatch 204 /favicon.ico$)ExpiresActive,ExpiresDefault,ExpiresByType(mod_expires subset: emitsCache-Control: max-age=NandExpiresheaders based on response Content-Type; supportsaccess plus N <unit>time-spec)Require valid-userandRequire user <name>for per-user authorization (basic auth)Allow from/Deny from/Order(Apache 2.2 IP-based access control) andRequire ip <CIDR>/Require not ip <CIDR>(Apache 2.4 syntax)RewriteEngine,RewriteRule,RewriteCond(incl. flags[QSA],[L],[R],[NC],[F],[G]),RewriteBaseAuthType Basicwith.htpasswd(bcrypt, SHA-1, Apache MD5)Header set/add/unset/append/merge/setifempty/echo/edit/edit*withalways|onsuccesscondition and basic placeholders (%t,%D,%l,%s,%H,%m,%U)
- HTTPS with auto-generated self-signed certificates (cached in
~/.config/webrunner/) or your own cert/key - Simultaneous HTTP + HTTPS listeners on IPv4 and IPv6 (default)
- Graceful shutdown on
Ctrl+C/SIGTERM
brew install codedeviate/cli/webrunnercargo install webrunnerRequires Rust 1.75+ (2021 edition).
git clone https://github.com/codedeviate/webrunner.git
cd webrunner
cargo install --path .Or build a release binary:
cargo build --release
./target/release/webrunnerOptional runtime dependencies (only needed if you serve CGI scripts of that
type): perl, php, bun. Missing interpreters trigger a startup warning, and
the corresponding scripts return 500.
cd /path/to/your/site
webrunnerThen open http://localhost:8080.
webrunner [OPTIONS]
-p, --port <PORT> HTTP port [default: 8080]
--https Enable HTTPS (auto-generates a self-signed cert)
--https-port <PORT> HTTPS port [default: 8443]
--cert <PATH> Path to TLS certificate (PEM); requires --key
--key <PATH> Path to TLS private key (PEM); requires --cert
--no-index Disable directory listing (return 403 for dirs)
--root <DIR> Directory to serve (also accepted as a
positional argument). Default: cwd.
--bind <ADDR> Bind addresses (IPv4/IPv6 literals).
Repeatable / comma-separated.
Default: 0.0.0.0,::
--cgi <EXT[,EXT...]> Extra extensions to execute as CGI (js, ts).
pl and php are CGI by default.
--no-cgi <EXT> Disable always-on CGI for listed extensions
(pl, php). Repeatable / comma-separated.
Mutually exclusive with --cgi per extension.
--cgi-timeout <SECS> CGI script timeout. 0 disables.
Default: 30
--log-level <LEVEL> off | warn | info | debug. Default: info
--log <PATH> Access log file, or - for stdout.
Combined Log Format.
--compression <ON|OFF> Enable brotli / gzip compression for
text-shaped static files. Default: on
--hsts <SECS> Send Strict-Transport-Security on HTTPS
with this max-age. Default: no header.
--hsts-include-subdomains
Add includeSubDomains directive.
Requires --hsts.
--hsts-preload Add preload directive. Requires --hsts.
--examples Print rich usage examples and exit
-h, --help Show help
-V, --version Show version
Run webrunner --examples for a complete cookbook covering CGI, .htaccess,
auth, rewrites, and HTTPS.
webrunner prints to stderr (error, warn) and stdout (info,
debug). Default level is info. Use --log-level warn to suppress
the startup banner, or --log-level off to silence everything except
fatal startup errors.
Access logs. Pass --log <PATH> to write one Combined Log Format
line per request. --log - writes to stdout instead. Access logs are
separate from --log-level: setting --log-level off does not
silence them. The %u field is the authenticated username for
requests that passed AuthType Basic auth, and - otherwise.
Compression. webrunner serves text-shaped responses (text/*,
application/json, application/javascript, SVG, wasm) compressed
with brotli or gzip when the client's Accept-Encoding allows it and
the response is at least 256 bytes. --compression off disables this
entirely. Range requests are always served uncompressed.
webrunner -p 3000 --no-indexwebrunner --root ./docs
webrunner ./docs # same thing, positional formThe path is canonicalised at startup; the Serving … banner shows the
absolute directory webrunner is actually serving. The default (no flag,
no positional) is unchanged: serve the current working directory.
webrunner --https
# https://localhost:8443 (cert cached in ~/.config/webrunner/)The certificate fingerprint is printed at startup so you can verify the exception your browser shows.
webrunner --cert ./cert.pem --key ./key.pemPass --hsts <SECS> to send Strict-Transport-Security: max-age=<SECS> on HTTPS responses. Use --hsts-include-subdomains
and --hsts-preload for the corresponding directives:
webrunner --https --hsts 60 # short experiment
webrunner --https --hsts 31536000 --hsts-include-subdomains
webrunner --https --hsts 31536000 --hsts-include-subdomains --hsts-preloadCaution for localhost work: browsers cache HSTS for the full
max-age and apply it to all paths on that origin — testing HSTS
against https://localhost can leave a browser refusing
http://localhost (or other ports on that origin) until the cache
expires. Start with a short max-age (e.g. --hsts 60) when
experimenting.
webrunner --bind 127.0.0.1,::1Use this on a shared dev box so only localhost clients can reach the
server. Comma-separated IP literals; repeat the flag if you prefer.
webrunner --bind 192.168.1.10Useful for serving the dev site to another device on the LAN (e.g. a phone for mobile testing).
hello.pl (must be executable):
#!/usr/bin/perl
print "Content-Type: text/html\n\n";
print "<h1>Hello from Perl</h1>\n";Browse to http://localhost:8080/hello.pl.
Scripts receive standard CGI/1.1 environment variables (REQUEST_METHOD,
QUERY_STRING, CONTENT_TYPE, CONTENT_LENGTH, PATH_INFO, SCRIPT_NAME,
HTTP_*, …). The POST body is delivered on stdin. Output is parsed as
headers + blank line + body; use Status: 404 Not Found to set a non-200
response.
CGI scripts that don't return within --cgi-timeout seconds (default 30) are killed and the client receives a 504. Pass --cgi-timeout 0
to disable the limit while debugging a slow script interactively.
By default, .js and .ts files are served as static assets — which is
what a normal static site needs. To execute them server-side via bun run
instead, opt in with --cgi:
webrunner --cgi js,tsThe script's stdout is parsed as CGI output (headers, blank line, body),
same as for .pl / .php. bun must be on PATH.
webrunner --no-cgi pl,phpUseful when serving a directory of Perl/PHP source for reading rather
than execution. Mutually exclusive with --cgi for the same extension.
.htaccess:
DirectoryIndex index.php
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
AuthType Basic
AuthName "Restricted Area"
AuthUserFile /absolute/path/to/.htpasswd
Require valid-user
ErrorDocument 404 /errors/404.htmlGenerate the password file with:
htpasswd -B .htpasswd aliceSupported hash formats: bcrypt ($2y$…), SHA-1 ({SHA}…), Apache MD5
($apr1$…).
Use the Header directive in .htaccess to set, add, remove, or
rewrite response headers per directory:
# Security headers, applied to every response (including errors).
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "no-referrer"
# CORS preflight responses.
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
# Strip a leaky header.
Header unset X-Powered-By
# Echo selected request headers back (matched as regex on names).
Header echo "X-Forwarded-.*"
# Add a timing header for browser DevTools.
Header always set X-Request-Duration "%D us"Supported actions: set, setifempty, add, append, merge,
unset, echo, edit, edit*. The optional always|onsuccess
modifier defaults to onsuccess (only applied to 2xx responses).
Placeholders in values: %t (Unix microseconds), %D (request
duration), %l (body size), %s (status), %H (protocol), %m
(method), %U (URL path), %% (literal %).
Note: when --hsts is set, the HSTS layer overwrites any
Header set Strict-Transport-Security for HTTPS responses.
For each request, webrunner walks from the document root down to the target
file's directory and merges the .htaccess files it finds. Deeper files
override shallower ones. Unknown directives are skipped with a warning on
stderr.
cargo test # run the test suite
cargo build # debug build
cargo build --releaseThe crate is organised by concern:
| Module | Responsibility |
|---|---|
cli |
Command-line parsing and validation |
server |
HTTP/HTTPS listeners and graceful shutdown |
handler |
Request dispatch and .htaccess evaluation |
static_files |
Static file serving and directory listings |
cgi |
CGI/1.1 process spawning and response parsing |
htaccess |
.htaccess parser and merger |
rewrite |
RewriteRule / RewriteCond engine |
auth |
HTTP Basic auth with .htpasswd verification |
mime |
MIME-type lookup |
tls |
Self-signed cert generation and loading |
- Not a production server. No request size limits, no rate limiting, no process isolation between CGI scripts. Use it for local development.
- CGI scripts inherit the parent's
PATHso interpreters resolve correctly. - Only a practical subset of
.htaccessis implemented — the directives listed in the Features section. Unknown directives are warned and ignored. <IfModule>blocks are treated as passthrough (their contents always apply, since webrunner implements the relevant module semantics natively).<FilesMatch "regex">and<Files "glob">now scope containedHeader,RewriteRule, auth directives, andAddTypeto files whose basename matches the pattern.<Directory>in.htaccessis recognized but doesn't scope (Apache itself disallows it there).<If "expression">is recognized but doesn't scope until the expression evaluator ships. Common Apache directives webrunner does not implement (php_flag,php_value,FileETag,AddEncoding,AddCharset,AddOutputFilterByType,SetEnv,SetEnvIf,SetEnvIfNoCase,RequestHeader,DirectorySlash) are silently skipped — pass--log-level debugto see what was skipped.- mod_expires supports only the
access/nowbase and single-component specs (access plus 1 year).modificationbase, legacy short form (A3600/M86400), and multi-component specs (access plus 1 year 6 months) are not yet implemented.
MIT © Thomas Björk