aloha
Configuration Reference ← Home

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"
    }
}
i Fields marked required must be present. optional fields have defaults or can be omitted.

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" or tls-acme
  • Properties are named key=value pairs 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 nodeTypeDefaultDescription
state-dirpath-- 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.
userstring-- optional Unix username to switch to after all sockets are bound. Only effective when started as root; silently ignored otherwise.
groupstringuser's primary group optional Unix group to switch to. Defaults to the primary GID of user from /etc/passwd.
inherit-supplementary-groupsboolfalse 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.
authblock-- optional Authentication back-end. See Authentication.
geoipblock-- optional GeoIP database for country-based access control. See GeoIP.
tlsblock-- optional Global TLS defaults inherited by every TLS listener. See TLS protocol options.
access-policyblock-- 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 nodeTypeDefaultDescription
bindstring-- 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.
fdinteger-- 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-vhoststring | #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 proxyTypeDefaultDescription
(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.
tlsblock-- 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 listenerTypeDefaultDescription
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.
accessblock-- 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 nodeTypeDefaultDescription
request-headerinteger (s)unlimited optional Maximum seconds to wait for a complete request line and headers. Protects against Slowloris-style attacks.
handlerinteger (s)unlimited optional Maximum seconds a request handler may run before it is cancelled and 408 Request Timeout is returned.
keepaliveinteger (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"
}
NodeRequired valuesDescription
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.
! The tls-self-signed certificate is regenerated on every start and is not trusted by browsers. Use tls-file or tls-acme for production.
i HTTP/1.1 and HTTP/2 are both supported on TLS listeners; protocol selection is automatic via ALPN.

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 nodeTypeDefaultDescription
domainstring (repeatable)-- required Domain name for the Subject Alternative Name. At least one required.
emailstring-- optional Contact address registered with the ACME provider.
namestringfirst domain optional Storage subdirectory name under state-dir.
stagingbooleanfalse optional Use the Let's Encrypt staging server (untrusted, no rate limits -- useful for testing).
serverURLLet's Encrypt optional Override the ACME directory URL.
retry-intervalinteger (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 nodeTypeDefaultDescription
min-version "1.2" | "1.3" "1.2" optional Minimum TLS protocol version to accept.
cipherstring (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 nodeTypeDefaultDescription
dbpath-- 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
}
! A startup error is returned if 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 nodeTypeDefaultDescription
servicestring"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 nodeTypeDefaultDescription
urlstring-- required Server URL. Supported schemes: ldap:// (plain), ldaps:// (TLS), ldapi:// (Unix socket).
bind-dnstring-- required DN template for the simple bind. Must contain {user}, replaced with the RFC 4514-escaped username.
base-dnstring-- required Base DN for the group membership search.
group-filterstring (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-attrstringcn optional Entry attribute used as the group name.
starttlsbooleanfalse optional Upgrade a plain ldap:// connection to TLS using STARTTLS.
timeoutinteger (s)5 optional Seconds before an LDAP operation is abandoned.
! Empty passwords are rejected before any bind attempt is made. Many LDAP servers accept an empty password as an anonymous bind, which would otherwise grant access to any username.

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 nodeTypeDefaultDescription
urlstring-- required HTTP endpoint to call. Must use http:// scheme.
forward-headerstring-- repeatable Request header forwarded verbatim to the auth endpoint. Typically "Authorization" or "Cookie".
user-headerstring-- optional Response header whose value becomes the authenticated username. Absent or empty → empty username.
groups-headerstring-- optional Response header holding a comma-separated list of group names.
timeoutinteger (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 nodeTypeDefaultDescription
wrapstring-- 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-namestring"aloha_session" optional Name of the session cookie set in the response and read from subsequent requests.
validityinteger (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:

  1. Exact literal match — all literal names, O(1).
  2. Regex patterns — in config declaration order; first match wins.
  3. Listener default-vhost fallback.
// 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" } }
}
ChildArgumentDescription
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 access block — firewall-style rules controlling which clients and users may reach this location. See Access control.
  • An auth block — configures the HTTP Basic auth challenge realm. See auth realm.
  • A request-headers and/or response-headers block — 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" }
}
FormDescription
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):

OperationArgumentsDescription
setname, value Set the header to this value, replacing any existing value. Creates the header if absent.
addname, value Append a value without removing existing values. Useful for multi-valued headers such as Vary.
removename 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.

VariableValue
{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.

i Variables that reference the authenticated identity ({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} vs REMOTE_ADDR: 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:
    request-headers {
        set "X-Real-IP" "{client_ip}"
    }
    The backend sees this as HTTP_X_REAL_IP in the CGI environment.
  • Interaction with the proxy's forwarding headers: The proxy handler unconditionally appends to X-Forwarded-For and sets X-Real-IP after request-headers rules run. A remove rule for those headers will be overwritten by the proxy's own logic.
  • Empty rendered values: set and add are 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 nodeTypeDefaultDescription
rootpath-- required Filesystem directory to serve. Path traversal outside root is blocked.
strip-prefixbooleanfalse 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-filestring (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 nodeTypeDefaultDescription
upstreamstring-- 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-prefixbooleanfalse 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 nodeTypeDefaultDescription
socketstring-- required FastCGI socket: unix:/path for a Unix domain socket or tcp:host:port for TCP.
rootpath-- required Document root; combined with the request path to build SCRIPT_FILENAME.
indexstring-- 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 nodeTypeDefaultDescription
socketstring-- required SCGI socket: unix:/path or tcp:host:port.
rootpath-- required Document root for SCRIPT_FILENAME.
indexstring-- 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 nodeTypeDefaultDescription
rootpath-- 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 nodeTypeDefaultDescription
toURL or path-- required Destination written to the Location header. Supports template variables such as {host} and {path_and_query}.
codeinteger301 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 location block — all conditions are available.
  • At the listener level in stream mode (when a proxy child is present) — only ip and country conditions are supported. Identity conditions are rejected at startup. Denied connections are closed silently; redirect is treated as deny.

Statement types

StatementPropertiesDescription
allow-- Terminal allow. Immediately permits the request. Propagates through apply frames — allow inside a named block always terminates.
denycode=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.

ConditionArgument(s)Supported inDescription
ipCIDR or IPlocation, stream listener Client address or range. Repeatable — multiple entries OR. IPv4-mapped IPv6 addresses are normalised automatically.
countrycode …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.
userusernamelocation only Specific authenticated username. Repeatable.
groupgroup namelocation 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"
}
SyntaxDescription
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/json
  • application/javascript, application/ecmascript
  • application/xml, application/xhtml+xml
  • application/wasm, application/manifest+json
  • image/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"
        }
    }
}