bserver includes several production-oriented features that work automatically without configuration.
bserver uses the HTTP Host header to resolve requests to virtual host
directories under the www/ base directory. Each directory name corresponds
to a domain.
www/<hostname> exists as a directory (or symlink),
the request is served from there.www.example.com when www/example.com
exists), the request is served from www/default.421 Misdirected Request. The request is not served.This means www.example.com and api.example.com automatically work when
you create www/example.com, but deeply nested bogus domains like
update.update.update.m.example.com are rejected immediately.
For domains that need more than one level of subdomains, create a symlink:
cd www && ln -s example.com deep.sub.example.com
default DirectoryThe www/default directory serves as a fallback for known vhosts that don't
have their own directory. For example, if www/example.com exists and
someone visits www.example.com, the request is served from www/default
(since www/www.example.com doesn't exist, but www.example.com is one
level deeper than the known example.com vhost).
If the default directory doesn't exist, requests that would fall back to
it receive a 404 Not Found.
bserver caches rendered YAML and markdown pages in memory. When the same page is requested again, the cached HTML is served directly without re-rendering. This significantly reduces CPU usage for sites with many visitors.
?debug) bypasses the cache entirely.Entries are evicted in three ways:
At startup, bserver checks available system memory on Linux by reading
/proc/meminfo. If available RAM is limited, the cache size is automatically
reduced:
A warning is logged when the effective cache size is lower than the configured maximum. On non-Linux platforms, the configured maximum is used as-is.
These settings go in _config.yaml (in the www directory):
| Setting | Default | Description |
|---|---|---|
cache-size |
1024 |
Maximum cache size in MB (0 to disable) |
cache-age |
900 |
Maximum entry age in seconds (15 minutes) |
static-age |
86400 |
Maximum Cache-Control age for static files in seconds (24 hours) |
Set cache-size: 0 to disable caching entirely.
bserver sets Cache-Control headers on all responses to help browsers and
proxies cache content efficiently.
YAML and markdown pages receive a Cache-Control: public, max-age=N header
where N matches the cache-age setting (default 900 seconds / 15 minutes).
This tells browsers to reuse the page without re-requesting it for that
duration.
For static files (images, CSS, JavaScript, fonts, etc.), bserver uses a heuristic based on the file's last modification time:
static-age (default 24 hours)For example, a CSS file last modified 2 hours ago gets max-age=3600 (1 hour).
A logo image unchanged for 30 days gets max-age=86400 (24 hours, the cap).
This approach means frequently-updated files are re-checked sooner, while stable files are cached longer.
bserver automatically manages TLS certificates for HTTPS. To protect against bogus domains exhausting Let's Encrypt rate limits, certificate requests are restricted to known virtual hosts.
A domain qualifies for a Let's Encrypt certificate only if it passes the same known-vhost check used for request routing:
www/<domain> (e.g., www/example.com)www.example.com works when www/example.com exists)Deeply nested bogus domains like a.b.c.d.example.com are rejected without
contacting Let's Encrypt.
Requests to unknown domains are rejected at two levels:
421 Misdirected Request
without serving any content. This 421 counts as an error for the rate
limiter, so persistent scanners are blocked after 10 attempts.IP addresses and domains with non-public suffixes (.local, .test,
.internal, etc.) always get self-signed certificates without contacting
Let's Encrypt.
Every response includes these security headers automatically:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents browsers from MIME-sniffing |
X-Frame-Options |
SAMEORIGIN |
Blocks framing by other sites (clickjacking protection) |
Referrer-Policy |
strict-origin-when-cross-origin |
Limits referrer information sent to other origins |
These are applied as middleware, so they cover all responses including static files, rendered pages, error pages, and PHP output.
Every HTTP request is logged with the client IP address, hostname, HTTP method, path, response status code, and duration:
203.0.113.42 example.com GET / 200 12ms
203.0.113.42 example.com GET /about 200 3ms
198.51.100.7 example.com GET /missing 404 1ms
The IP address is extracted from the TCP connection source (RemoteAddr).
This makes it easy to identify repeated requests from the same source,
spot scanning patterns, and correlate with rate limiting events.
Cached responses are typically much faster than first renders, making it easy to spot cache misses in the logs.
bserver automatically rate-limits IP addresses that make too many consecutive failed requests (status 400 or higher). This protects against scanning, fishing, and brute-force attacks without affecting normal traffic.
This means legitimate users who occasionally hit a 404 are unaffected — a single successful page view resets the counter entirely.
When a blocked IP sends a request, the server skips all normal request processing (no routing, no rendering, no file I/O) and responds with a minimal drop response using one of several randomized strategies:
429 Too Many Requests503 Service UnavailableThe randomized responses are designed to confuse automated scanners and make it difficult for attackers to distinguish between a block and a genuine server issue. Only the first blocked request is logged (with "dropped" in place of the status code) to avoid flooding the log.
Each time an IP is blocked, the penalty duration doubles:
| Offense | Block Duration |
|---|---|
| 1st | 10 minutes |
| 2nd | 20 minutes |
| 3rd | 40 minutes |
| 4th | 80 minutes |
| ... | ... |
| 9th+ | ~42 hours (cap) |
The penalty level is preserved across blocks, so a persistent attacker faces increasingly long timeouts. The penalty history is cleared when the IP has been idle for at least 1 hour after its block expires.
A scanning attack against a known vhost (error paths on a valid domain):
198.51.100.7 example.com POST /webhook/upload 404 106ms
198.51.100.7 example.com POST /webhook/files 404 109ms
...
198.51.100.7 rate-limited after 10 consecutive errors (penalty: 10m0s)
198.51.100.7 example.com POST /webhook/batch dropped
A scanning attack using bogus domains (rejected at the vhost level):
198.51.100.7 bogus.update.m.example.com GET / 421 0s
198.51.100.7 bogus.update.m.example.com GET /admin 421 0s
...
198.51.100.7 rate-limited after 10 consecutive errors (penalty: 10m0s)
198.51.100.7 bogus.update.m.example.com GET / dropped
Only the first dropped request is logged; subsequent drops from the same IP during the same penalty period are silently discarded.
When HTTPS is active (port 443 is available), all HTTP requests are
automatically redirected to HTTPS with a 308 Permanent Redirect status.
The only exception is ACME HTTP-01 challenge requests from Let's Encrypt,
which are handled on port 80 to complete certificate issuance.
When HTTPS is not available (port 443 cannot be bound), HTTP serves requests directly with the full middleware chain (logging, security headers, rate limiting).
After binding to privileged ports (80 and 443), bserver drops privileges
to the nobody user. This limits the impact of any potential security
vulnerability — even if the server process is compromised, it runs with
minimal filesystem and system permissions.
Privilege dropping is automatic and logged at startup:
Dropped privileges to nobody (UID=65534 GID=65534)
If the nobody user doesn't exist or privilege dropping fails for any
reason, the server continues as the current user and logs a warning.
If port 80 is unavailable (e.g., another process is using it, or the server is running without root privileges), bserver automatically tries alternative ports 8000 through 8099 and uses the first available one.
This makes it easy to run bserver in development without sudo:
Warning: cannot listen on :80 (trying alternative ports)
Using alternative HTTP port: :8000
If port 443 is unavailable, HTTPS is disabled and the server runs HTTP-only. A warning is logged but the server continues normally.
bserver handles SIGINT (Ctrl+C) and SIGTERM signals gracefully:
This means deployments using systemctl restart or container orchestrators
won't drop active requests.
Use -version to print the build version and exit:
$ bserver -version
bserver dev
Override the version at build time with:
go build -ldflags "-X main.Version=1.0.0"
bserver enforces configurable request body limits for dynamic handlers (YAML/Markdown render requests that execute scripts and PHP CGI requests). Oversized bodies are rejected with:
413 Request Entity Too LargeSet this in www/_config.yaml (or per-vhost _config.yaml):
max-body-bytes: 1048576
You can also override globally with environment variable:
MAX_BODY_BYTESThis helps prevent unbounded memory usage from large POST payloads.
HTTPS now uses hardened defaults by default:
X25519, P-256)This keeps compatibility while improving secure-by-default transport settings.