simple-httpd is a static file server with optional extras. The threat model is: untrusted clients on the listen socket, a trusted config and document root on disk. CGI, FastCGI, SSI, and reverse proxy stay off unless you enable them; turning them on means the process runs or forwards application traffic.
Enable HTTPS with a PEM chain and key:
ssl_cert = /etc/simple-httpd/tls/fullchain.pem
ssl_key = /etc/simple-httpd/tls/privkey.pem
tls_min_version = 1.2
hsts = true
hsts_max_age = 31536000TLS 1.2 is the default floor; set tls_min_version = 1.3 to refuse older handshakes. Cipher suites prefer ECDHE (PFS) and TLS 1.3 AEAD suites. HSTS is sent only on TLS connections. OCSP stapling is not implemented — terminate TLS at a front proxy if you need stapling today. There is no HTTP→HTTPS redirect on a second port.
Permissions: private key 600, owned by the service user. Prefer binding as root (or with CAP_NET_BIND_SERVICE) then user / group to drop privileges after listen.
- Paths are resolved under the selected document root.
..segments that would escape return400or403. - Symlinks are refused unless
follow_symlinks = true. - Directory listing is off by default. Turn it on only for trees you are willing to publish.
Do not point document_root at /, $HOME, or a tree that contains secrets.
Always (from the static handler): X-Content-Type-Options: nosniff.
When security_headers = true (the default):
| Header | Default |
|---|---|
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Content-Security-Policy is sent only when csp is non-empty. The browser enforces it; the daemon does not parse the policy.
The built-in directory listing uses an inline <style> block. Use something like:
csp = default-src 'self'; style-src 'self' 'unsafe-inline'if listing might be enabled. A strict default-src 'self' will leave listings unstyled.
allow_methods defaults to GET, HEAD, OPTIONS. Anything else against a static path gets 405 and an Allow header. Proxy, CGI, and FastCGI prefixes skip that filter so backends can accept POST.
auth_basic_realm = Restricted
auth_basic_file = /etc/simple-httpd/htpasswdFile format is plaintext user:password, one per line (# comments). Copy htpasswd.example, change the password, chmod 600. There is no digest, no bcrypt htpasswd, and no per-path realm — auth covers the whole listener except /healthz and /metrics.
Missing or empty auth files fail startup rather than running open.
rate_limit_enabled = true
rate_limit_requests = 60
rate_limit_window_seconds = 60Counters are per client IP. Over-limit responses are 429 with Retry-After. The table is capped (~20k IPs) so it cannot grow without bound. This is a courtesy throttle, not a DDoS product.
Health and metrics paths are not counted.
allow_ips = 10.0.0.0/8,192.168.0.0/16
deny_ips = 10.0.0.5
max_connections_per_ip = 32
connection_rate_limit_enabled = true
connection_rate_limit = 30
connection_rate_window_seconds = 60Deny is evaluated first. An empty allow_ips means any non-denied peer is accepted. Connections that fail the lists or connection-rate / per-IP caps are closed at accept (and counted as rejected). Request-path checks still return 403 if a peer somehow reaches the pipeline.
security_log = /var/log/simple-httpd/security.logEvents include ip_denied, auth_failed, admin_auth_failed, rate_limited, connection_rate_limited, connection_limit, admin_config, and admin_reload.
Use this as a lightweight control list — not a certification:
- TLS with current certificates; prefer
tls_min_version = 1.3where clients allow - HSTS on public HTTPS
- Document root excludes secrets; symlinks off; listing off
- Basic auth or network ACL for non-public trees and
admin_path - Rate limits and (where needed) IP allow/deny
- Privilege drop (
user/group) after bind; non-root in containers - Logs rotated;
security_logretained for review - Config and htpasswd backed up with restricted permissions
Prefix rewrites happen before auth and static mapping. Do not rewrite /healthz onto a protected file if you still want probes unauthenticated — health is matched after rewrite.
CGI scripts must be executable files under the document root. FastCGI talks to a TCP process you already run. SSI include paths cannot escape the document root; exec is disabled. Reverse proxy backends are cleartext HTTP only — do not point proxy at an untrusted origin. WebSocket is an HTTP/1.1 byte tunnel after 101, not a framed protocol implementation.
- Client certificate authentication
- Digest / OAuth / JWT
- HTTP/3
- HTTPS reverse-proxy origins
- FastCGI unix sockets or php-fpm process management
- SSI
exec/ a template language beyond SSI - GraphQL or a general application plugin API
- Cloud object-store backends (S3/GCS) as document roots
- OCSP stapling
- Request-body inspection beyond size and framing
- chroot or seccomp
Those belong in front (firewall, load balancer, CDN) or remain intentionally out of scope for a static-file daemon. See ROADMAP.md.
Start from config/examples/security.conf.example:
- TLS + HSTS (
tls_min_version1.2 or 1.3) - No directory listing, no symlink follow
- Tight
max_request_size/ header count / timeouts - CSP + security headers
allow_methods = GET, HEAD, OPTIONS- Rate limiting on; consider
max_connections_per_ip - Optional Basic auth for non-public trees;
admin_pathonly with auth user/groupafter bind- Logs to files at
warnorinfo, plussecurity_log