Configuration Reference
Complete reference for aloha.kdl
Overview
aloha is configured via a single KDL file.
By default it looks for aloha.kdl in the current working directory.
Pass --config /path/to/file.kdl (or -c) to use a
different path.
A minimal configuration needs one listener and one vhost:
listener "[::]:8080"
vhost "localhost" {
location "/" {
static "./public"
}
}
KDL quick primer
KDL nodes have the form node-name arg prop="value" { child ... }.
- Arguments are positional values after the node name:
vhost "example.com"ortls-acme - Properties are named
key=valuepairs after the node name - Children live inside
{ }, one per line:bind "[::]:8080" - Line comments start with
// - The null literal is written
#null; booleans are#true/#false
server
Global server settings. The entire node is optional.
server {
state-dir "/var/lib/aloha"
user "aloha"
tls {
min-version "1.2"
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| 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. |
| user | string | -- | optional Unix username to switch to after all sockets are bound. Only effective when started as root; silently ignored otherwise. |
| group | string | user's primary group | optional
Unix group to switch to. Defaults to the primary GID of
user from /etc/passwd. |
| inherit-supplementary-groups | bool | false | optional
Skip the setgroups() call during privilege drop,
preserving supplementary groups inherited at startup.
Intended for container deployments that pass extra groups via
podman run --group-add keep-groups. |
| auth | block | -- | optional Authentication back-end. See Authentication. |
| geoip | block | -- | optional GeoIP database for country-based access control. See GeoIP. |
| tls | block | -- | optional Global TLS defaults inherited by every TLS listener. See TLS protocol options. |
| access-policy | block | -- | optional
Named, reusable access policy block. Repeatable; each takes a
name as its first argument. Referenced from access
blocks via apply "name". See
Access control. |
| error-page | -- | -- | optional
Custom HTML body for an error status code. Repeatable. Takes
the status code as first argument, then either a file path or
html="...". See
Custom error pages. |
Privilege drop
When aloha is started as root (needed to bind ports 80 and 443), set
user to drop privileges immediately after all sockets are
bound, before accepting any connections.
The syscall sequence is setgroups -> setgid
-> setuid.
In container deployments using
podman run --group-add keep-groups, set
inherit-supplementary-groups #true to preserve the supplementary groups
passed in by podman instead of replacing them with
setgroups().
server { user "www-data" } // group from /etc/passwd
server { user "aloha"; group "aloha" } // explicit group
server { user "aloha"; inherit-supplementary-groups #true } // container: preserve inherited groups
listener
Opens a socket and begins accepting HTTP connections. At least one is required. Use separate listeners for plain HTTP and HTTPS.
// Plain HTTP
listener {
bind "[::]:80"
}
// HTTPS with an explicit default vhost
listener {
bind "[::]: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 "[::]:80"
default-vhost #null
}
// systemd socket activation (fd 3 = first socket passed by systemd)
listener {
fd 3
}
// Unix domain socket (useful behind nginx/caddy on the same host)
listener {
bind "unix:/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.
| Child node | Type | Default | Description |
|---|---|---|---|
| bind | string | -- | Address to listen on. Use "host:port" for TCP
(e.g. "[::]:8080") or "unix:/path"
for a Unix domain socket.
Mutually exclusive with fd; exactly one must be present. |
| fd | integer | -- | Adopt an already-open file descriptor as the listening socket
(systemd socket activation). Mutually exclusive with
bind. |
| 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. |
| default-vhost | string | #null |
first vhost | Vhost used when no Host header matches.
Omit to fall back to the first vhost defined in the config.
Set to #null to return 404 for unrecognised hosts. |
proxy child in listener
Adding a proxy child to a listener activates
stream mode: raw TCP bytes are forwarded to the upstream; HTTP routing
does not apply.
Combine with a tls-* node on the listener to terminate
TLS from clients before forwarding — aloha decrypts the
incoming connection, then forwards the plaintext stream to the
upstream. Add tls inside the proxy block
to re-encrypt the upstream connection (re-TLS).
// Plain TCP tunnel to a PostgreSQL backend
listener "[::]:5432" {
proxy "db.internal:5432" {
proxy-protocol "v2"
}
}
// Unix domain socket upstream
listener "[::]:5432" {
proxy "unix:/run/postgresql/.s.PGSQL.5432"
}
// TLS termination: clients connect over TLS, backend gets plaintext
listener "[::]:5433" {
tls-self-signed
proxy "db.internal:5432" {
proxy-protocol "v2"
}
}
// Full re-TLS: terminate client TLS, re-encrypt to upstream
listener "[::]:5433" {
tls-file cert="cert.pem" key="key.pem"
proxy "db.internal:5432" {
tls // verify with system CA roots
}
}
Child node of proxy | Type | Default | Description |
|---|---|---|---|
| (positional) | string | -- | required
Upstream address: "host:port" for TCP or
"unix:/path" for a Unix domain socket. |
| proxy-protocol | "v1" | "v2" | -- | optional
Prepend a HAProxy PROXY protocol header so the upstream sees
the real client IP. "v2" is binary and preferred. |
| tls | block | -- | optional
Connect to the upstream using TLS (re-TLS). Verifies the
upstream certificate against system CA roots by default.
Add skip-verify child to disable verification
(e.g. for self-signed internal certs):
tls { skip-verify }. |
These options belong directly on the listener, not
inside the proxy block:
Child node of listener | Type | Default | Description |
|---|---|---|---|
| accept-proxy-protocol | "v1" | "v2" | -- | optional
Read a HAProxy PROXY protocol header from each incoming
connection before optional TLS and before forwarding.
The recovered client address is used for access rules and
is available for outbound proxy-protocol if
also set. "v2" is preferred. |
| access | block | -- | optional
IP/country firewall rules (see
Access control).
Only ip and country conditions
are supported; identity conditions are rejected at startup.
Denied connections are closed silently; redirect rules are
treated as deny. |
The access block is placed at the listener
level (sibling to proxy). It may only use ip
and country conditions; identity conditions 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 at all.
// Restrict a database port to an internal CIDR block
listener "[::]:5432" {
proxy "db.internal:5432"
access {
allow { ip "10.0.0.0/8" }
deny code=403
}
}
timeouts child node
Optional connection and request timeout settings. All values are whole
seconds. Omit any field (or the entire timeouts node) for
no limit.
listener {
bind "[::]:8080"
timeouts {
request-header 30
handler 60
keepalive 75
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| request-header | integer (s) | unlimited | optional Maximum seconds to wait for a complete request line and headers. Protects against Slowloris-style attacks. |
| 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. |
tls-* — certificate mode
Add one of three sibling nodes to a listener to enable TLS:
tls-file, tls-self-signed, or
tls-acme. Each accepts its required values either as KDL
properties (one-line form) or as child nodes (block form).
// Self-signed -- ephemeral, generated at startup (development only)
tls-self-signed
// PEM files from disk -- one-line property form
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 "admin@example.com"
}
| 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 only). |
tls-acme |
domain+ (and state-dir on
server) |
Obtain and renew certs automatically via ACME HTTP-01. |
tls-self-signed certificate is regenerated on every start and is
not trusted by browsers. Use tls-file or tls-acme
for production.
ACME / Let's Encrypt
With tls-acme, aloha obtains and renews a certificate
automatically 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 "[::]:443"
tls-acme {
domain "example.com" "www.example.com"
email "admin@example.com"
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| domain | string (repeatable) | -- | required Domain name for the Subject Alternative Name. At least one required. |
| string | -- | optional Contact address registered with the ACME provider. | |
| name | string | first domain | optional
Storage subdirectory name under state-dir. |
| staging | boolean | false |
optional Use the Let's Encrypt staging server (untrusted, no rate limits -- useful for testing). |
| server | URL | Let's Encrypt | optional Override the ACME directory URL. |
| retry-interval | integer (s) | 3600 |
optional Seconds between retry attempts on certificate failure. |
TLS protocol options
These child nodes can appear in any tls block — either
inside a listener or inside the global server
block. Per-listener values override the global defaults.
server {
tls {
min-version "1.2"
cipher "TLS13_AES_256_GCM_SHA384"
cipher "TLS13_CHACHA20_POLY1305_SHA256"
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| min-version | "1.2" | "1.3" |
"1.2" |
optional Minimum TLS protocol version to accept. |
| cipher | string (repeatable) | provider defaults | optional Restrict the allowed cipher suites by name. |
GeoIP
aloha can restrict access by the client's country of origin using a
MaxMind MMDB database. Configure the path to the database once in the
server block; then use country conditions
in any access block.
server {
geoip {
db "/etc/aloha/GeoLite2-Country.mmdb"
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| db | path | -- | required Filesystem path to the MaxMind MMDB file. |
The database is loaded into memory at startup. Compatible databases:
GeoLite2-Country (smallest, country codes only),
GeoLite2-City (larger, also has country codes), or
any MMDB file that contains a country.iso_code field.
The file must be readable by the aloha process at startup (after
privilege drop, if server user= is set).
Once configured, use the country condition in access blocks:
// Allow only US, Canada, and UK
location "/admin/" {
access {
allow { country "US" "CA" "GB" }
deny code=403
}
static { root "/var/www/admin" }
}
// Block specific countries; allow everyone else
location "/api/" {
access {
deny { country "CN" "RU" "KP" }
allow
}
proxy { upstream "http://127.0.0.1:3000" }
}
Country codes are ISO 3166-1 alpha-2 (two uppercase letters).
Multiple codes on one country node are OR-ed.
Private and reserved IP ranges (127.0.0.0/8, 10.0.0.0/8, etc.) are
not in the database and will not satisfy a
country condition. Combine with ip if you
need to allow private ranges:
access {
allow { ip "10.0.0.0/8" }
allow { country "US" }
deny code=403
}
country conditions
appear anywhere but no server { geoip { db ... } } is
configured.
Authentication
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; the chosen mechanism 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 an
access block to enforce who is allowed
(see Access control).
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 access blocks.
| Child node | Type | Default | Description |
|---|---|---|---|
| service | string | "login" |
optional
PAM service name. Must correspond to a file in
/etc/pam.d/. |
PAM authentication blocks the calling thread while libpam runs; aloha runs it on a dedicated blocking thread so the async runtime is not stalled.
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 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"
}
| Child node | Type | Default | Description |
|---|---|---|---|
| url | string | -- | required
Server URL. Supported schemes: ldap:// (plain),
ldaps:// (TLS), ldapi:// (Unix socket). |
| bind-dn | string | -- | required
DN template for the simple bind. Must contain {user},
replaced with the RFC 4514-escaped username. |
| base-dn | string | -- | required Base DN for the group membership search. |
| 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. |
| group-attr | string | cn |
optional Entry attribute used as the group name. |
| 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. |
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" // optional: username from response
groups-header "X-Auth-Groups" // optional: comma-separated groups
timeout 5
}
}
When the auth endpoint returns 200, aloha reads the identity from
response headers named by user-header and
groups-header. The username and groups are then available
for user, group, and
authenticated conditions in access blocks.
If neither header is configured, a 200 response still counts as
authenticated (suitable for pure allow/deny decisions).
| Child node | Type | Default | Description |
|---|---|---|---|
| url | string | -- | required
HTTP endpoint to call. Must use http:// scheme. |
| forward-header | string | -- | repeatable
Request header forwarded verbatim to the auth endpoint.
Typically "Authorization" or
"Cookie". |
| user-header | string | -- | optional Response header whose value becomes the authenticated username. Absent or empty → empty username. |
| 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. |
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 full two-server
example.
JWT
Issues and validates ES256 (ECDSA P-256) JWT session cookies so that
browsers only need to send credentials once per session. After a
successful login the server 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 itself (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 so that
any client can verify tokens without holding the private key.
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, no issuance:
server {
state-dir "/var/lib/aloha"
auth "jwt" {
cookie-name "aloha_session"
}
}
In both modes, an incoming 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 token payload carries sub (username) and
groups (array of group names), which are used for
user, group, and authenticated
conditions in access blocks.
Cookies are issued with
HttpOnly; SameSite=Strict; Path=/; Max-Age=<validity>
flags. The Secure flag is added automatically on TLS
listeners.
| Child node | Type | Default | Description |
|---|---|---|---|
| wrap | string | -- | optional
Credential back-end used for first-time logins. Accepts the
same argument as a top-level auth node:
"pam", "ldap", or
"subrequest" with their usual child nodes.
Omit for standalone validation. |
| cookie-name | string | "aloha_session" |
optional Name of the session cookie set in the response and read from subsequent requests. |
| validity | integer (s) | 300 |
optional Lifetime of issued tokens in seconds. |
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.
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 — prefix the name with
~. The remainder 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 — all literal names, O(1).
- Regex patterns — in config declaration order; first match wins.
- Listener
default-vhostfallback.
// Exact match for the bare domain and 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" } }
}
| Child | Argument | Description |
|---|---|---|
| alias | name repeatable | Additional hostname or regex pattern that maps to this vhost. |
| location | path prefix at least one | URL routing rule. Longest prefix match wins; declaration order does not matter. See location. |
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 can carry:
- An
accessblock — firewall-style rules controlling which clients and users may reach this location. See Access control. - An
authblock — configures the HTTP Basic auth challenge realm. See auth realm. - A
request-headersand/orresponse-headersblock — inject or modify headers. See Header injection.
basic-auth — Basic auth realm
A basic-auth node inside a location
configures the WWW-Authenticate realm sent in
401 responses. It does not by itself restrict access;
pair it with an access block containing a
deny code=401 rule.
location "/members/" {
basic-auth realm="Members Area"
access {
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 (PAM or LDAP) must be
configured for credentials to be validated. Without one, all requests
are treated as anonymous and deny code=401 will always
challenge.
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: proxy,
fastcgi, scgi, cgi,
static, and redirect.
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 |
|---|---|---|
| set | name, value | Set the header to this value, replacing any existing value. Creates the header if absent. |
| 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 the header is absent. |
Value strings can contain {variable} placeholders replaced
at request time. Unrecognised placeholders pass through unchanged.
| 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; equals the path
when there is no query |
| {host} | Value of the Host request header |
| {scheme} | "https" for TLS listeners,
"http" for plain listeners |
A fallback can be specified with {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}"
This is most useful for {username} and
{groups}, which are empty for anonymous requests, but
works for any variable.
{username}, {groups}) cause the configured
auth back-end to run even when there is no access block.
If no credentials are present the variables render as empty strings.
Notes:
-
{client_ip}vsREMOTE_ADDR: FastCGI, SCGI, and CGI handlers setREMOTE_ADDR = 0.0.0.0. Use arequest-headersrule with{client_ip}to pass the real address:
The backend sees this asrequest-headers { set "X-Real-IP" "{client_ip}" }HTTP_X_REAL_IPin the CGI environment. -
Interaction with the proxy's forwarding headers:
The
proxyhandler unconditionally appends toX-Forwarded-Forand setsX-Real-IPafterrequest-headersrules run. Aremoverule for those headers will be overwritten by the proxy's own logic. -
Empty rendered values:
setandaddare silently skipped when the rendered value is empty. In particular,set "X-Auth-User" "{username}"injects the header for authenticated requests and leaves it absent for anonymous ones. - Invalid header values: If a rendered value contains characters that are not valid in an HTTP header (e.g. control characters), the operation is silently skipped and a warning is logged.
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"
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| 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. |
| index-file | string (repeatable) | "index.html", "index.htm" |
optional
Filenames tried in order for directory requests.
Returns 403 if none exist. Supplying any
index-file children replaces the defaults. |
Handler: proxy
Reverse-proxies requests to an upstream HTTP server. Connections to the
upstream are pooled and reused across requests unless
proxy-protocol is set (see below).
location "/api/" {
proxy {
upstream "http://127.0.0.1:3000"
strip-prefix #true
}
}
// Send HAProxy PROXY protocol header so the backend sees the real client IP
location "/api/" {
proxy "http://127.0.0.1:3000" {
proxy-protocol "v2"
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| upstream | string | -- | required
Base URL of the upstream server (http://…
or https://…), or a Unix domain socket
path (unix:/path/to/socket). Both
http and https schemes are
supported for TCP backends; HTTPS is verified against
Mozilla’s bundled root certificates.
unix: paths are Unix-only; the backend
receives Host: localhost. |
| 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. |
| proxy-protocol | "v1" | "v2" |
-- | optional
Send a HAProxy PROXY protocol header on each upstream
connection so the backend can see the real client IP and
port. "v2" (binary) is preferred.
Not supported for unix: upstreams.
When set, connection pooling is disabled — each
request opens a fresh TCP connection because the header
is bound to the connection. |
The proxy sets X-Forwarded-For (appending the client IP to
any existing chain), 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.
Handler: fastcgi
Forwards requests to a FastCGI application server such as PHP-FPM using the binary FastCGI record protocol.
location "/" {
fastcgi {
socket "unix:/run/php/fpm.sock"
root "/var/www/html"
index "index.php"
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| socket | string | -- | required
FastCGI socket: unix:/path for a Unix domain
socket or tcp:host:port for TCP. |
| root | path | -- | required
Document root; combined with the request path to build
SCRIPT_FILENAME. |
| index | string | -- | optional
Default script appended to directory requests
(paths ending in /),
e.g. "index.php". |
A new connection is opened per request (no pooling). The full CGI/1.1
environment is sent including all HTTP_* headers.
REMOTE_ADDR is set to 0.0.0.0; use a
request-headers rule with
{client_ip} to pass the real address.
Handler: scgi
Forwards requests to an SCGI application server (Gunicorn, uWSGI, etc.) using the netstring-framed SCGI protocol.
location "/" {
scgi {
socket "unix:/run/myapp.sock"
root "/var/www/html"
index "index.py"
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| socket | string | -- | required
SCGI socket: unix:/path or
tcp:host:port. |
| root | path | -- | required
Document root for SCRIPT_FILENAME. |
| index | string | -- | optional Default script appended to directory requests. |
REMOTE_ADDR is 0.0.0.0; use
request-headers with {client_ip} to inject
the real address.
Handler: cgi
Unix only
Executes a CGI script as a child process. One process is forked per request.
location "/cgi-bin/" {
cgi {
root "/usr/lib/cgi-bin"
}
}
| Child node | 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. |
REMOTE_ADDR is 0.0.0.0; use
request-headers with {client_ip}.
Handler: redirect
Returns an HTTP redirect response.
location "/old/" {
redirect {
to "/new/"
code 301
}
}
| Child node | Type | Default | Description |
|---|---|---|---|
| to | URL or path | -- | required
Destination written to the Location header.
Supports template
variables such as {host} and
{path_and_query}. |
| code | integer | 301 |
optional
HTTP status code: 301 (permanent) or
302 (temporary). |
Template variables make it possible to build the destination from the incoming request. The canonical example is redirecting all plain HTTP traffic to HTTPS. ACME challenge paths and health endpoints are intercepted before routing and are never affected by a location handler.
// Redirect all HTTP traffic to HTTPS.
// ACME challenges (/.well-known/acme-challenge/) still work on
// the plain listener -- they are intercepted before routing runs.
location "/" {
redirect {
to "https://{host}{path_and_query}"
code 301
}
}
Handler: status
Serves a live server status page. The page auto-refreshes every 10 seconds. No child nodes are required.
location "/status" {
status
}
Protect with an access block to restrict visibility.
HTML output (default) — a self-contained page showing:
- 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 output — send
Accept: application/json. The response is a JSON object
with all the same data:
{
"version": "0.2.0",
"pid": 12345,
"uptime_secs": 3661,
"uptime_human": "1h 1m 1s",
"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,
"avg_5min": 8.7, "avg_15min": 7.1 },
"latency_ms": { "lt_1": 40000, "lt_10": 50000, "lt_50": 5000,
"lt_200": 500, "lt_1000": 200, "ge_1000": 65 },
"memory_kb": 32768,
"listeners": [
{ "address": "[::]:443", "protocol": "HTTPS-ACME",
"acme_domains": ["example.com"] }
],
"vhosts": [
{ "name": "example.com", "aliases": [],
"locations": [{ "path": "/", "handler": "static" }] }
],
"auth": "pam:login"
}
The auth field is null when no auth back-end
is configured, or a string of the form "pam:service",
"ldap:url", or "subrequest:url". Rates are
computed from a 15-minute ring buffer updated every 5 seconds and
converge to accurate values as the buffer fills.
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 access block and basic-auth realm to
decide whether the caller is authenticated, then returns
200 OK on success. aloha’s access policy machinery
issues the 401 or 403 before the handler is reached, so the handler
itself has no configuration.
location "/auth/validate" {
auth-request
basic-auth realm="My Service"
access {
allow { authenticated }
deny
}
}
On a successful authentication the response includes
X-Auth-User and X-Auth-Groups headers
populated from the resolved principal. A
subrequest back-end on the calling
server reads these headers to build the identity for its own access
checks.
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 "127.0.0.1:9000" }
vhost "127.0.0.1" {
location "/validate" {
auth-request
basic-auth realm="Protected"
access { allow { authenticated } deny }
}
}
App server — delegates authentication 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 "[::]:80" }
vhost "app.example.com" {
location "/admin/" {
static { root "/var/www/admin" }
basic-auth realm="Admin Area"
access {
allow { group "admins" }
deny
}
}
}
Access control
An access block contains a sequence of statements evaluated
top to bottom. Each statement either terminates the decision immediately
or passes control to the next statement. If the block falls through
without a terminal decision, the request is denied (403 for
IP/country-only blocks, 401 for blocks containing identity conditions).
Access blocks are supported in two places:
- Inside a
locationblock — all conditions are available. - At the
listenerlevel in stream mode (when aproxychild is present) — onlyipandcountryconditions are supported. Identity conditions are rejected at startup. Denied connections are closed silently;redirectis treated as deny.
Statement types
| Statement | Properties | Description |
|---|---|---|
| allow | -- | Terminal allow. Immediately permits the
request. Propagates through apply frames —
allow inside a named block always terminates. |
| deny | code=N (default 403) |
Terminal deny. Immediately rejects with the
given status. Use code=401 with a
basic-auth block to issue a Basic auth challenge.
The implicit fall-through default is 401 when the block has
identity conditions, 403 otherwise. |
| pass | -- | Non-terminal exit. Exits the current block successfully; the calling block continues. Useful for filter policies that should not issue a final decision themselves. |
| redirect | to="...", code=N (default 302) |
Terminal redirect. Not available in
stream-proxy blocks. |
| apply | "policy-name" |
Evaluate a named policy.
Terminal outcomes propagate up immediately; pass
or fall-through continues in the calling block. |
A statement with no conditions is a catch-all that always matches.
Conditions
Conditions are specified as child nodes inside the statement block. They are AND-ed across types and OR-ed within the same type.
| Condition | Argument(s) | Supported in | Description |
|---|---|---|---|
| ip | CIDR or IP | location, stream listener | Client address or range. Repeatable — multiple entries OR. IPv4-mapped IPv6 addresses are normalised automatically. |
| country | code … | location, stream listener | ISO 3166-1 alpha-2 code(s). Multiple arguments OR. Requires
server { geoip { db ... } }. Private IPs never
match. See GeoIP. |
| authenticated | -- | location only | Any authenticated (non-anonymous) user. |
| user | username | location only | Specific authenticated username. Repeatable. |
| group | group name | location only | Authenticated user is a member of this group. Repeatable. |
Authentication is lazy — the auth back-end is only called when an identity condition is actually evaluated. If an IP or country check fails first, no authentication happens.
Named access policies
Define reusable policy blocks in the server block and
reference them with apply:
server {
access-policy "geo-filter" {
pass { country "US" "CA" "GB" }
deny code=403
}
access-policy "require-auth" {
pass { authenticated }
deny // → 401 (identity block default)
}
access-policy "admin-only" {
allow { group "admin" }
deny code=403
}
}
Use pass to make a policy a filter — it exits the block
successfully when conditions match, letting the caller continue.
allow always terminates immediately.
location "/admin/" {
access {
apply "geo-filter" // deny 403 → stop; pass → continue
apply "require-auth" // deny 401 → stop; pass → continue
apply "admin-only" // allow → terminal; deny → stop
}
static { root "/var/www/admin" }
}
More examples:
// Country AND auth (auth only called for matching countries)
access {
pass { country "US" "CA" }
deny code=403
allow { authenticated }
deny // → 401
}
// Allow internal IPs without auth, require auth for external
access {
allow { ip "10.0.0.0/8" }
allow { authenticated }
deny code=401
}
// Redirect unauthenticated users to a login page
access {
allow { authenticated }
redirect to="/login/" code=302
}
// Restrict a stream proxy to a specific country (requires GeoIP)
stream-proxy {
upstream "db.internal:5432"
access {
allow { country "US" "CA" }
deny code=403
}
}
Custom error pages
Override the default <h1>N</h1> 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 every error response. The file is read from disk each time, 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.
Response compression
aloha automatically compresses responses when the client sends an
Accept-Encoding header that includes
br (brotli) or gzip.
Brotli is preferred when both are accepted.
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: gzip (or br)
Vary: Accept-Encoding
There is no per-location configuration; compression is always active for eligible responses.
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 access policies
access-policy "internal-only" {
pass { ip "10.0.0.0/8" }
deny code=403
}
access-policy "require-auth" {
pass { authenticated }
deny // → 401 (identity block default)
}
access-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 "[::]:80" {
default-vhost "example.com"
}
// HTTPS with a Let's Encrypt certificate
listener "[::]:443" {
default-vhost "example.com"
tls-acme {
domain "example.com" "www.example.com"
email "admin@example.com"
}
}
// Encrypted tunnel to an internal database
stream-proxy "[::]:5433" {
tls-file cert="/etc/aloha/db-cert.pem" key="/etc/aloha/db-key.pem"
upstream "db.internal:5432"
proxy-protocol "v2"
access {
allow { ip "10.0.0.0/8" }
deny code=403
}
}
vhost "example.com" {
alias "www.example.com"
// Status page -- internal network only
location "/status" {
access {
apply "internal-only"
allow
}
status
}
// Admin area -- internal AND authenticated admin
location "/admin/" {
basic-auth realm="Admin"
access {
apply "internal-only" // 403 if external
apply "require-auth" // 401 if unauthenticated
apply "require-admin" // 403 if not 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:/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"
}
}
}