Configuration Reference
Complete reference for aloha.kdl
Quick start
aloha is configured via a single
KDL file, read from
aloha.kdl in the current working directory by
default. Pass -c /path/to/file.kdl to
use a different path.
A production web server serving static files over HTTPS needs three things: a plain HTTP listener on port 80 to answer ACME challenges, a TLS listener on port 443 that obtains its certificate automatically, and a vhost that maps your domain to a directory on disk.
// /etc/aloha.kdl
server {
// Directory for ACME account keys, certificates, and JWT keys.
// Must exist and be writable by the aloha user.
state-dir "/var/lib/aloha"
// Drop privileges after binding ports 80 and 443.
user aloha
}
// Port 80: required so Let's Encrypt can reach the HTTP-01 challenge
// endpoint at /.well-known/acme-challenge/. aloha intercepts those
// requests automatically -- no special location rule needed.
listener {
bind "tcp://[::]:80"
}
// Port 443: aloha fetches and renews the certificate automatically.
// The domain must resolve to this server before the first startup.
listener {
bind "tcp://[::]:443"
tls-acme {
domain example.com www.example.com
email [email protected] // for expiry notices from Let's Encrypt
}
}
vhost example.com {
alias www.example.com
location "/" {
static { root "/var/www/example.com" }
}
}
On first startup aloha binds both ports (as root if needed),
drops to the aloha user, then completes the
ACME order before accepting HTTPS connections. Renewal happens
automatically in the background before the certificate expires.
domain names must point at this
server in DNS before the first run.
To redirect plain HTTP traffic to HTTPS rather than serving it directly, replace the port 80 vhost content with a redirect:
vhost example.com {
alias www.example.com
// Redirect HTTP to HTTPS. ACME challenges on port 80 are
// handled before routing and are never caught by this redirect.
location "/" {
redirect { to "https://{host}{path_and_query}"; code 301 }
}
}
// Serve static files only on the TLS listener (443).
// You can use the same vhost name on multiple listeners.
KDL v2 primer
aloha uses KDL version 2. KDL nodes have the form:
node-name arg1 arg2 key=value {
child-node "value"
}
Arguments are positional values placed after
the node name.
Properties are named key=value
pairs.
Children live inside { },
one per line (or separated by ;).
Strings
KDL v2 has three string forms:
| Form | Syntax | When to use |
|---|---|---|
| Quoted | "hello world" |
Strings containing spaces, special characters, or
anything that could be mistaken for a keyword.
Supports standard escape sequences:
\\ \"
\n \t
\r \u{HHHH}.
|
| Bare identifier | example.com |
Simple values with no spaces — hostnames,
service names, short paths.
Legal characters include letters, digits,
- _ .
: and several others, but
not spaces, =,
{ }, ",
or \.
When in doubt, use a quoted string.
|
| Raw string | #"C:\Users\foo"# |
No escape processing. Add more
# marks on both sides if the content
contains "#:
##"contains "# here"##.
Use for regex patterns, Windows paths, or any
string that would need many escape sequences.
|
Keyword literals
KDL v2 uses a # prefix for built-in literals,
distinguishing them from bare-identifier strings:
| Literal | Meaning | Example |
|---|---|---|
| #true | Boolean true | strip-prefix #true |
| #false | Boolean false | starttls #false |
| #null | Null / no value | default-vhost #null |
true (without #) is a
bare-identifier string, not a boolean.
Always use #true / #false.
Numbers
Integers and floats are written without quotes.
Underscores may be used as digit separators:
1_000_000.
Hexadecimal (0xff), octal
(0o77), and binary (0b1010)
are supported for integers.
Comments
//— line comment to end of line./* … */— block comment, may span lines./-— slashdash; comments out the next argument, property, or entire node (including its children).
Multiple nodes per line
A semicolon terminates a node, allowing siblings on one line:
server { user aloha; group aloha }
allow { authenticated } deny code=401
server
Global settings that apply to the entire process.
The server block is optional; omit it to accept
all defaults.
server {
state-dir "/var/lib/aloha"
user aloha
tls {
min-version "1.2"
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| auth | block | — | optional Authentication back-end. |
| error-page | — | — | optional
Custom HTML body for an error status code.
Repeatable. Takes the status code as first argument,
then path="..." or
html="...". |
| geoip | block | — | optional GeoIP database for country-based access control. |
| group | string | user’s primary group | optional
Unix group to switch to after binding. Defaults to
the primary GID of user from
/etc/passwd. |
| cert-key-mode | integer (octal) | 0o600 |
optional
Unix file mode for ACME private key files
(key.pem) under
state-dir. Defaults to owner
read/write only; widen (e.g. 0o640)
to share the key with a group such as a sidecar
process. acme_account.json is
always written 0600 regardless of this setting. |
| access-log | block | — | optional Configure access-log format and destination. Without this block, lines flow through the global tracing subscriber (the historical default). |
| health | bool | block | #true | optional
Enable or disable the built-in health endpoints
(/healthz, /livez,
/readyz). |
| inherit-supplementary-groups | bool | #false | optional
Skip setgroups() during privilege drop,
preserving supplementary groups inherited at startup. |
| policy | block | — | optional Named, reusable policy block. Repeatable; each takes a name as its first argument. |
| state-dir | path | — | Directory for persistent runtime state: ACME account
keys, issued certificates, and JWT signing keys.
Required when any listener uses tls-acme
or when auth jwt is configured. |
| tls | block | — | optional Global TLS defaults inherited by every TLS listener. |
| user | string | — | optional Unix username to switch to after all sockets are bound. Only effective when started as root; silently ignored otherwise. |
access-log — Access log
format and sink
One access-log block at server scope
selects the format and destination for per-request access
records. Without the block, lines pass through the global
tracing subscriber exactly as before.
Example: JSON to a file rotated externally by
logrotate:
server {
access-log {
format "json"
path "/var/log/aloha/access.log"
}
}
| Property | Type | Default | Notes |
|---|---|---|---|
| format | string | — | required
One of tracing, json,
common, combined.
tracing emits structured events through
the global subscriber (historical behaviour);
json emits one newline-delimited JSON
object per request; common is the NCSA
Common Log Format
(%h %l %u %t "%r" %s %b);
combined extends common with
"%{Referer}i" "%{User-agent}i". |
| path | string | stdout | optional
Filesystem path of the sink file (opened append-only,
created if missing). Ignored for
format "tracing". aloha does not
rotate logs; use logrotate with a
move-and-truncate (copytruncate or
signal-based) policy. |
auth — Authentication
back-ends
aloha supports several authentication back-ends: HTTP Basic
credentials validated against PAM or LDAP, an external HTTP
endpoint (subrequest), and ES256 JWT session cookies. Configure
one back-end in the server block; it applies to
every location that challenges users.
Once a back-end is configured, add a
basic-auth block inside a location
to issue a WWW-Authenticate: Basic challenge,
and a policy block to enforce who is allowed
(see Policy blocks).
JWT
Issues and validates ES256 (ECDSA P-256) JWT session cookies
so that browsers need to send credentials only once per
session. After a successful login, aloha sets a
Set-Cookie header; subsequent requests carry the
cookie instead of re-sending credentials.
Externally-issued JWTs (e.g. from an OAuth provider) can also
be validated without aloha issuing tokens (standalone mode).
A P-256 private key is generated on first startup and stored
at {state-dir}/jwt/ec-key.pem
(mode 0600). The corresponding public key is
automatically served at
/.well-known/jwks.json on every virtual host.
Session mode — wraps an existing credential back-end:
server {
state-dir "/var/lib/aloha"
auth jwt {
wrap pam service=login // or ldap, subrequest
cookie-name aloha_session // optional
validity 300 // seconds; optional
}
}
Standalone mode — validates incoming JWTs only, never issues:
server {
state-dir "/var/lib/aloha"
auth jwt {
cookie-name aloha_session
}
}
In both modes, a JWT is accepted from either the named cookie
or an Authorization: Bearer <token>
header. Tokens are checked for a valid ES256 signature, the
correct kid field, and a non-expired
exp claim. The payload carries sub
(username) and groups (array of group names).
Cookies are issued with
HttpOnly; SameSite=Strict; Path=/; Max-Age=N;
the Secure flag is added automatically on TLS
listeners.
| Children | Type | Default | Description |
|---|---|---|---|
| cookie-name | string | "aloha_session" |
optional Name of the session cookie. |
| validity | integer (s) | 300 |
optional Lifetime of issued tokens in seconds. |
| wrap | string | — | optional
Credential back-end for first-time logins.
Accepts the same argument as a top-level
auth block: pam,
ldap, subrequest, or
oidc
with their usual children.
Omit for standalone validation. |
LDAP
server {
auth ldap {
url "ldap://localhost:389"
bind-dn "uid={user},ou=people,dc=example,dc=com"
base-dn "ou=groups,dc=example,dc=com"
// Optional:
group-filter "(memberUid={user})"
group-attr "cn"
starttls #false
timeout 5
}
}
Authentication is performed as an LDAP simple bind.
The {user} placeholder in bind-dn
(and in group-filter) is replaced with the
RFC 4514-escaped username at request time.
After a successful bind, a subtree search under
base-dn finds the user’s group memberships.
Unix socket connections are supported via the
ldapi:// scheme:
auth ldap {
url "ldapi:///var/run/slapd/ldapi"
bind-dn "uid={user},ou=people,dc=example,dc=com"
base-dn "ou=groups,dc=example,dc=com"
}
| Children | Type | Default | Description |
|---|---|---|---|
| base-dn | string | — | required Base DN for the group membership search. |
| bind-dn | string | — | required
DN template for the simple bind. Must contain
{user}, replaced with the
RFC 4514-escaped username. |
| group-attr | string | cn |
optional Entry attribute used as the group name. |
| group-filter | string | (memberUid={user}) |
optional
LDAP filter template for finding a user’s
groups. {user} is replaced with the
RFC 4515-escaped username. Default is
RFC 2307 posixGroup style. |
| starttls | boolean | #false |
optional
Upgrade a plain ldap:// connection to
TLS using STARTTLS. |
| timeout | integer (s) | 5 |
optional Seconds before an LDAP operation is abandoned. |
| url | string | — | required
Server URL. Supported schemes: ldap://
(plain), ldaps:// (TLS),
ldapi:// (Unix socket). |
OIDC
server {
state-dir "/var/lib/aloha"
auth jwt {
validity 3600
wrap oidc {
issuer "https://accounts.example.com"
client-id "aloha"
client-secret-file "/etc/aloha/oidc-secret"
redirect-uri "https://app.example/.aloha/oidc/callback"
// Optional:
scope "openid" "profile" "email"
groups-claim "groups"
username-claim "sub"
}
}
}
Browser-driven single sign-on against any standards-compliant OIDC identity provider (Google, Keycloak, Authelia, Okta, Entra, …). Uses the authorization-code flow with PKCE. OIDC must be wrapped inside auth jwt so the post-login identity can be persisted as an aloha session cookie; subsequent requests carry authentication via that cookie and never round-trip to the IdP.
Two server-wide endpoints are reserved automatically (paths configurable):
GET /.aloha/oidc/login— generates the PKCE challenge and CSRF state, then 302-redirects to the IdP’s authorisation endpoint.GET /.aloha/oidc/callback— validates the state cookie, exchanges the code for an ID token, issues the JWT session cookie, and 302-redirects to the originally requested URL.
When OIDC is configured, anonymous browser clients that hit a
protected location are automatically redirected to the login
endpoint instead of receiving the basic-auth challenge.
API clients (no Accept: text/html or carrying an
Authorization header) keep the 401 response.
| Children | Type | Default | Description |
|---|---|---|---|
| callback-path | string | /.aloha/oidc/callback |
optional
Path the IdP redirects to with the authorisation
code. Must match the path component of
redirect-uri. |
| client-id | string | — | required OAuth2 client identifier registered with the IdP. |
| client-secret | string | — | optional
OAuth2 client secret. Prefer
client-secret-file so the value never
appears in the parsed configuration. |
| client-secret-file | string | — | optional
Path to a file containing the OAuth2 client secret.
Trailing whitespace is stripped. Takes precedence
over an inline client-secret. |
| groups-claim | string | groups |
optional Name of the ID-token claim listing the user’s groups. Accepts a JSON array of strings or a single space-delimited string. |
| issuer | string | — | required
Base issuer URL. Must use https://;
http://localhost is permitted for
development. Discovery is performed at startup. |
| login-path | string | /.aloha/oidc/login |
optional Path on aloha that initiates the OIDC login flow. |
| redirect-uri | string | — | required
Absolute URL registered with the IdP; must resolve
back to callback-path on this server. |
| scope | string… | openid profile email |
optional
One scope "…" child per requested
scope. The mandatory openid scope is
added automatically when absent. |
| state-ttl | integer (s) | 600 |
optional Seconds an unfinished login (PKCE verifier + nonce + return URL) is held before eviction. |
| username-claim | string | sub |
optional
Name of the ID-token claim used as the authenticated
user’s username. Falls back to sub
when the named claim is absent. |
| refresh | boolean | #false |
optional
Enable refresh-token support so that short-lived
session cookies can be renewed without a full IdP
round-trip. When enabled, aloha implicitly requests
the offline_access scope. |
| refresh-ttl | integer (s) | 86400 |
optional Sliding idle timeout for a refresh session. Each successful refresh resets the timer; entries idle longer than this are evicted. |
| refresh-cookie | string | __aloha_oidc_refresh |
optional Name of the long-lived HttpOnly cookie carrying the opaque refresh-session id. |
| logout-path | string | /.aloha/oidc/logout |
optional
Path on aloha that terminates the session. A
GET here clears the JWT and refresh
cookies and (optionally) redirects through the
IdP’s end-session endpoint. |
| post-logout-uri | string | / |
optional Where the browser is sent after logout completes. Must be a same-origin absolute path; pre-register the corresponding absolute URL with the IdP if RP-initiated logout is enabled. |
| idp-logout | boolean | #true |
optional
When true and discovery exposed
end_session_endpoint, the logout
endpoint redirects through the IdP so the IdP-side
session is terminated alongside aloha’s. Set
to #false for IdPs that misbehave on
logout — aloha then performs local-only
logout (clears its own cookies and returns to
post-logout-uri). |
| userinfo | boolean | #false |
optional
When enabled, fetch the IdP’s
/userinfo endpoint after each token
exchange and merge its claims with the ID token
(UserInfo wins on non-empty values). Required for
IdPs that omit groups / email
from the ID token; Google is the canonical
example. Adds one extra HTTP round-trip per login
and per refresh. |
| discovery-refresh | integer (s) | 3600 |
optional
Seconds between periodic re-runs of OIDC discovery.
This is how aloha picks up JWKS rotation at the
IdP without restart. Set to 0 to
disable periodic refresh (only the initial
bootstrap runs). |
| discovery-retry | boolean | #true |
optional
When true (default), discovery failures at startup
do not abort aloha — the provider stays in a
not-ready state and a background task retries with
exponential backoff (capped at 5 minutes). All
OIDC endpoints return 503 with a
Retry-After header until the first
successful discovery. Set to #false
to restore fail-fast startup. |
| backchannel-logout | boolean | #true |
optional
When true (default), expose a
POST-only endpoint that accepts
signed logout_tokens pushed by the
IdP and tears down matching server-side refresh
entries. Spec: OpenID Connect Back-Channel Logout
1.0. |
| backchannel-logout-path | string | /.aloha/oidc/backchannel-logout |
optional
Path the IdP POSTs the logout_token
to. Register the corresponding absolute URL with
the IdP’s client settings. |
| backchannel-max-iat-skew | integer (s) | 120 |
optional
Maximum acceptable clock skew, in seconds, between
the inbound logout-token’s iat
claim and the local clock. Anything older is
rejected as stale. |
| backchannel-jti-ttl | integer (s) | 300 |
optional
How long a successfully-applied logout-token’s
jti is remembered to reject replays.
Should comfortably exceed
backchannel-max-iat-skew. |
| bearer | boolean | #false |
optional
Accept Authorization: Bearer <jwt>
from API callers, validated against the IdP’s
JWKS as a parallel auth path alongside the browser
session cookie. Requires at least one
bearer-audience entry. |
| bearer-audience | string | — | optional
Repeatable: each child contributes one accepted
aud value. A bearer token is rejected
unless its aud claim contains at least
one of the listed values. |
| bearer-cache-size | integer | 1024 |
optional
LRU capacity for verified bearer tokens. Cache
hits skip the per-request signature verification
until the token’s own exp. |
| revoke-on-logout | boolean | #true |
optional
When true (default), the logout endpoint
additionally POSTs the dropped refresh token to
the IdP’s revocation_endpoint
(RFC 7009). Defence-in-depth on top of the
end-session redirect. The call is
fire-and-forget — logout returns promptly
even if the IdP’s revocation endpoint is
slow. |
| require-iss | boolean | #false |
optional
Mix-up attack mitigation (RFC 9207). Aloha
always rejects callbacks whose iss
parameter is present but does not match the
configured issuer. With require-iss
#true the callback also rejects
responses that omit iss entirely. |
| resource | string | — | optional
Repeatable: each child contributes one RFC 8707
resource value. Forwarded on the
authorization, code-exchange, and refresh
requests so the IdP narrows the access
token’s aud to the listed
resources. Values must be absolute http(s) URLs
without a #fragment. |
When refresh is enabled, the callback response
sets a second cookie alongside the session JWT: an opaque
session id that maps to an in-memory entry holding the IdP
refresh token. On any request where the session JWT has
expired but the refresh cookie is still present, aloha calls
the IdP’s token endpoint, issues a fresh JWT, and serves
the request without bouncing the browser through the login
redirect. Refresh state is in-memory only; a server restart
forces users to re-log in.
GET <logout-path> tears the session down:
aloha drops the server-side refresh entry, emits past-dated
Set-Cookie headers that clear the JWT, refresh,
and login-state cookies, then issues a 302.
With idp-logout enabled and an
end_session_endpoint in the IdP’s
discovery document, the redirect carries
id_token_hint, post_logout_redirect_uri,
and client_id through the IdP so its session is
terminated too; otherwise the browser goes straight to
post-logout-uri. Hitting the logout endpoint
without an active session is a safe no-op redirect.
Back-channel logout. With
backchannel-logout enabled (the default), aloha
also exposes a POST-only endpoint that accepts
signed logout_token JWTs pushed by the IdP.
Each token is validated (signature against the cached JWKS,
iss/aud, iat within
backchannel-max-iat-skew, the back-channel-logout
events claim, presence of sub or
sid, absence of nonce, and
jti uniqueness within
backchannel-jti-ttl). On success aloha removes
every refresh entry whose stored sid matches
(or every entry for the user when only sub was
sent). This only tears down server-side state
— the user’s in-flight JWT session cookie remains
valid until its own expiry, so operators who need fast
revocation should keep validity short.
Login-flow query passthrough. The login
endpoint forwards the standard OIDC hints
login_hint, prompt,
max_age, acr_values, and
ui_locales to the IdP’s authorisation URL
when they appear on the inbound query string (alongside
return). Values are length-capped at 256 bytes
and restricted to printable ASCII. Useful for SPAs that
need prompt=none silent re-auth, password-
manager deep links via login_hint, MFA
enforcement via acr_values, and localised
login pages via ui_locales. See OIDC Core 1.0
§3.1.2.1 for the canonical definitions.
Bearer-token resource-server mode. With
bearer enabled, aloha accepts
Authorization: Bearer <jwt> from API
callers in addition to the browser session cookie. Tokens
are validated against the IdP’s cached JWKS
(signature, iss, exp,
nbf) and rejected unless the aud
claim contains at least one configured
bearer-audience. Successful validations are
cached by SHA-256(token) under the configured
bearer-cache-size, so repeated requests with
the same token skip the crypto until the token’s own
exp. The resolved Identity uses the same
username-claim / groups-claim
configuration as the SSO flow, so access policies see one
consistent Identity shape regardless of which auth path
produced it. Supports JWT-format tokens only; opaque
(introspection-required) tokens are not yet handled.
OAuth standards-track polish. Three small
defensive measures sit alongside the main flow:
revoke-on-logout (RFC 7009) tells the IdP to
invalidate the refresh token at logout in addition to the
existing end-session redirect;
require-iss + the (always-on) iss
match on the callback (RFC 9207) protects against
mix-up attacks where a malicious site tricks a logged-in
user into completing an authorization flow against the
wrong IdP;
resource (RFC 8707) lets the relying party
narrow the access token’s aud to one or
more specific protected resources, which pairs naturally
with bearer-token resource-server mode above.
PAM
server {
auth pam {
service login // PAM service name; defaults to login
}
}
Credentials are validated by calling into libpam using the
named service (/etc/pam.d/<service>).
After a successful authentication, the user’s Unix group
memberships are resolved via getgrouplist(3) and
become available for group conditions in
policy blocks.
PAM is dispatched on a dedicated blocking thread so the async
runtime is not stalled.
| Children | Type | Default | Description |
|---|---|---|---|
| service | string | "login" |
optional
PAM service name. Must correspond to a file in
/etc/pam.d/. |
File (htpasswd)
server {
auth file {
path "/etc/aloha/htpasswd"
cache 60 // seconds between mtime checks
}
}
Validates HTTP Basic credentials against an htpasswd-style
file. Each line is user:hash[:group1,group2,...];
blank lines and lines beginning with # are
ignored. Supported hash schemes are
bcrypt ($2y$, $2b$,
$2a$), SHA-512 crypt
($6$), and Argon2id
($argon2id$). Weaker schemes (DES, MD5-crypt,
plain) are dropped at parse time so a misconfigured file
never silently authenticates against a weak hash.
Generate entries with the standard tools:
htpasswd -B -C 10 for bcrypt,
mkpasswd -m sha-512 for SHA-512 crypt,
or argon2 from the libargon2 package.
Group memberships in the optional third column become
available for group conditions in
policy blocks.
The parsed table is cached in memory; aloha re-stats the
file once cache seconds have elapsed and
reparses only if the mtime has changed. This makes
operator edits visible within ~cache seconds
without paying the parse cost on every request.
| Children | Type | Default | Description |
|---|---|---|---|
| path | string | — | required
Filesystem path to the htpasswd file. Accepts
the positional shorthand
auth file "/etc/aloha/htpasswd". |
| cache | integer (seconds) | 60 |
optional Seconds between freshness checks against the file’s mtime. A change triggers a full reparse on the next request. |
Subrequest
Delegates authentication to an external HTTP endpoint —
the same pattern as nginx’s auth_request
module. For every request that needs authentication, aloha
makes an outgoing HTTP GET to the configured URL, forwarding
selected request headers.
An HTTP 200 response means authenticated; any other status
or a network error means anonymous.
server {
auth subrequest {
url "http://auth.internal:8080/validate"
forward-header Authorization // repeatable
forward-header Cookie
user-header X-Auth-User // identity from response
groups-header X-Auth-Groups
timeout 5
}
}
When the auth endpoint returns 200, aloha reads identity from
the response headers named by user-header and
groups-header.
If neither header is configured, a 200 still counts as
authenticated (suitable for pure allow/deny decisions).
| Children | Type | Default | Description |
|---|---|---|---|
| forward-header | string | — | repeatable
Request header forwarded verbatim to the auth
endpoint. Typically Authorization or
Cookie. |
| groups-header | string | — | optional Response header holding a comma-separated list of group names. |
| timeout | integer (s) | 5 |
optional Seconds to wait for the auth endpoint before treating the request as anonymous. |
| url | string | — | required
HTTP endpoint to call. Must use
http:// scheme. |
| user-header | string | — | optional Response header whose value becomes the authenticated username. |
The typical deployment pairs the subrequest back-end on the
protected server with an
auth-request
handler on the auth server. See that section for the
complete two-server example.
error-page
Override the default minimal response body for any HTTP error
status code. Define error pages in the server
block; they apply to all error responses generated by access
policy denials.
server {
error-page 403 path="/var/www/errors/403.html"
error-page 401 html="<h1>Authentication Required</h1>"
error-page 404 path="/var/www/errors/404.html"
}
| Syntax | Description |
|---|---|
error-page N path="..." |
Read HTML from this file on each error response. The file is re-read on every request, so updates take effect without restarting aloha. |
error-page N html="..." |
Use this literal HTML string as the response body. |
The Content-Type is always
text/html; charset=utf-8. The error page
replaces only the body; the status code and headers
(e.g. WWW-Authenticate for 401 responses) are
set normally. If the file is missing or unreadable, aloha
falls back to the default minimal body.
geoip
Restricts access by the client’s country of origin using
a MaxMind MMDB database. Configure the path once in the
server block; then use country
predicates in any policy block.
server {
geoip {
db "/etc/aloha/GeoLite2-Country.mmdb"
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| db | path | — | required
Filesystem path to the MaxMind MMDB file.
Compatible databases: GeoLite2-Country
(country codes only),
GeoLite2-City, or any MMDB file
that contains a country.iso_code
field. |
The database is loaded into memory at startup; the file must
be readable by the aloha process after privilege drop.
Country codes are ISO 3166-1 alpha-2 (two uppercase
letters). Private and reserved IP ranges
(127.0.0.0/8, 10.0.0.0/8, etc.) are not in the database and
will not match a country
predicate. Combine with address to allow private
ranges explicitly.
// Allow only US, Canada, and UK; plus the internal network
policy {
allow address "10.0.0.0/8"
allow country US CA GB
deny code=403
}
country predicates appear anywhere but no
server { geoip { db … } } is
configured.
user and group
When aloha is started as root (necessary to bind ports 80 and
443), set user to drop privileges immediately
after all sockets are bound and before any connections are
accepted.
The syscall sequence is
setgroups → setgid →
setuid.
In container deployments that use
podman run --group-add keep-groups,
set inherit-supplementary-groups #true to
preserve the supplementary groups passed by podman rather than
replacing them with a setgroups() call.
server { user www-data }
server { user aloha; group aloha }
// container: preserve groups from --group-add keep-groups
server { user aloha; inherit-supplementary-groups #true }
health
aloha responds to GET and HEAD
requests on /healthz, /livez, and
/readyz with a 200 OK JSON body
{"status":"ok","check":"healthz"} (and so on).
These endpoints are intercepted before vhost routing, so they
work without a Host header and cannot be
shadowed by user-defined locations. They are enabled by
default; set health #false to disable them.
server {
health #false // disable all health endpoints
}
// Block form (equivalent):
server {
health { enabled #false }
}
| Children | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | #true |
optional
Enable or disable the built-in health endpoints.
Equivalent to passing #false directly
as a positional argument to health. |
/livez), readiness
(/readyz), and startup (/healthz)
probes can all point at these paths without any additional
configuration. Only GET and HEAD
are handled; other methods fall through to normal routing.
tls — Global TLS
defaults
These settings can appear in a tls block
inside server (global defaults) or inside a
listener (per-listener override). Per-listener
values take precedence.
server {
tls {
min-version "1.2"
cipher TLS13_AES_256_GCM_SHA384
cipher TLS13_CHACHA20_POLY1305_SHA256
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| cipher | string | provider defaults | repeatable Restrict the allowed cipher suites by name. |
| min-version | "1.2" | "1.3" |
"1.2" |
optional Minimum TLS protocol version to accept. |
listener
Opens a socket and begins accepting connections. At least one is required. Use separate listeners for plain HTTP and HTTPS.
// Plain HTTP
listener {
bind "tcp://[::]:80"
}
// HTTPS with an explicit default vhost
listener {
bind "tcp://[::]:443"
default-vhost example.com
tls-file cert="/etc/aloha/cert.pem" key="/etc/aloha/key.pem"
}
// null disables the fallback -- unrecognised hosts get a 404
listener {
bind "tcp://[::]:80"
default-vhost #null
}
// Unix domain socket (useful behind a front-end proxy)
listener {
bind "unix-stream:/run/aloha.sock"
}
Unix socket connections have no IP address; they are treated as
coming from 127.0.0.1 for access-control and
GeoIP purposes. Access logs show [unix] as the
peer.
| Children | Type | Default | Description |
|---|---|---|---|
| accept-proxy-protocol | v1 | v2 |
— | optional
Read a HAProxy PROXY protocol header from each
incoming connection before TLS or HTTP parsing.
The real client address from the header replaces the
TCP peer address for access rules, logging, and
forwarding headers. Use when aloha sits behind
HAProxy or an AWS NLB that speaks PROXY protocol.
v2 (binary) is preferred. Pair with
trusted-proxies
to restrict which peers may inject headers. |
| trusted-proxies | list of CIDR / IP | — | optional
Allowlist of peer addresses permitted to send a PROXY
protocol header. Only meaningful together with
accept-proxy-protocol.
When omitted, the listener trusts every peer —
safe only if the listener is firewalled from
untrusted networks. Connections from peers outside
the list are dropped before the header is read, so
arbitrary clients cannot spoof their source address.
Accepts bare IPs or CIDR ranges, e.g.
trusted-proxies "10.0.0.0/8" "192.168.1.1".
|
| bind | string | — | required
Address to listen on. Use "host:port"
for TCP (e.g. "[::]:8080"),
"udp://host:port" for QUIC/HTTP/3 (requires
the http3 cargo feature), or
"unix-stream:/path" for a Unix domain socket.
If a matching socket was inherited from a parent
process (seamless upgrade), aloha adopts it instead
of calling bind(2).
Pairing a TCP/TLS listener with a udp:
listener on the same port enables automatic
HTTP/3 advertisement via the Alt-Svc
response header. The auto-injected value
(h3=":<port>"; ma=86400) is only
added when no Alt-Svc header is already
present on the response, so a
response-headers { set "Alt-Svc" "..." }
rule in a location or vhost always wins. |
| default-vhost | string | #null |
first vhost | optional
Vhost used when no Host header matches.
Set to #null to return 404 for
unrecognised hosts. |
| alpn | strings (positional) | h2 http/1.1 on TLS listeners |
optional
Override the listener’s advertised ALPN
protocols. Order matters — the first
entry the client accepts wins. A vhost can
further narrow this list via its own
alpn
child (TCP/TLS only; QUIC listeners always use
the listener default). At least one value is
required. |
| quic-transport | block | — | optional
Per-listener QUIC transport knobs. Only valid
on udp: listeners; misuse on a TCP
listener is a parse-time error. |
| max-connections | integer | unlimited | optional Maximum simultaneous open connections on this listener. New connections are deferred (not dropped) when the limit is reached, so the kernel accept queue is never backed up. |
| max-request-body | integer (bytes) | unlimited | optional
Reject requests whose
Content-Length header exceeds this
value with
413 Content Too Large. Applies before
any handler runs. HTTP mode only. |
The bind address may also be written as the listener’s
positional argument: listener "tcp://[::]:80" { … }.
quic-transport —
QUIC transport tuning
Per-listener overrides for the QUIC transport. Only valid
on udp: listeners; absent knobs fall back to
the quinn defaults.
listener {
bind "udp://[::]:443"
tls-acme { domain "example.com" }
quic-transport {
max-concurrent-bidi-streams 100
max-idle-timeout 30 // seconds
keep-alive-interval 15
zero-rtt #false
retry-tokens #true
retry-token-lifetime 15
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| max-concurrent-bidi-streams | integer | quinn default | optional Cap on simultaneous client-initiated bidirectional streams. Limits an abusive peer from opening unbounded request streams over a single QUIC connection. |
| max-idle-timeout | integer (seconds) | quinn default | optional Close a QUIC connection after this many seconds with no traffic in either direction. |
| keep-alive-interval | integer (seconds) | off | optional Send a PING frame this often when the connection is otherwise idle, so NAT bindings and stateful middleboxes don’t time the path out. |
| zero-rtt | bool | #false |
optional Accept 0-RTT early data on resumed sessions. 0-RTT data is replayable, so leave this off unless every endpoint reachable through this listener is fully idempotent. |
| retry-tokens | bool | #true |
optional Force source-address validation via QUIC Retry packets. Defends against spoofed-source connection floods at the cost of one extra round-trip on the first handshake from each client address. |
| retry-token-lifetime | integer (seconds) | quinn default | optional How long an issued retry token remains valid before the client must re-handshake. |
proxy — Stream
(TCP) proxy
Adding a proxy block to a listener
activates stream mode: raw TCP bytes are forwarded to the
upstream. HTTP routing does not apply.
Combine with a tls-* block on the listener to
terminate TLS from clients before forwarding — aloha
decrypts the incoming connection and forwards the plaintext
stream. Add a tls block inside the
proxy block to re-encrypt the upstream connection
(re-TLS).
// Plain TCP tunnel to a PostgreSQL backend
listener "tcp://[::]:5432" {
proxy "db.internal:5432" {
proxy-protocol v2
}
}
// Unix domain socket upstream
listener "tcp://[::]:5432" {
proxy "unix-stream:/run/postgresql/.s.PGSQL.5432"
}
// TLS termination: clients connect over TLS, backend gets plaintext
listener "tcp://[::]:5433" {
tls-self-signed
proxy "db.internal:5432" {
proxy-protocol v2
}
}
// Full re-TLS: terminate client TLS, re-encrypt to upstream
listener "tcp://[::]:5433" {
tls-file cert="cert.pem" key="key.pem"
proxy "db.internal:5432" {
tls // verify with bundled WebPKI roots
}
}
// Restrict a database port to an internal CIDR block
listener "tcp://[::]:5432" {
proxy "db.internal:5432"
policy {
allow address "10.0.0.0/8"
deny code=403
}
}
Arguments
| Position | Type | Description |
|---|---|---|
| upstream | string | required
Upstream address: "host:port" for TCP
or "unix-stream:/path" for a Unix domain
socket. |
Children
| Child | Type | Default | Description |
|---|---|---|---|
| proxy-protocol | v1 | v2 |
— | optional
Prepend a HAProxy PROXY protocol header so the
upstream sees the real client IP. v2
is preferred. |
| tls | block | — | optional Connect to the upstream using TLS. Verifies the upstream certificate against system CA roots. |
| tls › skip-verify | flag | — | optional
Disable upstream certificate verification. Presence
of the node is sufficient; no value required:
tls { skip-verify }. |
A policy block placed at the
listener level (sibling to proxy)
may only use address and country
predicates. Identity predicates are rejected at startup.
Denied connections are closed silently; redirect rules are
treated as deny.
A config consisting entirely of stream listeners is valid with
no vhost defined at all.
timeouts
Optional connection and request timeout settings. All values
are in whole seconds.
request-header defaults to 30 s
even when the timeouts block is absent; set it to
0 to disable. All other values default to unlimited.
listener {
bind "tcp://[::]:8080"
timeouts {
request-header 30
handler 60
keepalive 75
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| handler | integer (s) | unlimited | optional
Maximum seconds a request handler may run before it
is cancelled and
408 Request Timeout is returned. |
| keepalive | integer (s) | unlimited | optional
HTTP/1.1 keep-alive idle timeout. Set to
0 to disable keep-alive entirely. |
| request-header | integer (s) | 30 | optional
Maximum seconds to wait for a complete request line
and headers. Protects against Slowloris-style
attacks. Set to 0 to disable. |
tls-* — Certificate mode
Add one of three sibling blocks to a listener to enable TLS. Each accepts its settings either inline (one-line form) or as children in a block.
// Self-signed -- ephemeral, generated at startup (development only)
tls-self-signed
// PEM files from disk
tls-file cert="/etc/aloha/cert.pem" key="/etc/aloha/key.pem"
// ACME / Let's Encrypt
tls-acme {
domain example.com www.example.com
email [email protected]
}
| Node | Required values | Description |
|---|---|---|
tls-file |
cert, key |
PEM certificate chain and private key from disk. |
tls-self-signed |
— | Generates an ephemeral cert at startup. Development use only — not trusted by browsers. |
tls-acme |
domain+ and state-dir in
server |
Obtain and renew certificates automatically via ACME HTTP-01. |
tls |
certificate name (positional or
cert="name") |
Reference a top-level
certificate.
Multiple listeners that share a name share a single
acceptor (and, for ACME, one renewal loop). |
tls-acme
blocks that resolve to the same on-disk slot — the same
explicit name, or the same first
domain when name is unset. Define a
top-level certificate and reference it from both
listeners.
tls-acme — ACME /
Let’s Encrypt
With tls-acme, aloha obtains and automatically
renews a certificate via the ACME HTTP-01 challenge.
A plain HTTP listener must be running to answer challenge
requests. Requires state-dir in the
server block.
listener {
bind "tcp://[::]:443"
tls-acme {
domain example.com www.example.com
email [email protected]
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| domain | string | — | required Domain name for the Subject Alternative Name. repeatable — at least one required. |
| string | — | optional Contact address registered with the ACME provider. | |
| name | string | first domain | optional
Storage subdirectory name under
state-dir. |
| retry-interval | integer (s) | 3600 |
optional Seconds between retry attempts after a certificate failure. |
| server | URL | Let’s Encrypt | optional Override the ACME directory URL. |
| staging | boolean | #false |
optional Use the Let’s Encrypt staging server (untrusted, no rate limits — useful for testing). |
| challenge | string | "http-01" |
optional
Which ACME challenge to use. One of
"http-01" (port 80, default;
no extra setup),
"dns-01" (publishes TXT records via
a dns-provider; the only type that
can validate wildcard SANs), or
"tls-alpn-01" (validates on
port 443 using the acme-tls/1
ALPN, so no extra port is needed). |
| dns-provider | block | — | optional
Required when challenge is
"dns-01". Selects the back-end
that publishes the validation TXT records.
Built-in kinds: "acme-dns"
(API user / API key for an acme-dns server),
"cloudflare" (zone‑id +
bearer token), "route53" (hosted
zone id; AWS credentials from the standard
provider chain; requires
--features dns-route53), and
"exec" (runs an external program
with the FQDN and TXT value in the environment).
See the example below. |
// Wildcard cert via Cloudflare DNS-01:
tls-acme {
domain *.example.com
email "[email protected]"
challenge "dns-01"
dns-provider "cloudflare" {
zone-id "abc123..."
api-token "cf-token..."
}
}
// Or with an operator-supplied shell hook:
tls-acme {
domain vpn.example.com
challenge "dns-01"
dns-provider "exec" {
program "/usr/local/libexec/aloha-dns.sh"
}
}
// TLS-ALPN-01: needs no extra port.
tls-acme {
domain internal.example.com
challenge "tls-alpn-01"
}
program (plus any
args) with three environment variables:
ALOHA_DNS_ACTION (set or
clear),
ALOHA_DNS_FQDN (the full record name,
including the _acme-challenge. prefix), and
ALOHA_DNS_VALUE (the base64url SHA-256
digest aloha needs published as a TXT record). A
non-zero exit aborts the challenge.
mtls — Mutual TLS
(client‑cert auth)
Inside any tls-file, tls-self-signed,
or tls-acme block, an mtls { }
child installs a rustls
WebPkiClientVerifier so the listener requires (or
accepts) a client certificate chained to one of the listed CAs.
A verified leaf certificate becomes an authenticated
Principal: its Common Name is exposed as the
request username, and the full subject DN and SAN list are
available as the {client_cert_subject} and
{client_cert_sans} template variables for
request-headers / response-headers
rules.
listener {
bind "tcp://[::]:443"
tls-file cert="/etc/...crt" key="/etc/...key" {
mtls {
ca "/etc/aloha/clients-ca.pem"
mode "required"
revocation "/etc/aloha/crl.pem"
refresh 300
}
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| ca | path | — | required repeatable Trust‑anchor PEM file. At least one is required; the client's leaf must chain to one of them. |
| mode | string | "required" |
optional
"required" aborts the handshake when the
client sends no certificate or one not chained to a
listed CA. "optional" still accepts
anonymous clients; only verified leaves set the
Principal. |
| revocation | path | — | optional repeatable PEM‑encoded certificate revocation list. The verifier rejects any leaf whose serial appears in the union of all listed CRLs. |
| refresh | integer (s) | 0 |
optional
When greater than zero and at least one
revocation file is set, aloha re‑reads
every CRL file on this interval and atomically swaps
the verifier so the new revocation set applies to all
future handshakes. Reload failures keep the previous
verifier live. |
username (so policy
allow user "alice" works directly);
the groups list is empty. Layer with PAM/LDAP/OIDC if you
also need group membership.
ocsp — OCSP stapling
Inside any tls-file or tls-acme block,
aloha runs a background OCSP‑stapling task by default:
on startup it reads the certificate's Authority Information
Access (AIA) extension to find the issuer's OCSP responder
URL, POSTs a request, and attaches the resulting response to
every TLS handshake via the
certificate_status_request extension. The staple
is refreshed in the background before the responder’s
nextUpdate field expires and is persisted to disk
under state-dir/ocsp/ so restarts don’t
replay a fresh fetch.
listener {
bind "tcp://[::]:443"
tls-file cert="/etc/...crt" key="/etc/...key" {
ocsp #false // disable for this listener
ocsp-timeout 10 // fetch timeout (s)
ocsp-min-refresh 3600 // floor for refresh cadence (s)
ocsp-failure-backoff 300 // retry delay after a failure (s)
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| ocsp | boolean | #true |
optional
Master switch. #false skips OCSP
entirely for this listener (no fetch, no staple). |
| ocsp-timeout | integer (s) | 10 |
optional HTTP request timeout when contacting the responder. |
| ocsp-min-refresh | integer (s) | 3600 |
optional
Floor for the in‑memory refresh interval; aloha
re‑fetches at least this often even if the
responder advertises a far‑future
nextUpdate, so revocations propagate. |
| ocsp-failure-backoff | integer (s) | 300 |
optional Retry interval after an OCSP fetch failure. Until the next success, the listener keeps serving without a staple. |
ocsp_refresh_failures on the status page. TLS
handshakes are never aborted because of an OCSP problem.
certificate
Defines a named certificate at the top level of the config so
that any number of listeners can share it via
tls cert="name". For ACME
sources this collapses what would otherwise be N independent
renewal loops — one per listener — into a single
background task that hot-swaps one shared acceptor.
The body contains exactly one source child:
acme, files, or
self-signed. Each accepts the same properties and
children as the corresponding inline form
(tls-acme,
tls-file,
tls-self-signed).
// One ACME cert shared by two listeners
certificate "main" {
acme {
domain example.com www.example.com
email [email protected]
}
}
listener {
bind "tcp://[::]:443"
tls cert="main"
}
listener {
bind "tcp://[::]:8443"
tls cert="main"
}
// File-based shared cert
certificate "internal" {
files cert="/etc/aloha/internal.crt" key="/etc/aloha/internal.key"
}
certificate whenever two or more
listeners would otherwise need identical
tls-acme or tls-file blocks. Aloha
rejects such configurations at parse time because the
duplicate ACME managers would race on the same on-disk
directory.
vhost
Maps one or more hostnames to a set of URL routing rules.
Requests are matched against the Host header
(port suffix stripped). At least one vhost is required for
HTTP mode; stream-proxy-only configs may omit them.
Name matching
The first argument is the primary name; alias
adds extra names. Both support two matching modes:
- Exact literal — the default. The full hostname must match exactly.
- Regex — add
regex=#trueas a property. The string is compiled as a regular expression anchored at both ends (^...$). Invalid patterns are caught at startup.
For each request, matching proceeds in this order:
- Exact literal match across all vhosts — O(1) lookup.
- Regex patterns — in config declaration order; first match wins.
- Listener
default-vhostfallback.
// Exact match for the bare domain plus an alias
vhost example.com {
alias www.example.com
location "/" {
static { root "/var/www/example" }
}
}
// Regex -- matches any subdomain of example.com
vhost ".+\.example\.com" regex=#true {
location "/" {
static { root "/var/www/wildcard" }
}
}
| Children | Argument | Description |
|---|---|---|
| alias | name repeatable | Additional hostname or regex pattern that maps to this vhost. |
| alpn | strings (positional) | Override the ALPN protocols advertised when this vhost is selected via SNI on a TCP/TLS listener. Lets one host on a shared listener disable HTTP/2 (or any other protocol) without affecting its neighbours. Only literal-name vhosts participate; regex vhosts and QUIC listeners fall back to the listener default. |
| location | path prefix at least one | URL routing rule. Longest prefix match wins; declaration order does not matter. |
location
Maps a URL path prefix to a handler. The location with the longest matching prefix wins — declaration order does not matter. Each location contains exactly one handler node.
In addition to a handler, a location may carry:
- A
policyblock — access control rules. See Policy blocks. - A
basic-authblock — HTTP Basic auth realm. See basic-auth. request-headersand/orresponse-headersblocks — inject or modify headers. See Header injection.- One or more
rate-limitblocks — token-bucket throttle keyed on client IP, authenticated user, or a named header. See Rate limiting. - A
max-request-bodyoverride — tighter cap for one location than the listener-wide ceiling. See Body-size override. - An optional
matchblock — narrow which requests this location accepts. Failing matches fall through to the next candidate location. See Request matchers. - An optional
rewritedirective — substitute the request URI via a regex and re-route from the top. See URL rewrite.
Rate limiting and body-size limits
Each location may declare one or more
rate-limit blocks and an optional per-location
max-request-body override. Rate-limit blocks
evaluate in declaration order after authentication and
access control; the first denial short-circuits with
429 Too Many Requests and a
Retry-After header.
location "/login" {
// Tight body cap for a login form on an otherwise
// permissive listener.
max-request-body 4096
// Token-bucket per peer IP. 5 tokens per minute,
// bucket holds 5 (= rate when burst is omitted).
rate-limit {
rate 5 per="minute"
key client-ip
}
proxy { upstream "http://login/" }
}
location "/api/" {
// Stacked limits: a strict per-IP rate plus a softer
// per-user rate. Both must Allow.
rate-limit {
rate 100 per="second"
burst 200
key client-ip
}
rate-limit {
rate 1000 per="minute"
key user
}
proxy { upstream "http://api/" }
}
location "/keyed/" {
// Per-API-key tier: the request header value is the
// bucket key. Missing headers share the empty "" bucket.
rate-limit {
rate 500 per="hour"
burst 100
key header "X-API-Key"
}
proxy { upstream "http://api/" }
}
| Children | Type | Default | Description |
|---|---|---|---|
| rate N per="unit" | integer; per ∈ second | minute | hour |
— | required
Tokens added per per window. Must be
> 0. Internally normalised to tokens/second
before the bucket runs. |
| burst N | integer | rate | optional
Maximum bucket size. Defaults to rate
(non-bursty). Must be > 0 when given. |
name="label" or name "label" |
string | auto-generated | optional
Human-friendly identifier surfaced in access-log
records (rate_limit=<name>) when
this rule denies a request. Defaults to
loc-<i>-rl-<j> based on
the rule’s position in the config. |
| key form | client-ip | user |
header "Name" |
— | required
Bucket key. client-ip uses the peer
address; user uses the authenticated
username (empty for anonymous — all anonymous
callers share one bucket); header "Name"
uses the request header value (empty when absent). |
| max-request-body N | integer (bytes) | listener-wide cap | optional
Per-location override; useful when one route on
an otherwise permissive listener needs a tighter
ceiling. Applies only to requests with a
Content-Length header; streaming
bodies stay bounded by the listener-wide cap.
Returns 413 on excess. |
Request matchers
A location may carry an optional match { }
block. When present, the router only dispatches the
location for requests that satisfy every
predicate inside the block (AND across predicates).
Within a single predicate, the listed alternatives
combine with OR. When a matcher rejects a request the
router falls through to the next candidate location
(next-shortest matching prefix; declaration order on
ties). Two locations may share the same prefix and
be disambiguated by their matchers alone.
location "/api/" {
match {
method "POST" "PUT"
header "X-API-Version" "~^v[23]$"
query "format" "json"
}
proxy { upstream "http://api/" }
}
// Anonymous requests to /app go to the login flow; everyone
// else proceeds to the app.
location "/app" {
match { header-absent "Authorization" }
redirect "/login"
}
location "/app" {
proxy { upstream "http://app/" }
}
// Static image extensions get aggressive caching; everything
// else falls through to the default location.
location "/" {
match { path "[.](jpg|png|gif|webp)$" }
response-headers {
set "Cache-Control" "public, max-age=31536000"
}
static { root "/var/www" }
}
// Sibling fallback at the same prefix: every other request
// to /api/ lands here.
location "/api/" {
static { root "/var/www/api" }
}
| Predicate | Form | Semantics |
|---|---|---|
| method "NAME"+ | one or more method tokens | Matches when the request method equals one of the listed names. Names are validated as HTTP method tokens at config load. |
| header "Name" "value"+ | header name then one or more value patterns | Matches when the named request header is
present and its value satisfies one of the
listed alternatives. A value prefixed with
~ is compiled as a regex (Rust
regex syntax); any other value is
matched exactly. Missing headers never match. |
| query "name" "value"+ | query parameter name then one or more accepted values | Matches when the first occurrence of the named query parameter equals one of the listed values. Missing parameters never match. |
| path "regex"+ | one or more regex patterns | Matches when the request URI path satisfies at
least one of the patterns. Patterns are
evaluated unanchored — operators wanting
whole-path matches include
^…$ explicitly. Useful for
content-typed routing
("[.](jpg|png|gif)$") without
spinning up a separate location prefix. |
| header-absent "Name" | header name | Matches precisely when the named header is
missing from the request. Use this for
"anonymous client" routing
(header-absent "Authorization")
— the regular header
predicate always fails on a missing header, so
it cannot express absence on its own. |
| not { predicate+ } | block containing one or more predicates | The inner predicates are AND-evaluated and the
result is inverted. Example: not {
method "GET" } matches every method
except GET; not { method
"GET"; header "X" "y" } matches unless
both inner predicates hold. Empty
bodies are rejected at config load. |
URL rewrite
A location may carry an optional rewrite
directive that substitutes the request URI before any
handler runs. The from regex is matched
against the URI path (not the query string); when it
matches, the path is replaced by the
to template — which may reference
capture groups via $1, $2, or
$name — and the router re-evaluates
the request from the top, potentially landing on a
different location. When the regex does not match, the
rewrite is a no-op and the location's own handler runs
on the original URI.
The replacement template may include a query string after
a literal ?; whatever follows replaces the
request's query. When the template omits a query, the
rewritten URI has no query — the original is
not carried over. A rewrite chain that fails
to settle on a non-rewriting location within ten hops is
treated as a misconfiguration and yields 404; a
tracing::warn! records the offending URI.
location "/legacy/" {
// /legacy/users/42 -> /api/users/42, served by the next
// location below.
rewrite from="^/legacy/(.*)$" to="/api/$1"
static { root "/var/www" } // only used when
// regex doesn't match
}
location "/api/" {
proxy { upstream "http://api/" }
}
| Field | Type | Description |
|---|---|---|
| from | regex string (property or child) | required
Pattern compiled with the Rust regex
crate. Matched against the request URI path
only; capture groups may be referenced from
the to template. |
| to | template string (property or child) | required
Replacement template. Capture references use
$1, $2, or
$name. May include a query string
after a literal ?; absent
templates clear the request's query. |
Handler:
auth-request
The server-side counterpart to the
subrequest authentication
back-end. A location using this handler acts as an auth
endpoint: it applies its own policy block and
basic-auth realm to decide whether the caller
is authenticated, then returns 200 OK on
success with X-Auth-User and
X-Auth-Groups response headers populated from
the resolved identity.
The access policy machinery issues the 401 or 403 before the handler itself is reached, so the handler has no configuration.
location "/auth/validate" {
auth-request
basic-auth realm="My Service"
policy {
allow { authenticated }
deny
}
}
Complete two-server example
Auth server — validates HTTP Basic credentials via PAM and returns the identity:
// auth-server.kdl
server {
auth pam { service login }
}
listener { bind "tcp://127.0.0.1:9000" }
vhost "127.0.0.1" {
location "/validate" {
auth-request
basic-auth realm=Protected
policy { allow { authenticated }; deny }
}
}
App server — delegates auth to the auth server and restricts a location to group members:
// app-server.kdl
server {
auth subrequest {
url "http://127.0.0.1:9000/validate"
forward-header Authorization
user-header X-Auth-User
groups-header X-Auth-Groups
}
}
listener { bind "tcp://[::]:80" }
vhost app.example.com {
location "/admin/" {
basic-auth realm="Admin Area"
policy {
allow { group admins }
deny
}
static { root "/var/www/admin" }
}
}
basic-auth
Configures the WWW-Authenticate realm sent in
401 responses. It does not by itself restrict
access; pair it with a policy block containing a
deny code=401 rule (or an authenticated
predicate, which issues a 401 challenge automatically for
anonymous users).
location "/members/" {
basic-auth realm="Members Area"
policy {
allow { authenticated }
deny code=401
}
static { root "/var/www/members" }
}
| Form | Description |
|---|---|
basic-auth |
Use the default realm "Restricted". |
basic-auth realm="..." |
Property form (one-line). |
basic-auth { realm "..." } |
Block form. Both forms accept the same string. |
A server-level auth back-end must be configured
for credentials to be validated. Without one, all requests are
treated as anonymous and deny code=401 will always
challenge without ever accepting credentials.
Handlers: cgi /
fastcgi / scgi
These three handlers all speak CGI-adjacent protocols and
share several characteristics. They all set
REMOTE_ADDR to 0.0.0.0; use a
request-headers block with
{client_ip} to inject the real address
(see Header injection).
A new connection is opened per request (no connection
pooling).
cgi
Unix only
Executes a CGI script as a child process. One process is forked per request.
location "/cgi-bin/" {
request-headers { set X-Real-IP "{client_ip}" }
cgi {
root "/usr/lib/cgi-bin"
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| root | path | — | required Directory containing CGI scripts. The request path maps directly to a file under this directory. Path traversal is blocked. |
fastcgi
Forwards requests to a FastCGI application server such as PHP-FPM using the binary FastCGI record protocol.
location "/" {
request-headers { set X-Real-IP "{client_ip}" }
fastcgi {
socket "unix-stream:/run/php/fpm.sock"
root "/var/www/html"
index index.php
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| index | string | — | optional
Default script appended to directory requests
(paths ending in /),
e.g. "index.php". |
| root | path | — | required
Document root; combined with the request path to
build SCRIPT_FILENAME. |
| socket | string | — | required
FastCGI socket: unix:/path for a Unix
domain socket or tcp:host:port for
TCP. |
scgi
Forwards requests to an SCGI application server (Gunicorn, uWSGI, etc.) using the netstring-framed SCGI protocol.
location "/" {
request-headers { set X-Real-IP "{client_ip}" }
scgi {
socket "unix-stream:/run/myapp.sock"
root "/var/www/html"
index index.py
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| index | string | — | optional Default script appended to directory requests. |
| root | path | — | required
Document root for
SCRIPT_FILENAME. |
| socket | string | — | required
SCGI socket: unix:/path or
tcp:host:port. |
Handler: proxy
Reverse-proxies HTTP requests to an upstream server.
Connections to the upstream are pooled and reused across
requests unless proxy-protocol is set.
location "/api/" {
proxy {
upstream "http://127.0.0.1:3000"
strip-prefix #true
}
}
// With HAProxy PROXY protocol so backend sees the real client IP
location "/api/" {
proxy "http://127.0.0.1:3000" {
proxy-protocol v2
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| proxy-protocol | v1 | v2 |
— | optional
Send a HAProxy PROXY protocol header on each
upstream connection. Not supported for
unix: upstreams. When set, connection
pooling is disabled because the header is
bound to the connection. |
| strip-prefix | boolean | #false |
optional
Remove the matched location prefix before
forwarding. With location "/api/" and
strip-prefix #true,
/api/users is forwarded as
/users. |
| upstream | string | — | required
Base URL of the upstream server
(http://… or
https://…), or a Unix domain
socket path (unix:/path/to/socket).
HTTPS backends are verified against
Mozilla’s bundled root certificates.
Unix socket backends receive
Host: localhost. |
| scheme | auto | h3 |
auto |
optional
Wire protocol used to reach the upstream.
auto (the default) lets hyper-util
negotiate HTTP/2 or HTTP/1.1 via ALPN against
https:// upstreams.
h3 forces HTTP/3 over QUIC; the
upstream URL must be https:// since
QUIC mandates TLS. |
| pool-idle-timeout | integer (seconds) | 90 |
optional
How long a cached upstream connection sits unused
before it is closed. Applied to hyper-util's pool
for h1/h2 and to aloha's per-handler reaper for
h3. 0 disables idle reaping. |
| pool-max-idle | integer | hyper-util default | optional
Maximum idle upstream connections kept in the
pool per host. Lower this when the pool is
holding more sockets than the backend can
comfortably keep alive; 0 disables
pooling entirely. Applies to h1/h2 only —
the h3 pool holds at most one connection per
handler today. |
| connect-timeout | integer (seconds) | OS default | optional Cap the TCP/TLS handshake time to an upstream. A slow or unreachable backend fails over to the next pool member (or returns 502) instead of hanging the request for the kernel’s default connect timeout. |
| tls | block | — | optional
Upstream TLS settings for
https:// upstreams. The only
child today is
skip-verify;
using this block against a plaintext upstream
is a parse-time error. |
| tls › skip-verify | flag | — | optional
Disable certificate verification against
upstream backends. Intended for internal
services with self-signed or otherwise
untrusted certificates. Write as
tls { skip-verify }. |
upstream may also be written as the handler’s
positional argument: proxy "http://…".
strip-prefix may be written as a property:
proxy strip-prefix=#true.
The proxy unconditionally appends to
X-Forwarded-For, sets X-Real-IP,
and overrides Host with the upstream authority.
Hop-by-hop headers are stripped from both the forwarded
request and the backend response.
HAProxy PROXY protocol over QUIC is not supported. The wire format is an IETF draft and only HAProxy 2.9+ ships an implementation today — other major proxies (nginx, Caddy, Envoy, Traefik) don’t. For setups that need source-IP preservation on HTTP/3 traffic, terminate QUIC at HAProxy and forward decrypted HTTP/1.1 or HTTP/2 to aloha with PROXY protocol v2 over TCP.
Multi-upstream load balancing
upstream may appear multiple times to declare a
pool of backends. The proxy picks one per request via the
configured lb-policy and can eject failing
upstreams (passively on request errors, actively via a
background probe) and retry against the next.
location "/api/" {
proxy {
upstream "http://a:8080"
upstream "http://b:8080" weight=2
upstream "http://c:8080"
lb-policy "least-conn"
active-health {
path "/healthz"
interval 10
expect-status 200
}
passive-health { eject-after 3; eject-for 30 }
retry { max 2; on-status 502 503 504 }
}
}
// Header-hashed affinity: the request header to hash sits on the
// same node as the policy.
location "/api/" {
proxy {
upstream "http://a:8080"
upstream "http://b:8080"
lb-policy "header-hash" header="X-Session-Id"
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| upstream … weight=N | integer | 1 |
optional
Relative pick weight for this upstream. Heavier
upstreams take a proportionally larger share of
round-robin / random picks and a higher
load-tolerance share under least-conn.
0 excludes the upstream from the
picker (useful for parking). |
| lb-policy … header=name | round-robin | least-conn |
random | ip-hash |
header-hash |
round-robin |
optional
Picker policy. ip-hash sends all
requests from one peer IP to the same upstream
(sticky sessions without cookies).
header-hash requires a
header="X-Name" property on
the same node; it hashes that header to select an
upstream, falling back to round-robin when the
header is absent on a given request. |
| active-health { … } | block | — | optional
Active health-check tuning. Children:
path (default /),
interval seconds (default
10; 0 disables),
timeout seconds (default
2),
expect-status (default
200),
unhealthy-after (default
2), healthy-after
(default 1). Probes use a separate
hyper-util client so a stall can never wedge real
traffic. |
| passive-health { … } | block | — | optional
Children: eject-after consecutive
request errors before ejection (default: never),
eject-for seconds the upstream stays
out (default 30). An ejected
upstream is skipped by the picker until the
deadline passes. |
| retry { … } | block | — | optional
Children: max additional attempts
(default 0); on-status
followed by one or more status codes
(e.g. on-status 502 503 504) that
trigger a retry. on-status is
required whenever max>0
so it is explicit which response codes should
retry. When retry is enabled the request body is
buffered up-front so it can be replayed across
attempts. |
Retry is enabled by setting retry max=N
with N>0. Because aloha must be able to replay
the request body across attempts, enabling retry causes the
body to be collected into memory before the first attempt.
For requests with no body (GET / HEAD / empty POST) this is
effectively free; for large request bodies it is a real cost
and operators should leave retry max=0 (the
default).
Handler: redirect
Returns an HTTP redirect response.
// Simple permanent redirect
location "/old/" {
redirect {
to "/new/"
code 301
}
}
// Redirect all HTTP traffic to HTTPS using template variables.
// ACME challenges (/.well-known/acme-challenge/) are intercepted
// before routing and are never affected by a redirect handler.
location "/" {
redirect {
to "https://{host}{path_and_query}"
code 301
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| code | integer | 301 |
optional
HTTP status code: 301 (permanent) or
302 (temporary). |
| to | URL or path | — | required
Destination written to the Location
header. Supports
template variables
such as {host} and
{path_and_query}. |
to may also be written as the handler’s
positional argument: redirect "/new/".
code may be written as a property:
redirect "/new/" code=301.
Header injection
The request-headers and
response-headers blocks inside a
location add, replace, or remove HTTP headers
before the request reaches the backend and before the response
reaches the client. This works for all handler types.
location "/api/" {
request-headers {
set X-Client-IP "{client_ip}"
set X-Auth-User "{username}"
set X-Auth-Groups "{groups}"
set X-Forwarded-Proto "{scheme}"
remove Authorization
}
response-headers {
set X-Frame-Options DENY
set X-Content-Type-Options nosniff
add Vary Accept-Encoding
remove Server
}
proxy { upstream "http://backend:8080" }
}
Operations (applied in declaration order):
| Operation | Arguments | Description |
|---|---|---|
| add | name, value | Append a value without removing existing values.
Useful for multi-valued headers such as
Vary. |
| remove | name | Delete the header. A no-op if absent. |
| set | name, value | Set the header to this value, replacing any existing value. Creates the header if absent. Skipped silently when the rendered value is empty. |
Value strings may contain
{variable} placeholders expanded at request time.
Unrecognised placeholders pass through unchanged.
A fallback is written {variable|default}: if the
variable resolves to an empty string, the default text is used
instead.
set X-Auth-User "{username|anonymous}"
set X-Auth-Groups "{groups|none}"
| Variable | Value |
|---|---|
| {client_ip} | Client IPv4 or IPv6 address |
| {username} | Authenticated username; empty string if anonymous |
| {groups} | Authenticated user’s groups, comma-joined; empty if anonymous |
| {method} | HTTP request method (GET, POST, …) |
| {path} | Request URI path (without query string) |
| {query} | Query string without the leading ?;
empty string if absent |
| {path_and_query} | Request path plus query string,
e.g. /api/v1?foo=bar |
| {host} | Value of the Host request header |
| {scheme} | "https" for TLS listeners,
"http" for plain |
| {client_cert_subject} | Verified client‑certificate subject DN (mTLS). Empty on plaintext / non‑mTLS connections. |
| {client_cert_sans} | Comma‑joined SANs (DNS / URI / email) from the verified client certificate. Empty when no cert was presented. |
{username}, {groups}) cause the
configured auth back-end to run even when there is no
policy block. If no credentials are present the
variables render as empty strings.
FastCGI, SCGI, and CGI handlers set
REMOTE_ADDR = 0.0.0.0; use a
request-headers rule with
{client_ip} to pass the real address as
HTTP_X_REAL_IP in the CGI environment.
The proxy handler unconditionally appends to
X-Forwarded-For and sets X-Real-IP
after request-headers rules run, so a
remove rule for those headers will be
overwritten.
Handler: static
Serves files from a local directory. Supports
Range requests, ETag conditional
GET, and directory index files. Files are
streamed in 64 KB chunks without buffering the entire
file in memory.
location "/assets/" {
static {
root "/var/www/assets"
strip-prefix #true
index-file index.html
index-file index.htm
}
}
| Children | Type | Default | Description |
|---|---|---|---|
| index-file | string | "index.html",
"index.htm" |
repeatable
Filenames tried in order for directory requests.
Returns 403 if none exist. Supplying any
index-file children replaces the
defaults entirely. |
| root | path | — | required
Filesystem directory to serve. Path traversal
outside root is blocked. |
| strip-prefix | boolean | #false |
optional
Remove the matched location prefix before resolving
the file path. With
location "/assets/" and
strip-prefix #true, a request for
/assets/app.js maps to
{root}/app.js. |
| try-files | string… | none | optional
Ordered list of candidate path templates to test
before serving 404. The literal token
{path} is substituted with the
request URI path (after any
strip-prefix); all other characters
are copied verbatim. The first candidate that
exists under root as a regular file
is served. Common pattern: client-side router
fallback to /index.html —
try-files "{path}" "{path}.html"
"/index.html". When set, this directive
bypasses the default directory-index resolution;
candidates that resolve to a directory are
skipped. |
| directory-listing | boolean | #false |
optional
When the resolved path is a directory and no
index-file matches, render an HTML
listing of the directory contents (one row per
entry, with size and mtime). Hidden files (names
beginning with .) are excluded;
directories sort before files, then alphabetical.
A GET to a directory without a trailing slash is
redirected with 301 so relative links resolve. |
| userdir | string | none | optional
Per‑user mode (Unix only). When set, URLs of
the form /~<user>/<rest>
serve HOME/<userdir>/<rest>
from the named user’s account. Mutually
exclusive with root. See the example
below. |
| userdir-allowlist | string… | none | optional
Restrict ~user resolution to the
listed usernames. When set, every other request
returns 404 even if the named user exists. |
| userdir-min-uid | integer | 1000 |
optional
Minimum UID that may be served via ~user.
Keeps system accounts (root,
daemon, …) off the public web
even if they have a home directory and a
public_html. |
// Public file share with a browsable listing.
location "/files/" {
static {
root "/srv/share"
directory-listing #true
}
}
// Per-user homepages: /~alice/foo.html serves ~alice/public_html/foo.html.
location "/" {
static {
userdir "public_html"
userdir-allowlist "alice" "bob"
}
}
root may also be written as the handler’s
positional argument: static "/var/www/assets".
strip-prefix may be written as a property:
static "/var/www/assets" strip-prefix=#true.
Handler: status
Serves a live server status page. The HTML page auto-refreshes every 10 seconds. No children are required.
location "/status" {
status
}
Protect with a policy block to restrict
visibility. The handler responds to any HTTP method;
Accept: application/json returns a JSON
object with all the same data.
HTML output shows:
- Uptime and total / active request counts
- Request rate: last 5 s, 1/5/15-minute rolling averages
- Status code distribution (2xx / 3xx / 4xx / 5xx)
- Latency histogram (6 buckets from <1 ms to ≥1 s)
- Resident memory in MiB (Linux only)
- Server version and process ID
- Listener table: address, protocol, ACME domain list
- Virtual host table: names, aliases, locations with handler types
// JSON response (Accept: application/json)
{
"version": "0.2.0",
"pid": 12345,
"uptime_secs": 3661,
"requests_total": 98765,
"requests_active": 3,
"status": { "2xx": 95000, "3xx": 1200, "4xx": 500, "5xx": 65 },
"rates": { "current_per_sec": 12.5, "avg_1min": 10.2 },
"auth": "pam:login"
}
Overview
A policy block is a sequence of statements
evaluated top to bottom. The first statement whose
predicate matches fires its action and evaluation
stops. If no statement matches, the request is
denied with 403.
Statements are of the form
action [predicate [values…]].
A statement with no predicate is a catch-all that
always matches, so placing a bare allow or
deny last acts as the default outcome.
policy {
allow address "10.0.0.0/8" // allow internal
allow authenticated // allow any logged-in user
deny code=403 // catch-all deny
}
Where policy blocks appear
| Context | Syntax | Available predicates |
|---|---|---|
server |
policy "name" { … } |
Named definition — no predicates evaluated here. See Named policies. |
location |
policy { … } |
All predicates |
listener
(stream mode) |
policy { … } |
address, country only.
Identity predicates are rejected at startup.
Denied connections are closed silently;
redirect is treated as deny. |
Statements
Every statement begins with an action keyword. All actions are terminal: when a statement fires, no further statements in the block are evaluated.
| Statement | Arguments / properties | Description |
|---|---|---|
| allow | [predicate] | Permit the request and pass it to the handler. |
| apply | "policy-name" |
Inline the rules of a
named policy at
this point. The named policy’s statements are
spliced into the list in place of the
apply; first-match semantics apply
across the combined sequence. If none of the spliced
rules fire, evaluation continues with the next
statement after the apply. |
| deny | [predicate] code=N
(default 403) |
Reject the request with the given HTTP status code.
Use deny code=401 to issue an HTTP
Basic auth challenge when the location also has a
basic-auth node. Auth predicates
(authenticated, user,
group) issue a 401 challenge
automatically for anonymous users, making an explicit
deny code=401 often unnecessary. |
| redirect | [predicate] to="…"
code=N (default 302) |
Return an HTTP redirect to the given URL or path. Not available in stream mode. |
Predicates
A predicate is an optional condition that guards a statement. Without a predicate the statement is a catch-all that always fires. There are two syntactic forms:
-
Inline — the predicate is written as
arguments directly on the statement node. Multiple values
within one predicate are OR-combined:
allow country US CA GBfires when the client is in any of those three countries. -
Block — the predicate is a child
block
{ }containing multiple predicate nodes separated by;or newlines. All nodes inside the block must match (AND, evaluated left-to-right with short-circuit):allow { address "10.0.0.0/8"; authenticated }fires only when the client is on the internal network and is authenticated.
// Inline OR: any of these countries
allow country US CA GB
// Block AND: internal network AND authenticated
allow { address "10.0.0.0/8"; authenticated }
// not: negates a predicate (suppresses 401 challenge for auth predicates)
deny code=403 not country US CA
| Predicate | Argument(s) | Supported in | Description |
|---|---|---|---|
| address | CIDR or IP (one or more) | location, stream | Client IP address falls within any of the listed
CIDR ranges or equals any of the listed addresses.
IPv4-mapped IPv6 addresses (e.g.
::ffff:192.0.2.1) are normalised to
plain IPv4 before comparison. |
| authenticated | — | location only | The request carries valid credentials. For anonymous
requests, the predicate fails and automatically
issues a 401 challenge (with the realm from
basic-auth if present). Use
not authenticated to match anonymous
users without challenging. |
| country | ISO 3166-1 alpha-2 code (one or more) | location, stream | Client country matches any of the listed codes.
Requires server { geoip { db … }
}. Private and reserved IP ranges are not in
the database and never match; combine with
address to allow them explicitly.
See geoip. |
| group | name (one or more) | location only | The authenticated user is a member of any of the listed groups. Group membership is resolved by the active auth back-end at authentication time. Issues a 401 challenge for anonymous users. |
| not | predicate [values…] | both | Negates the given predicate. When a negated auth
predicate (not authenticated,
not user, not group)
encounters an anonymous user, the 401 challenge is
suppressed and the predicate matches (an anonymous
user is trivially “not authenticated”).
|
| user | name (one or more) | location only | The authenticated username matches any of the listed names. Issues a 401 challenge for anonymous users. |
authenticated,
user, group) invoke the auth
back-end only when they are actually reached during
evaluation. A statement that appears earlier in the block
and fires first short-circuits the rest, so the auth
back-end is never called. This makes rule order significant
for performance as well as correctness: put cheap
address checks before expensive auth checks.
Named policies
Any number of reusable policy blocks can be defined in the
server block by giving each a name as its first
argument. Named policies are not evaluated at definition time;
they are inlined wherever apply "name" appears
in a concrete policy block.
// Definition (server block)
server {
policy geo-filter {
deny code=403 not country US CA GB
}
policy require-auth {
deny code=401 not authenticated
}
policy admin-only {
allow group admin
deny code=403
}
}
// Usage -- the three apply statements expand to six rules:
// deny code=403 not country US CA GB
// deny code=401 not authenticated
// allow group admin
// deny code=403
location "/admin/" {
basic-auth realm=Admin
policy {
apply geo-filter
apply require-auth
apply admin-only
}
static { root "/var/www/admin" }
}
Because apply splices rules in place, the
effective rule list is exactly what you would get by writing
all the rules out by hand. Named policies compose without
any special scoping: if none of a named policy’s rules
fire, evaluation falls through to the next statement after
the apply.
More examples
// Block CN/RU; require auth for everyone else
policy {
deny code=403 country CN RU
deny code=401 not authenticated
allow
}
// Allow internal IPs unconditionally; require auth for external
policy {
allow address "10.0.0.0/8"
allow authenticated // issues 401 for anonymous
}
// AND: only authenticated users FROM the internal network
policy {
allow { address "10.0.0.0/8"; authenticated }
deny code=403
}
// Redirect unauthenticated users to a login page instead of 401
policy {
allow authenticated
redirect to="/login/"
}
// Stream proxy: restrict by IP (listener level, no auth predicates)
listener "tcp://[::]:5432" {
proxy "db.internal:5432"
policy {
allow address "10.0.0.0/8"
deny code=403
}
}
Response compression
aloha automatically compresses responses when the client sends
an Accept-Encoding header that includes
zstd, br (brotli), or
gzip. Preference is
zstd › br › gzip
when more than one is offered, which gives the best ratio at
comparable CPU cost for text payloads. There is no
per-location configuration; compression is always active for
eligible responses.
Compression is applied to text-based content types:
text/*application/jsonapplication/javascript,application/ecmascriptapplication/xml,application/xhtml+xmlapplication/wasm,application/manifest+jsonimage/svg+xml
Responses smaller than 1 KB, responses that already carry
a Content-Encoding header, and binary formats
(images, video, audio, archives) are passed through
unmodified.
When compression is applied, aloha removes
Content-Length and adds:
Content-Encoding: zstd (or br, or gzip)
Vary: Accept-Encoding
Full example
// aloha.kdl
server {
state-dir "/var/lib/aloha"
user aloha
// Validate credentials via PAM (uses /etc/pam.d/login)
auth pam { service login }
// Reusable policy blocks
policy internal-only {
deny code=403 not address "10.0.0.0/8"
}
policy require-auth {
deny code=401 not authenticated
}
policy require-admin {
allow group admin
deny code=403
}
// Custom error pages
error-page 403 path="/var/www/errors/403.html"
error-page 401 html="<h1>Authentication Required</h1>"
}
// Plain HTTP -- required for ACME HTTP-01 challenges
listener {
bind "tcp://[::]:80"
default-vhost example.com
}
// HTTPS with a Let's Encrypt certificate
listener {
bind "tcp://[::]:443"
default-vhost example.com
tls-acme {
domain example.com www.example.com
email [email protected]
}
}
// Encrypted tunnel to an internal database (stream mode)
listener {
bind "tcp://[::]:5433"
tls-file cert="/etc/aloha/db-cert.pem" key="/etc/aloha/db-key.pem"
proxy "db.internal:5432" {
proxy-protocol v2
}
policy {
allow address "10.0.0.0/8"
deny code=403
}
}
vhost example.com {
alias www.example.com
// Status page -- internal network only
location "/status" {
policy { apply internal-only; allow }
status
}
// Admin area -- internal AND authenticated admin group
location "/admin/" {
basic-auth realm=Admin
policy {
apply internal-only // 403 if external
apply require-auth // 401 if unauthenticated
apply require-admin // 403 if not in admin group
}
request-headers {
set X-Auth-User "{username}"
set X-Auth-Groups "{groups}"
}
proxy "http://127.0.0.1:3000"
}
// API -- inject client info, enforce security headers
location "/api/" {
request-headers {
set X-Client-IP "{client_ip}"
set X-Forwarded-Proto "{scheme}"
remove Authorization
}
response-headers {
set X-Frame-Options DENY
set X-Content-Type-Options nosniff
remove Server
}
proxy {
upstream "http://127.0.0.1:4000"
strip-prefix #true
}
}
// PHP application via FastCGI
location "/app/" {
request-headers { set X-Real-IP "{client_ip}" }
fastcgi {
socket "unix-stream:/run/php/fpm.sock"
root "/var/www/html"
index index.php
}
}
// Old URL redirect
location "/old/" {
redirect { to "/new/"; code 301 }
}
// Static files
location "/" {
static {
root "/var/www/example.com"
index-file index.html
}
}
}
// Wildcard subdomain -- regex match
vhost ".+\.example\.com" regex=#true {
location "/" {
static { root "/var/www/wildcard" }
}
}