Notation legend

aloha uses KDL v2 as its configuration language. The grammar below uses a pseudo-EBNF adapted to KDL’s structure. KDL distinguishes three kinds of values on a node: positional arguments (bare values), named properties (key=value), and child blocks ({ … }). Each is shown explicitly in the rules below.

(* Notation used throughout this document *)

<name>            (* non-terminal — defined elsewhere in this grammar *)
"literal"         (* literal KDL node name or string value *)
<string>          (* any KDL string *)
<integer>         (* KDL integer value *)
<boolean>         (* #true or #false *)
[ x ]            (* x is optional *)
x | y             (* either x or y *)
x*               (* zero or more repetitions of x *)
x+               (* one or more repetitions of x *)
{}          (* KDL child block *)
prop=<value>      (* named KDL property (not a positional argument) *)
(* comment *)     (* explanatory note, not part of the syntax *)
Note See the Configuration Reference for prose explanations, defaults, and annotated examples for every node.

Top-level

A config file contains at most one server block, one or more listener blocks, and zero or more vhost blocks. Any other top-level node is a hard error.

config   = server? listener+ vhost*

server   = "server"                                    { server-child* }
listener = "listener" [<string>]                     { listener-child* }
vhost    = "vhost" <string> [ regex=<boolean> ]  { vhost-child* }
Note vhost blocks are required when at least one HTTP listener is present. A config consisting only of stream listeners (with a proxy child) needs no vhosts.

server

Global server settings. All children are optional.

server-child =
    "state-dir"                    <string>
  | "user"                         <string>
  | "group"                        <string>
  | "inherit-supplementary-groups" <boolean>
  | tls-options-block
  | auth-backend
  | geoip-block
  | health-block
  | policy-def
  | error-page-def

TLS defaults

tls-options-block = "tls" { tls-option* }
tls-option        =   "min-version" ("1.2" | "1.3")
                    | "cipher"      <string>

Authentication backends

Exactly one auth node may appear inside server. The second positional argument names the backend type.

auth-backend =
    "auth" "pam"        [ { pam-child* } ]
  | "auth" "ldap"       { ldap-child* }
  | "auth" "subrequest" ( <string> | { subrequest-child* } )
  | "auth" "jwt"        [ { jwt-child* } ]

pam-child =
    "service"   <string>   (* default: "login" *)

ldap-child =
    "url"          <string>   (* required; ldap:// ldaps:// ldapi:// *)
  | "bind-dn"      <string>   (* required; must contain {user} *)
  | "base-dn"      <string>   (* required *)
  | "group-filter" <string>   (* default: "(memberUid={user})" *)
  | "group-attr"   <string>   (* default: "cn" *)
  | "starttls"     <boolean>  (* default: #false *)
  | "timeout"      <integer>  (* seconds; default: 5 *)

subrequest-child =
    "url"            <string>   (* required; http:// scheme *)
  | "forward-header" <string>+ (* repeatable; header names to pass through *)
  | "user-header"    <string>   (* response header carrying the username *)
  | "groups-header"  <string>   (* response header carrying group list *)
  | "timeout"        <integer>  (* seconds; default: 5 *)

jwt-child =
    "cookie-name" <string>   (* default: "aloha_session" *)
  | "validity"    <integer>  (* seconds; default: 300 *)
  | "wrap"        <string>   (* optional inner backend type: "pam" | "ldap" | "subrequest" *)
                               (* same child syntax as the corresponding auth-backend *)
Note auth jwt runs as a standalone token validator when no wrap child is present (it validates incoming cookies and bearer tokens but never issues new ones). With wrap, it wraps an inner credential backend: successful credential logins receive a session cookie, and subsequent requests are validated by JWT alone.
Note The auth subrequest short form auth subrequest "http://…" passes the URL as a positional argument and is equivalent to the block form auth subrequest { url "http://…" }.

GeoIP, health, named policies, error pages

geoip-block    = "geoip"      (<string> | { "db" <string> })
health-block   = "health"     (<boolean> | { "enabled" <boolean> })?
policy-def     = "policy"     <string> { policy-statement* }
error-page-def =
    "error-page" <integer> path=<string>  (* path to an HTML file *)
  | "error-page" <integer> html=<string>  (* inline HTML string *)

listener

listener = "listener" [<string>] [{ listener-child* }]
   (* optional positional string is the bind address *)

listener-child =
    "bind"                 <string>                         (* if not given as positional *)
  | "accept-proxy-protocol" ("v1" | "v2")                   (* HAProxy PROXY protocol on incoming connections *)
  | tls-node                                                  (* at most one; valid in both HTTP and stream mode *)
  | "proxy"                <string> [{ stream-proxy-opt* }]   (* stream mode: upstream address *)
  | policy-block                                              (* stream mode only *)
  | "default-vhost"        (<string> | #null)               (* HTTP mode only; #null disables fallback *)
  | timeouts-block                                            (* HTTP mode only *)
  | "max-connections"      <integer>                        (* HTTP + stream mode; defer connections at limit *)
  | "max-request-body"     <integer>                        (* bytes; HTTP mode only; returns 413 on excess *)
   (* "proxy" is mutually exclusive with "default-vhost" and "timeouts" *)

TLS

Four mutually exclusive sibling nodes select the TLS mode. The inline forms (tls-file, tls-self-signed, tls-acme) carry their source inside the listener; tls instead references a named top-level certificate so multiple listeners can share one acceptor (and, for ACME, one renewal loop). All four forms accept optional min-version and cipher children (inherited from server › tls defaults).

tls-node =
    "tls" ( <string> | cert=<string> )
        [{ tls-option* }]           (* reference a top-level certificate *)
  | "tls-file"
        [ cert=<string> ] [ key=<string> ]
        [{ tls-file-child* tls-option* }]
  | "tls-self-signed"
        [{ tls-option* }]
  | "tls-acme"
        [ domain=<string> ] [ email=<string> ]
        [{ tls-acme-child+ tls-option* }]

tls-file-child = "cert" <string> | "key" <string> | mtls-block

mtls-block =
    "mtls"
        { "ca" <string>+           (* at least one trust anchor PEM *)
          [ "mode" <string> ]      (* "required" (default) | "optional" *)
          [ "revocation" <string>+ ]  (* CRL PEM, repeatable *)
          [ "refresh" <integer> ]  (* CRL reload seconds; 0 disables *)
        }

tls-acme-child =
    "domain"         <string>+  (* at least one required overall; variadic *)
  | "name"           <string>   (* storage name; default: first domain *)
  | "email"          <string>
  | "staging"        <boolean>  (* default: #false *)
  | "server"         <string>   (* ACME directory URL override *)
  | "retry-interval" <integer>  (* seconds; default: 3600 *)
Required tls-file requires both cert and key. tls-acme requires at least one domain and a state-dir on the server block. Two listeners may not carry inline tls-acme blocks that resolve to the same on-disk slot (same explicit name or same first domain); define a top-level certificate and reference it from both.

Top-level certificate

A named certificate definition. Any number of listeners can reference it via tls cert="name"; for ACME sources they share a single renewal loop and on-disk cert directory. The body holds exactly one source child whose contents match the corresponding inline form.

certificate = "certificate" <string>
                { cert-source }   (* positional string is the certificate's name *)

cert-source =
    "acme"        { tls-acme-child+ }
  | "files"       ( cert=<string> key=<string> | { tls-file-child+ } )
  | "self-signed"

Timeouts

timeouts-block = "timeouts" timeout-prop* [{ timeout-child* }]
   (* properties and child nodes are equivalent; both forms accepted *)

timeout-prop  =   request-header=<integer>  (* default 30; 0 = unlimited *)
              |   handler=<integer>
              |   keepalive=<integer>

timeout-child =
    "request-header" <integer>  (* seconds; default 30; 0 = unlimited *)
  | "handler"        <integer>  (* seconds; returns 408 on expiry *)
  | "keepalive"      <integer>  (* seconds; 0 disables keep-alive *)

Stream mode (proxy child)

A proxy child activates stream mode: raw bytes are forwarded to the upstream over TCP or a Unix domain socket; HTTP routing does not apply. The positional argument is the upstream address (required). TLS termination on the incoming connection uses the same tls-* nodes as HTTP listeners. A tls child inside proxy re-encrypts the upstream connection.

stream-proxy-opt =
    "proxy-protocol" ("v1" | "v2")      (* prepend PROXY header to upstream *)
  | "tls"            [{ "skip-verify" }]  (* re-TLS to upstream; skip-verify disables cert check *)
Note The policy block on a stream listener may only use address and country predicates. user, group, and authenticated are forbidden because stream mode has no HTTP authentication layer.

vhost

vhost-child =
    "alias" <string> [ regex=<boolean> ]
  | location

Vhost names and aliases are literal hostnames by default; set regex=#true to treat the value as an anchored regex matched against the request Host. Matching order: exact literal lookup (O(1)), then regex patterns in config declaration order, then the listener’s default-vhost fallback.

location

location       = "location" <string> { location-child* }
location-child =
    handler                        (* exactly one required *)
  | policy-block
  | basic-auth-block
  | request-headers-block
  | response-headers-block
  | "max-request-body" <integer>           (* bytes; per-location override of listener cap *)
  | rate-limit-block*                            (* repeatable; stacked AND *)
  | match-block                                 (* optional; predicates AND-evaluated *)
  | rewrite-directive                           (* optional; URL rewrite + re-route *)

rate-limit-block = "rate-limit" {
    "rate" <integer> [per=("second" | "minute" | "hour")]
    ["burst" <integer>]                                (* default: rate *)
    "key" ("client-ip" | "user" | "header" <string>)
}

rewrite-directive = "rewrite" (from=<string> to=<string>)
                  | "rewrite" { "from" <string>; "to" <string> }

match-block = "match" { match-predicate+ }      (* at least one predicate; ANDed *)
match-predicate =
    "method" <string>+                          (* OR within the list *)
  | "header" <string> <string>+                 (* name then value(s); ~prefix = regex *)
  | "header-absent" <string>                   (* match when header missing *)
  | "query"  <string> <string>+                 (* parameter name then accepted value(s) *)
  | "path"   <string>+                          (* regex(es) against URI path *)
  | "not"    { match-predicate+ }            (* inner AND, result negated *)

Locations within a vhost are matched by longest-prefix. The path argument is a literal prefix, not a regex.

Handlers

Exactly one handler node must appear inside each location block.

(* Each handler accepts its primary field as a positional arg, a property,
   or a child node.  Modifiers (strip-prefix, code) are property/child.  *)
handler =
    "static"       [<string>] [strip-prefix=<boolean>] [{ static-child* }]
  | "proxy"        [<string>] [strip-prefix=<boolean>] [{ proxy-child* }]
  | "redirect"     [<string>] [code=<integer>]  [{ redirect-child* }]
  | "fastcgi"      [socket=<string> root=<string> index=<string>] [{ fastcgi-child* }]
  | "scgi"         [socket=<string> root=<string> index=<string>] [{ scgi-child* }]
  | "cgi"          (<string> | root=<string> | { "root" <string> })
  | "status"                                                                    (* no arguments *)
  | "auth-request"                                                              (* no arguments *)

static-child   =   "root"         <string>   (* required *)
               |   "strip-prefix" <boolean>  (* default: #false *)
               |   "index-file"   <string>+  (* repeatable; default: "index.html" "index.htm" *)
               |   "try-files"    <string>+  (* repeatable; ordered candidates; `{path}` substitutes the request path *)

proxy-child    =   "upstream"       <string> [weight=<integer>]     (* repeatable; "http://host:port" or "unix-stream:/path"; weight default 1 *)
               |   "proxy-protocol" ("v1" | "v2")          (* send HAProxy PROXY header to upstream *)
               |   "strip-prefix"   <boolean>              (* default: #false *)
               |   "scheme"         ("auto" | "h3")             (* h3 needs https upstream; default auto *)
               |   "pool-idle-timeout" <integer>          (* seconds; 0 disables; default 90 *)
               |   "pool-max-idle"  <integer>              (* h1/h2 only *)
               |   "connect-timeout" <integer>            (* seconds *)
               |   "tls" { "skip-verify" }                  (* https upstreams only *)
               |   "lb-policy" ("round-robin" | "least-conn" | "random" | "ip-hash" | "header-hash") [header=<string>]
                                                                  (* header= required iff header-hash *)
               |   "active-health" {
                       ["path" <string>]                       (* default "/" *)
                       ["interval" <integer>]                  (* seconds; 0 disables *)
                       ["timeout" <integer>]                   (* seconds; default 2 *)
                       ["expect-status" <integer>]             (* default 200 *)
                       ["unhealthy-after" <integer>]           (* default 2 *)
                       ["healthy-after" <integer>]             (* default 1 *)
                   }
               |   "passive-health" {
                       ["eject-after" <integer>]               (* default: never *)
                       ["eject-for" <integer>]                 (* seconds; default 30 *)
                   }
               |   "retry" {
                       ["max" <integer>]                        (* additional attempts; default 0 *)
                       ["on-status" <integer>+]                  (* required iff max>0 *)
                   }

redirect-child =   "to"           <string>   (* required *)
               |   "code"         <integer>  (* default: 301 *)

fastcgi-child  =   "socket"       <string>   (* required; "unix-stream:/path" or "host:port" *)
               |   "root"         <string>   (* required *)
               |   "index"        <string>

scgi-child     =   "socket"       <string>   (* required *)
               |   "root"         <string>   (* required *)
               |   "index"        <string>
Note auth-request exposes the current request’s resolved identity as response headers (X-Auth-User, X-Auth-Groups). It is used as the upstream endpoint referenced by an auth subrequest backend on another vhost. Access enforcement happens before the handler runs, so the handler only fires when access is allowed.

Access control

(* Inline policy in a location or stream listener,
   or named policy defined in a server block:        *)
policy-block     = "policy" [<string>] { policy-statement* }
                   (* no name = inline; with name = named policy in server block *)

policy-statement =
    "allow"    [ predicate ]
  | "deny"     [ code=<integer> ] [ predicate ]      (* default code: 403 *)
  | "redirect" to=<string> [ code=<integer> ] [ predicate ]   (* default code: 302 *)
  | "apply"    <string>                                    (* inline rules from a named policy *)

(* A predicate is either inline (args on the statement node) or
   a child block (AND across multiple pred-node entries).        *)
(* Inline: *)   predicate  =  pred-type <string>*
                            |  "not" pred-type <string>*
(* Block (AND): *)        |  { pred-node+ }

pred-type = "address" | "country" | "user" | "group" | "authenticated"

(* Inside a child block, each predicate is a node: *)
pred-node =
    "address"       <cidr-or-ip>+         (* OR across values *)
  | "country"       <iso2>+               (* OR across values; ISO 3166-1 alpha-2 *)
  | "user"          <string>+             (* OR across values *)
  | "group"         <string>+             (* OR across values *)
  | "authenticated"
  | "not"           pred-type <string>*  (* negate *)

<cidr-or-ip> = <string>  (* e.g. "10.0.0.0/8" or "::1" *)
<iso2>       = <string>  (* e.g. "US" "DE" *)
Semantics Statements are first-match; a policy block with no matching rule returns 403. Multiple values on one predicate are OR-combined (country "US" "CA" matches either). Multiple predicates in a child block are AND-combined, evaluated left-to-right with short-circuit. not inverts a predicate; when it suppresses an auth challenge (e.g. not authenticated for an anonymous user) no 401 is issued. Auth predicates (authenticated, user, group) automatically return 401 when the user is anonymous, without requiring an explicit deny code=401.
Named policies apply "name" splices a named policy’s rules at that point — first-match semantics continue across the flattened list. Circular references are detected at startup.

Header operations

basic-auth-block =
    "basic-auth"                                   (* realm default: "Restricted" *)
  | "basic-auth" realm=<string>                   (* property form *)
  | "basic-auth" { [ "realm" <string> ] }          (* block form *)

request-headers-block  = "request-headers"  { header-op* }
response-headers-block = "response-headers" { header-op* }

header-op =
    "set"    <string> <string>  (* header-name value *)
  | "add"    <string> <string>  (* header-name value *)
  | "remove" <string>           (* header-name *)