Getting started

Quick start

aloha is configured via a single KDL file, read from aloha.kdl in the current working directory by default. Pass -c /path/to/file.kdl to use a different path.

A production web server serving static files over HTTPS needs three things: a plain HTTP listener on port 80 to answer ACME challenges, a TLS listener on port 443 that obtains its certificate automatically, and a vhost that maps your domain to a directory on disk.

// /etc/aloha.kdl

server {
    // Directory for ACME account keys, certificates, and JWT keys.
    // Must exist and be writable by the aloha user.
    state-dir "/var/lib/aloha"

    // Drop privileges after binding ports 80 and 443.
    user aloha
}

// Port 80: required so Let's Encrypt can reach the HTTP-01 challenge
// endpoint at /.well-known/acme-challenge/.  aloha intercepts those
// requests automatically -- no special location rule needed.
listener {
    bind "tcp://[::]:80"
}

// Port 443: aloha fetches and renews the certificate automatically.
// The domain must resolve to this server before the first startup.
listener {
    bind     "tcp://[::]:443"
    tls-acme {
        domain example.com www.example.com
        email  [email protected]   // for expiry notices from Let's Encrypt
    }
}

vhost example.com {
    alias www.example.com
    location "/" {
        static { root "/var/www/example.com" }
    }
}

On first startup aloha binds both ports (as root if needed), drops to the aloha user, then completes the ACME order before accepting HTTPS connections. Renewal happens automatically in the background before the certificate expires.

Note Port 80 must be reachable from the internet while aloha is running. If a firewall blocks it, the ACME challenge will fail and no certificate will be issued. Both the domain names must point at this server in DNS before the first run.

To redirect plain HTTP traffic to HTTPS rather than serving it directly, replace the port 80 vhost content with a redirect:

vhost example.com {
    alias www.example.com

    // Redirect HTTP to HTTPS.  ACME challenges on port 80 are
    // handled before routing and are never caught by this redirect.
    location "/" {
        redirect { to "https://{host}{path_and_query}"; code 301 }
    }
}

// Serve static files only on the TLS listener (443).
// You can use the same vhost name on multiple listeners.
Note In the reference tables below, fields marked required must be present; optional fields have defaults or may be omitted; repeatable fields may appear more than once.

KDL v2 primer

aloha uses KDL version 2. KDL nodes have the form:

node-name arg1 arg2 key=value {
    child-node "value"
}

Arguments are positional values placed after the node name. Properties are named key=value pairs. Children live inside { }, one per line (or separated by ;).

Strings

KDL v2 has three string forms:

FormSyntaxWhen to use
Quoted "hello world" Strings containing spaces, special characters, or anything that could be mistaken for a keyword. Supports standard escape sequences: \\ \" \n \t \r \u{HHHH}.
Bare identifier example.com Simple values with no spaces — hostnames, service names, short paths. Legal characters include letters, digits, - _ . : and several others, but not spaces, =, { }, ", or \. When in doubt, use a quoted string.
Raw string #"C:\Users\foo"# No escape processing. Add more # marks on both sides if the content contains "#: ##"contains "# here"##. Use for regex patterns, Windows paths, or any string that would need many escape sequences.

Keyword literals

KDL v2 uses a # prefix for built-in literals, distinguishing them from bare-identifier strings:

LiteralMeaningExample
#trueBoolean true strip-prefix #true
#falseBoolean false starttls #false
#nullNull / no value default-vhost #null
Caution Writing true (without #) is a bare-identifier string, not a boolean. Always use #true / #false.

Numbers

Integers and floats are written without quotes. Underscores may be used as digit separators: 1_000_000. Hexadecimal (0xff), octal (0o77), and binary (0b1010) are supported for integers.

Comments

  • // — line comment to end of line.
  • /* … */ — block comment, may span lines.
  • /-slashdash; comments out the next argument, property, or entire node (including its children).

Multiple nodes per line

A semicolon terminates a node, allowing siblings on one line:

server { user aloha; group aloha }
allow { authenticated } deny code=401
Configuration

server

Global settings that apply to the entire process. The server block is optional; omit it to accept all defaults.

server {
    state-dir "/var/lib/aloha"
    user      aloha
    tls {
        min-version "1.2"
    }
}
ChildrenType DefaultDescription
auth block optional Authentication back-end.
error-page optional Custom HTML body for an error status code. Repeatable. Takes the status code as first argument, then path="..." or html="...".
geoip block optional GeoIP database for country-based access control.
group string user’s primary group optional Unix group to switch to after binding. Defaults to the primary GID of user from /etc/passwd.
cert-key-mode integer (octal)0o600 optional Unix file mode for ACME private key files (key.pem) under state-dir. Defaults to owner read/write only; widen (e.g. 0o640) to share the key with a group such as a sidecar process. acme_account.json is always written 0600 regardless of this setting.
access-log block optional Configure access-log format and destination. Without this block, lines flow through the global tracing subscriber (the historical default).
health bool | block#true optional Enable or disable the built-in health endpoints (/healthz, /livez, /readyz).
inherit-supplementary-groups bool#false optional Skip setgroups() during privilege drop, preserving supplementary groups inherited at startup.
policy block optional Named, reusable policy block. Repeatable; each takes a name as its first argument.
state-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.
tls block optional Global TLS defaults inherited by every TLS listener.
user string optional Unix username to switch to after all sockets are bound. Only effective when started as root; silently ignored otherwise.

access-log — Access log format and sink

One access-log block at server scope selects the format and destination for per-request access records. Without the block, lines pass through the global tracing subscriber exactly as before.

Example: JSON to a file rotated externally by logrotate:

server {
    access-log {
        format "json"
        path   "/var/log/aloha/access.log"
    }
}
PropertyTypeDefault Notes
format string required One of tracing, json, common, combined. tracing emits structured events through the global subscriber (historical behaviour); json emits one newline-delimited JSON object per request; common is the NCSA Common Log Format (%h %l %u %t "%r" %s %b); combined extends common with "%{Referer}i" "%{User-agent}i".
path stringstdout optional Filesystem path of the sink file (opened append-only, created if missing). Ignored for format "tracing". aloha does not rotate logs; use logrotate with a move-and-truncate (copytruncate or signal-based) policy.

auth — Authentication back-ends

aloha supports several authentication back-ends: HTTP Basic credentials validated against PAM or LDAP, an external HTTP endpoint (subrequest), and ES256 JWT session cookies. Configure one back-end in the server block; it applies to every location that challenges users.

Once a back-end is configured, add a basic-auth block inside a location to issue a WWW-Authenticate: Basic challenge, and a policy block to enforce who is allowed (see Policy blocks).

JWT

Issues and validates ES256 (ECDSA P-256) JWT session cookies so that browsers need to send credentials only once per session. After a successful login, aloha sets a Set-Cookie header; subsequent requests carry the cookie instead of re-sending credentials. Externally-issued JWTs (e.g. from an OAuth provider) can also be validated without aloha issuing tokens (standalone mode).

A P-256 private key is generated on first startup and stored at {state-dir}/jwt/ec-key.pem (mode 0600). The corresponding public key is automatically served at /.well-known/jwks.json on every virtual host.

Session mode — wraps an existing credential back-end:

server {
    state-dir "/var/lib/aloha"
    auth jwt {
        wrap pam service=login   // or ldap, subrequest
        cookie-name aloha_session  // optional
        validity    300              // seconds; optional
    }
}

Standalone mode — validates incoming JWTs only, never issues:

server {
    state-dir "/var/lib/aloha"
    auth jwt {
        cookie-name aloha_session
    }
}

In both modes, a JWT is accepted from either the named cookie or an Authorization: Bearer <token> header. Tokens are checked for a valid ES256 signature, the correct kid field, and a non-expired exp claim. The payload carries sub (username) and groups (array of group names). Cookies are issued with HttpOnly; SameSite=Strict; Path=/; Max-Age=N; the Secure flag is added automatically on TLS listeners.

ChildrenType DefaultDescription
validityinteger (s) 300 optional Lifetime of issued tokens in seconds.
wrapstring optional Credential back-end for first-time logins. Accepts the same argument as a top-level auth block: pam, ldap, subrequest, or oidc with their usual children. Omit for standalone validation.

LDAP

server {
    auth ldap {
        url      "ldap://localhost:389"
        bind-dn  "uid={user},ou=people,dc=example,dc=com"
        base-dn  "ou=groups,dc=example,dc=com"

        // Optional:
        group-filter "(memberUid={user})"
        group-attr   "cn"
        starttls     #false
        timeout      5
    }
}

Authentication is performed as an LDAP simple bind. The {user} placeholder in bind-dn (and in group-filter) is replaced with the RFC 4514-escaped username at request time. After a successful bind, a subtree search under base-dn finds the user’s group memberships.

Unix socket connections are supported via the ldapi:// scheme:

auth ldap {
    url     "ldapi:///var/run/slapd/ldapi"
    bind-dn "uid={user},ou=people,dc=example,dc=com"
    base-dn "ou=groups,dc=example,dc=com"
}
ChildrenType DefaultDescription
base-dnstring required Base DN for the group membership search.
bind-dnstring required DN template for the simple bind. Must contain {user}, replaced with the RFC 4514-escaped username.
group-attrstringcn optional Entry attribute used as the group name.
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.
starttlsboolean#false optional Upgrade a plain ldap:// connection to TLS using STARTTLS.
timeoutinteger (s)5 optional Seconds before an LDAP operation is abandoned.
urlstring required Server URL. Supported schemes: ldap:// (plain), ldaps:// (TLS), ldapi:// (Unix socket).
Caution Empty passwords are rejected before any bind attempt. Many LDAP servers accept an empty password as an anonymous bind, which would otherwise grant access to any username.

OIDC

server {
    state-dir "/var/lib/aloha"
    auth jwt {
        validity 3600
        wrap oidc {
            issuer             "https://accounts.example.com"
            client-id          "aloha"
            client-secret-file "/etc/aloha/oidc-secret"
            redirect-uri       "https://app.example/.aloha/oidc/callback"

            // Optional:
            scope          "openid" "profile" "email"
            groups-claim   "groups"
            username-claim "sub"
        }
    }
}

Browser-driven single sign-on against any standards-compliant OIDC identity provider (Google, Keycloak, Authelia, Okta, Entra, …). Uses the authorization-code flow with PKCE. OIDC must be wrapped inside auth jwt so the post-login identity can be persisted as an aloha session cookie; subsequent requests carry authentication via that cookie and never round-trip to the IdP.

Two server-wide endpoints are reserved automatically (paths configurable):

  • GET /.aloha/oidc/login — generates the PKCE challenge and CSRF state, then 302-redirects to the IdP’s authorisation endpoint.
  • GET /.aloha/oidc/callback — validates the state cookie, exchanges the code for an ID token, issues the JWT session cookie, and 302-redirects to the originally requested URL.

When OIDC is configured, anonymous browser clients that hit a protected location are automatically redirected to the login endpoint instead of receiving the basic-auth challenge. API clients (no Accept: text/html or carrying an Authorization header) keep the 401 response.

ChildrenType DefaultDescription
callback-pathstring /.aloha/oidc/callback optional Path the IdP redirects to with the authorisation code. Must match the path component of redirect-uri.
client-idstring required OAuth2 client identifier registered with the IdP.
client-secretstring optional OAuth2 client secret. Prefer client-secret-file so the value never appears in the parsed configuration.
client-secret-filestring optional Path to a file containing the OAuth2 client secret. Trailing whitespace is stripped. Takes precedence over an inline client-secret.
groups-claimstringgroups optional Name of the ID-token claim listing the user’s groups. Accepts a JSON array of strings or a single space-delimited string.
issuerstring required Base issuer URL. Must use https://; http://localhost is permitted for development. Discovery is performed at startup.
login-pathstring /.aloha/oidc/login optional Path on aloha that initiates the OIDC login flow.
redirect-uristring required Absolute URL registered with the IdP; must resolve back to callback-path on this server.
scopestring… openid profile email optional One scope "…" child per requested scope. The mandatory openid scope is added automatically when absent.
state-ttlinteger (s)600 optional Seconds an unfinished login (PKCE verifier + nonce + return URL) is held before eviction.
username-claimstringsub optional Name of the ID-token claim used as the authenticated user’s username. Falls back to sub when the named claim is absent.
refreshboolean#false optional Enable refresh-token support so that short-lived session cookies can be renewed without a full IdP round-trip. When enabled, aloha implicitly requests the offline_access scope.
refresh-ttlinteger (s) 86400 optional Sliding idle timeout for a refresh session. Each successful refresh resets the timer; entries idle longer than this are evicted.
logout-pathstring /.aloha/oidc/logout optional Path on aloha that terminates the session. A GET here clears the JWT and refresh cookies and (optionally) redirects through the IdP’s end-session endpoint.
post-logout-uristring / optional Where the browser is sent after logout completes. Must be a same-origin absolute path; pre-register the corresponding absolute URL with the IdP if RP-initiated logout is enabled.
idp-logoutboolean#true optional When true and discovery exposed end_session_endpoint, the logout endpoint redirects through the IdP so the IdP-side session is terminated alongside aloha’s. Set to #false for IdPs that misbehave on logout — aloha then performs local-only logout (clears its own cookies and returns to post-logout-uri).
userinfoboolean#false optional When enabled, fetch the IdP’s /userinfo endpoint after each token exchange and merge its claims with the ID token (UserInfo wins on non-empty values). Required for IdPs that omit groups / email from the ID token; Google is the canonical example. Adds one extra HTTP round-trip per login and per refresh.
discovery-refreshinteger (s) 3600 optional Seconds between periodic re-runs of OIDC discovery. This is how aloha picks up JWKS rotation at the IdP without restart. Set to 0 to disable periodic refresh (only the initial bootstrap runs).
discovery-retryboolean #true optional When true (default), discovery failures at startup do not abort aloha — the provider stays in a not-ready state and a background task retries with exponential backoff (capped at 5 minutes). All OIDC endpoints return 503 with a Retry-After header until the first successful discovery. Set to #false to restore fail-fast startup.
backchannel-logoutboolean #true optional When true (default), expose a POST-only endpoint that accepts signed logout_tokens pushed by the IdP and tears down matching server-side refresh entries. Spec: OpenID Connect Back-Channel Logout 1.0.
backchannel-logout-pathstring /.aloha/oidc/backchannel-logout optional Path the IdP POSTs the logout_token to. Register the corresponding absolute URL with the IdP’s client settings.
backchannel-max-iat-skewinteger (s) 120 optional Maximum acceptable clock skew, in seconds, between the inbound logout-token’s iat claim and the local clock. Anything older is rejected as stale.
backchannel-jti-ttlinteger (s) 300 optional How long a successfully-applied logout-token’s jti is remembered to reject replays. Should comfortably exceed backchannel-max-iat-skew.
bearerboolean#false optional Accept Authorization: Bearer <jwt> from API callers, validated against the IdP’s JWKS as a parallel auth path alongside the browser session cookie. Requires at least one bearer-audience entry.
bearer-audiencestring optional Repeatable: each child contributes one accepted aud value. A bearer token is rejected unless its aud claim contains at least one of the listed values.
bearer-cache-sizeinteger 1024 optional LRU capacity for verified bearer tokens. Cache hits skip the per-request signature verification until the token’s own exp.
revoke-on-logoutboolean #true optional When true (default), the logout endpoint additionally POSTs the dropped refresh token to the IdP’s revocation_endpoint (RFC 7009). Defence-in-depth on top of the end-session redirect. The call is fire-and-forget — logout returns promptly even if the IdP’s revocation endpoint is slow.
require-issboolean #false optional Mix-up attack mitigation (RFC 9207). Aloha always rejects callbacks whose iss parameter is present but does not match the configured issuer. With require-iss #true the callback also rejects responses that omit iss entirely.
resourcestring optional Repeatable: each child contributes one RFC 8707 resource value. Forwarded on the authorization, code-exchange, and refresh requests so the IdP narrows the access token’s aud to the listed resources. Values must be absolute http(s) URLs without a #fragment.

When refresh is enabled, the callback response sets a second cookie alongside the session JWT: an opaque session id that maps to an in-memory entry holding the IdP refresh token. On any request where the session JWT has expired but the refresh cookie is still present, aloha calls the IdP’s token endpoint, issues a fresh JWT, and serves the request without bouncing the browser through the login redirect. Refresh state is in-memory only; a server restart forces users to re-log in.

GET <logout-path> tears the session down: aloha drops the server-side refresh entry, emits past-dated Set-Cookie headers that clear the JWT, refresh, and login-state cookies, then issues a 302. With idp-logout enabled and an end_session_endpoint in the IdP’s discovery document, the redirect carries id_token_hint, post_logout_redirect_uri, and client_id through the IdP so its session is terminated too; otherwise the browser goes straight to post-logout-uri. Hitting the logout endpoint without an active session is a safe no-op redirect.

Back-channel logout. With backchannel-logout enabled (the default), aloha also exposes a POST-only endpoint that accepts signed logout_token JWTs pushed by the IdP. Each token is validated (signature against the cached JWKS, iss/aud, iat within backchannel-max-iat-skew, the back-channel-logout events claim, presence of sub or sid, absence of nonce, and jti uniqueness within backchannel-jti-ttl). On success aloha removes every refresh entry whose stored sid matches (or every entry for the user when only sub was sent). This only tears down server-side state — the user’s in-flight JWT session cookie remains valid until its own expiry, so operators who need fast revocation should keep validity short.

Login-flow query passthrough. The login endpoint forwards the standard OIDC hints login_hint, prompt, max_age, acr_values, and ui_locales to the IdP’s authorisation URL when they appear on the inbound query string (alongside return). Values are length-capped at 256 bytes and restricted to printable ASCII. Useful for SPAs that need prompt=none silent re-auth, password- manager deep links via login_hint, MFA enforcement via acr_values, and localised login pages via ui_locales. See OIDC Core 1.0 §3.1.2.1 for the canonical definitions.

Bearer-token resource-server mode. With bearer enabled, aloha accepts Authorization: Bearer <jwt> from API callers in addition to the browser session cookie. Tokens are validated against the IdP’s cached JWKS (signature, iss, exp, nbf) and rejected unless the aud claim contains at least one configured bearer-audience. Successful validations are cached by SHA-256(token) under the configured bearer-cache-size, so repeated requests with the same token skip the crypto until the token’s own exp. The resolved Identity uses the same username-claim / groups-claim configuration as the SSO flow, so access policies see one consistent Identity shape regardless of which auth path produced it. Supports JWT-format tokens only; opaque (introspection-required) tokens are not yet handled.

OAuth standards-track polish. Three small defensive measures sit alongside the main flow: revoke-on-logout (RFC 7009) tells the IdP to invalidate the refresh token at logout in addition to the existing end-session redirect; require-iss + the (always-on) iss match on the callback (RFC 9207) protects against mix-up attacks where a malicious site tricks a logged-in user into completing an authorization flow against the wrong IdP; resource (RFC 8707) lets the relying party narrow the access token’s aud to one or more specific protected resources, which pairs naturally with bearer-token resource-server mode above.

PAM

server {
    auth pam {
        service login   // PAM service name; defaults to login
    }
}

Credentials are validated by calling into libpam using the named service (/etc/pam.d/<service>). After a successful authentication, the user’s Unix group memberships are resolved via getgrouplist(3) and become available for group conditions in policy blocks. PAM is dispatched on a dedicated blocking thread so the async runtime is not stalled.

ChildrenType DefaultDescription
servicestring "login" optional PAM service name. Must correspond to a file in /etc/pam.d/.

File (htpasswd)

server {
    auth file {
        path  "/etc/aloha/htpasswd"
        cache 60   // seconds between mtime checks
    }
}

Validates HTTP Basic credentials against an htpasswd-style file. Each line is user:hash[:group1,group2,...]; blank lines and lines beginning with # are ignored. Supported hash schemes are bcrypt ($2y$, $2b$, $2a$), SHA-512 crypt ($6$), and Argon2id ($argon2id$). Weaker schemes (DES, MD5-crypt, plain) are dropped at parse time so a misconfigured file never silently authenticates against a weak hash.

Generate entries with the standard tools: htpasswd -B -C 10 for bcrypt, mkpasswd -m sha-512 for SHA-512 crypt, or argon2 from the libargon2 package. Group memberships in the optional third column become available for group conditions in policy blocks.

The parsed table is cached in memory; aloha re-stats the file once cache seconds have elapsed and reparses only if the mtime has changed. This makes operator edits visible within ~cache seconds without paying the parse cost on every request.

ChildrenType DefaultDescription
pathstring required Filesystem path to the htpasswd file. Accepts the positional shorthand auth file "/etc/aloha/htpasswd".
cacheinteger (seconds) 60 optional Seconds between freshness checks against the file’s mtime. A change triggers a full reparse on the next request.

Subrequest

Delegates authentication to an external HTTP endpoint — the same pattern as nginx’s auth_request module. For every request that needs authentication, aloha makes an outgoing HTTP GET to the configured URL, forwarding selected request headers. An HTTP 200 response means authenticated; any other status or a network error means anonymous.

server {
    auth subrequest {
        url            "http://auth.internal:8080/validate"
        forward-header Authorization   // repeatable
        forward-header Cookie
        user-header    X-Auth-User     // identity from response
        groups-header  X-Auth-Groups
        timeout        5
    }
}

When the auth endpoint returns 200, aloha reads identity from the response headers named by user-header and groups-header. If neither header is configured, a 200 still counts as authenticated (suitable for pure allow/deny decisions).

ChildrenType DefaultDescription
forward-headerstring repeatable Request header forwarded verbatim to the auth endpoint. Typically Authorization or Cookie.
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.
urlstring required HTTP endpoint to call. Must use http:// scheme.
user-headerstring optional Response header whose value becomes the authenticated username.

The typical deployment pairs the subrequest back-end on the protected server with an auth-request handler on the auth server. See that section for the complete two-server example.

error-page

Override the default minimal response body for any HTTP error status code. Define error pages in the server block; they apply to all error responses generated by access policy denials.

server {
    error-page 403 path="/var/www/errors/403.html"
    error-page 401 html="<h1>Authentication Required</h1>"
    error-page 404 path="/var/www/errors/404.html"
}
SyntaxDescription
error-page N path="..." Read HTML from this file on each error response. The file is re-read on every request, so updates take effect without restarting aloha.
error-page N html="..." Use this literal HTML string as the response body.

The Content-Type is always text/html; charset=utf-8. The error page replaces only the body; the status code and headers (e.g. WWW-Authenticate for 401 responses) are set normally. If the file is missing or unreadable, aloha falls back to the default minimal body.

geoip

Restricts access by the client’s country of origin using a MaxMind MMDB database. Configure the path once in the server block; then use country predicates in any policy block.

server {
    geoip {
        db "/etc/aloha/GeoLite2-Country.mmdb"
    }
}
ChildrenType DefaultDescription
dbpath required Filesystem path to the MaxMind MMDB file. Compatible databases: GeoLite2-Country (country codes only), GeoLite2-City, or any MMDB file that contains a country.iso_code field.

The database is loaded into memory at startup; the file must be readable by the aloha process after privilege drop. Country codes are ISO 3166-1 alpha-2 (two uppercase letters). Private and reserved IP ranges (127.0.0.0/8, 10.0.0.0/8, etc.) are not in the database and will not match a country predicate. Combine with address to allow private ranges explicitly.

// Allow only US, Canada, and UK; plus the internal network
policy {
    allow address "10.0.0.0/8"
    allow country US CA GB
    deny  code=403
}
Caution A startup error is returned if country predicates appear anywhere but no server { geoip { db … } } is configured.

user and group

When aloha is started as root (necessary to bind ports 80 and 443), set user to drop privileges immediately after all sockets are bound and before any connections are accepted. The syscall sequence is setgroupssetgidsetuid.

In container deployments that use podman run --group-add keep-groups, set inherit-supplementary-groups #true to preserve the supplementary groups passed by podman rather than replacing them with a setgroups() call.

server { user www-data }
server { user aloha; group aloha }
// container: preserve groups from --group-add keep-groups
server { user aloha; inherit-supplementary-groups #true }

health

aloha responds to GET and HEAD requests on /healthz, /livez, and /readyz with a 200 OK JSON body {"status":"ok","check":"healthz"} (and so on). These endpoints are intercepted before vhost routing, so they work without a Host header and cannot be shadowed by user-defined locations. They are enabled by default; set health #false to disable them.

server {
    health #false             // disable all health endpoints
}

// Block form (equivalent):
server {
    health { enabled #false }
}
ChildrenType DefaultDescription
enabledboolean#true optional Enable or disable the built-in health endpoints. Equivalent to passing #false directly as a positional argument to health.
Note Kubernetes liveness (/livez), readiness (/readyz), and startup (/healthz) probes can all point at these paths without any additional configuration. Only GET and HEAD are handled; other methods fall through to normal routing.

tls — Global TLS defaults

These settings can appear in a tls block inside server (global defaults) or inside a listener (per-listener override). Per-listener values take precedence.

server {
    tls {
        min-version "1.2"
        cipher TLS13_AES_256_GCM_SHA384
        cipher TLS13_CHACHA20_POLY1305_SHA256
    }
}
ChildrenType DefaultDescription
cipherstring provider defaults repeatable Restrict the allowed cipher suites by name.
min-version "1.2" | "1.3" "1.2" optional Minimum TLS protocol version to accept.

listener

Opens a socket and begins accepting connections. At least one is required. Use separate listeners for plain HTTP and HTTPS.

// Plain HTTP
listener {
    bind "tcp://[::]:80"
}

// HTTPS with an explicit default vhost
listener {
    bind          "tcp://[::]:443"
    default-vhost example.com
    tls-file cert="/etc/aloha/cert.pem" key="/etc/aloha/key.pem"
}

// null disables the fallback -- unrecognised hosts get a 404
listener {
    bind          "tcp://[::]:80"
    default-vhost #null
}

// Unix domain socket (useful behind a front-end proxy)
listener {
    bind "unix-stream:/run/aloha.sock"
}

Unix socket connections have no IP address; they are treated as coming from 127.0.0.1 for access-control and GeoIP purposes. Access logs show [unix] as the peer.

ChildrenType DefaultDescription
accept-proxy-protocol v1 | v2 optional Read a HAProxy PROXY protocol header from each incoming connection before TLS or HTTP parsing. The real client address from the header replaces the TCP peer address for access rules, logging, and forwarding headers. Use when aloha sits behind HAProxy or an AWS NLB that speaks PROXY protocol. v2 (binary) is preferred. Pair with trusted-proxies to restrict which peers may inject headers.
trusted-proxies list of CIDR / IP optional Allowlist of peer addresses permitted to send a PROXY protocol header. Only meaningful together with accept-proxy-protocol. When omitted, the listener trusts every peer — safe only if the listener is firewalled from untrusted networks. Connections from peers outside the list are dropped before the header is read, so arbitrary clients cannot spoof their source address. Accepts bare IPs or CIDR ranges, e.g. trusted-proxies "10.0.0.0/8" "192.168.1.1".
bindstring required Address to listen on. Use "host:port" for TCP (e.g. "[::]:8080"), "udp://host:port" for QUIC/HTTP/3 (requires the http3 cargo feature), or "unix-stream:/path" for a Unix domain socket. If a matching socket was inherited from a parent process (seamless upgrade), aloha adopts it instead of calling bind(2).

Pairing a TCP/TLS listener with a udp: listener on the same port enables automatic HTTP/3 advertisement via the Alt-Svc response header. The auto-injected value (h3=":<port>"; ma=86400) is only added when no Alt-Svc header is already present on the response, so a response-headers { set "Alt-Svc" "..." } rule in a location or vhost always wins.
default-vhost string | #null first vhost optional Vhost used when no Host header matches. Set to #null to return 404 for unrecognised hosts.
alpnstrings (positional) h2 http/1.1 on TLS listeners optional Override the listener’s advertised ALPN protocols. Order matters — the first entry the client accepts wins. A vhost can further narrow this list via its own alpn child (TCP/TLS only; QUIC listeners always use the listener default). At least one value is required.
quic-transport block optional Per-listener QUIC transport knobs. Only valid on udp: listeners; misuse on a TCP listener is a parse-time error.
max-connectionsinteger unlimited optional Maximum simultaneous open connections on this listener. New connections are deferred (not dropped) when the limit is reached, so the kernel accept queue is never backed up.
max-request-bodyinteger (bytes) unlimited optional Reject requests whose Content-Length header exceeds this value with 413 Content Too Large. Applies before any handler runs. HTTP mode only.

The bind address may also be written as the listener’s positional argument: listener "tcp://[::]:80" { … }.

quic-transport — QUIC transport tuning

Per-listener overrides for the QUIC transport. Only valid on udp: listeners; absent knobs fall back to the quinn defaults.

listener {
    bind "udp://[::]:443"
    tls-acme { domain "example.com" }
    quic-transport {
        max-concurrent-bidi-streams 100
        max-idle-timeout            30   // seconds
        keep-alive-interval         15
        zero-rtt                    #false
        retry-tokens                #true
        retry-token-lifetime        15
    }
}
ChildrenType DefaultDescription
max-concurrent-bidi-streams integerquinn default optional Cap on simultaneous client-initiated bidirectional streams. Limits an abusive peer from opening unbounded request streams over a single QUIC connection.
max-idle-timeoutinteger (seconds) quinn default optional Close a QUIC connection after this many seconds with no traffic in either direction.
keep-alive-intervalinteger (seconds) off optional Send a PING frame this often when the connection is otherwise idle, so NAT bindings and stateful middleboxes don’t time the path out.
zero-rttbool#false optional Accept 0-RTT early data on resumed sessions. 0-RTT data is replayable, so leave this off unless every endpoint reachable through this listener is fully idempotent.
retry-tokensbool#true optional Force source-address validation via QUIC Retry packets. Defends against spoofed-source connection floods at the cost of one extra round-trip on the first handshake from each client address.
retry-token-lifetime integer (seconds)quinn default optional How long an issued retry token remains valid before the client must re-handshake.

proxy — Stream (TCP) proxy

Adding a proxy block to a listener activates stream mode: raw TCP bytes are forwarded to the upstream. HTTP routing does not apply.

Combine with a tls-* block on the listener to terminate TLS from clients before forwarding — aloha decrypts the incoming connection and forwards the plaintext stream. Add a tls block inside the proxy block to re-encrypt the upstream connection (re-TLS).

// Plain TCP tunnel to a PostgreSQL backend
listener "tcp://[::]:5432" {
    proxy "db.internal:5432" {
        proxy-protocol v2
    }
}

// Unix domain socket upstream
listener "tcp://[::]:5432" {
    proxy "unix-stream:/run/postgresql/.s.PGSQL.5432"
}

// TLS termination: clients connect over TLS, backend gets plaintext
listener "tcp://[::]:5433" {
    tls-self-signed
    proxy "db.internal:5432" {
        proxy-protocol v2
    }
}

// Full re-TLS: terminate client TLS, re-encrypt to upstream
listener "tcp://[::]:5433" {
    tls-file cert="cert.pem" key="key.pem"
    proxy "db.internal:5432" {
        tls             // verify with bundled WebPKI roots
    }
}

// Restrict a database port to an internal CIDR block
listener "tcp://[::]:5432" {
    proxy "db.internal:5432"
    policy {
        allow address "10.0.0.0/8"
        deny  code=403
    }
}

Arguments

PositionTypeDescription
upstreamstring required Upstream address: "host:port" for TCP or "unix-stream:/path" for a Unix domain socket.

Children

ChildType DefaultDescription
proxy-protocol v1 | v2 optional Prepend a HAProxy PROXY protocol header so the upstream sees the real client IP. v2 is preferred.
tlsblock optional Connect to the upstream using TLS. Verifies the upstream certificate against system CA roots.
tls › skip-verifyflag optional Disable upstream certificate verification. Presence of the node is sufficient; no value required: tls { skip-verify }.

A policy block placed at the listener level (sibling to proxy) may only use address and country predicates. Identity predicates are rejected at startup. Denied connections are closed silently; redirect rules are treated as deny.

A config consisting entirely of stream listeners is valid with no vhost defined at all.

timeouts

Optional connection and request timeout settings. All values are in whole seconds. request-header defaults to 30 s even when the timeouts block is absent; set it to 0 to disable. All other values default to unlimited.

listener {
    bind "tcp://[::]:8080"
    timeouts {
        request-header 30
        handler        60
        keepalive      75
    }
}
ChildrenType DefaultDescription
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.
request-headerinteger (s) 30 optional Maximum seconds to wait for a complete request line and headers. Protects against Slowloris-style attacks. Set to 0 to disable.

tls-* — Certificate mode

Add one of three sibling blocks to a listener to enable TLS. Each accepts its settings either inline (one-line form) or as children in a block.

// Self-signed -- ephemeral, generated at startup (development only)
tls-self-signed

// PEM files from disk
tls-file cert="/etc/aloha/cert.pem" key="/etc/aloha/key.pem"

// ACME / Let's Encrypt
tls-acme {
    domain example.com www.example.com
    email  [email protected]
}
NodeRequired values Description
tls-file cert, key PEM certificate chain and private key from disk.
tls-self-signed Generates an ephemeral cert at startup. Development use only — not trusted by browsers.
tls-acme domain+ and state-dir in server Obtain and renew certificates automatically via ACME HTTP-01.
tls certificate name (positional or cert="name") Reference a top-level certificate. Multiple listeners that share a name share a single acceptor (and, for ACME, one renewal loop).
Note HTTP/1.1 and HTTP/2 are both supported on TLS listeners; protocol selection is automatic via ALPN.
Conflict Two listeners may not carry inline tls-acme blocks that resolve to the same on-disk slot — the same explicit name, or the same first domain when name is unset. Define a top-level certificate and reference it from both listeners.

tls-acme — ACME / Let’s Encrypt

With tls-acme, aloha obtains and automatically renews a certificate via the ACME HTTP-01 challenge. A plain HTTP listener must be running to answer challenge requests. Requires state-dir in the server block.

listener {
    bind "tcp://[::]:443"
    tls-acme {
        domain example.com www.example.com
        email  [email protected]
    }
}
ChildrenType DefaultDescription
domainstring required Domain name for the Subject Alternative Name. repeatable — at least one required.
emailstring optional Contact address registered with the ACME provider.
namestring first domain optional Storage subdirectory name under state-dir.
retry-intervalinteger (s) 3600 optional Seconds between retry attempts after a certificate failure.
serverURL Let’s Encrypt optional Override the ACME directory URL.
stagingboolean #false optional Use the Let’s Encrypt staging server (untrusted, no rate limits — useful for testing).
challengestring "http-01" optional Which ACME challenge to use. One of "http-01" (port 80, default; no extra setup), "dns-01" (publishes TXT records via a dns-provider; the only type that can validate wildcard SANs), or "tls-alpn-01" (validates on port 443 using the acme-tls/1 ALPN, so no extra port is needed).
dns-providerblock optional Required when challenge is "dns-01". Selects the back-end that publishes the validation TXT records. Built-in kinds: "acme-dns" (API user / API key for an acme-dns server), "cloudflare" (zone‑id + bearer token), "route53" (hosted zone id; AWS credentials from the standard provider chain; requires --features dns-route53), and "exec" (runs an external program with the FQDN and TXT value in the environment). See the example below.
// Wildcard cert via Cloudflare DNS-01:
tls-acme {
    domain *.example.com
    email  "[email protected]"
    challenge "dns-01"
    dns-provider "cloudflare" {
        zone-id   "abc123..."
        api-token "cf-token..."
    }
}

// Or with an operator-supplied shell hook:
tls-acme {
    domain vpn.example.com
    challenge "dns-01"
    dns-provider "exec" {
        program "/usr/local/libexec/aloha-dns.sh"
    }
}

// TLS-ALPN-01: needs no extra port.
tls-acme {
    domain internal.example.com
    challenge "tls-alpn-01"
}
DNS-01 exec hook The exec provider runs program (plus any args) with three environment variables: ALOHA_DNS_ACTION (set or clear), ALOHA_DNS_FQDN (the full record name, including the _acme-challenge. prefix), and ALOHA_DNS_VALUE (the base64url SHA-256 digest aloha needs published as a TXT record). A non-zero exit aborts the challenge.

mtls — Mutual TLS (client‑cert auth)

Inside any tls-file, tls-self-signed, or tls-acme block, an mtls { } child installs a rustls WebPkiClientVerifier so the listener requires (or accepts) a client certificate chained to one of the listed CAs. A verified leaf certificate becomes an authenticated Principal: its Common Name is exposed as the request username, and the full subject DN and SAN list are available as the {client_cert_subject} and {client_cert_sans} template variables for request-headers / response-headers rules.

listener {
    bind "tcp://[::]:443"
    tls-file cert="/etc/...crt" key="/etc/...key" {
        mtls {
            ca "/etc/aloha/clients-ca.pem"
            mode "required"
            revocation "/etc/aloha/crl.pem"
            refresh 300
        }
    }
}
ChildrenType DefaultDescription
capath required repeatable Trust‑anchor PEM file. At least one is required; the client's leaf must chain to one of them.
modestring "required" optional "required" aborts the handshake when the client sends no certificate or one not chained to a listed CA. "optional" still accepts anonymous clients; only verified leaves set the Principal.
revocationpath optional repeatable PEM‑encoded certificate revocation list. The verifier rejects any leaf whose serial appears in the union of all listed CRLs.
refreshinteger (s) 0 optional When greater than zero and at least one revocation file is set, aloha re‑reads every CRL file on this interval and atomically swaps the verifier so the new revocation set applies to all future handshakes. Reload failures keep the previous verifier live.
Identity model The cert’s Common Name becomes the request username (so policy allow user "alice" works directly); the groups list is empty. Layer with PAM/LDAP/OIDC if you also need group membership.
QUIC / HTTP/3 The verifier runs at handshake on QUIC listeners too, so an untrusted client cannot reach HTTP/3 either. The per‑request identity (CN, subject, SANs), however, is currently only surfaced on the TCP/HTTPS path.

ocsp — OCSP stapling

Inside any tls-file or tls-acme block, aloha runs a background OCSP‑stapling task by default: on startup it reads the certificate's Authority Information Access (AIA) extension to find the issuer's OCSP responder URL, POSTs a request, and attaches the resulting response to every TLS handshake via the certificate_status_request extension. The staple is refreshed in the background before the responder’s nextUpdate field expires and is persisted to disk under state-dir/ocsp/ so restarts don’t replay a fresh fetch.

listener {
    bind "tcp://[::]:443"
    tls-file cert="/etc/...crt" key="/etc/...key" {
        ocsp #false            // disable for this listener
        ocsp-timeout 10          // fetch timeout (s)
        ocsp-min-refresh 3600   // floor for refresh cadence (s)
        ocsp-failure-backoff 300 // retry delay after a failure (s)
    }
}
ChildrenType DefaultDescription
ocspboolean #true optional Master switch. #false skips OCSP entirely for this listener (no fetch, no staple).
ocsp-timeoutinteger (s) 10 optional HTTP request timeout when contacting the responder.
ocsp-min-refreshinteger (s) 3600 optional Floor for the in‑memory refresh interval; aloha re‑fetches at least this often even if the responder advertises a far‑future nextUpdate, so revocations propagate.
ocsp-failure-backoffinteger (s) 300 optional Retry interval after an OCSP fetch failure. Until the next success, the listener keeps serving without a staple.
Failure mode Stapling is best‑effort: a missing AIA extension, an unreachable responder, a non‑basic response, or any other failure leaves the listener serving without a staple, logs a WARN line, and increments ocsp_refresh_failures on the status page. TLS handshakes are never aborted because of an OCSP problem.

certificate

Defines a named certificate at the top level of the config so that any number of listeners can share it via tls cert="name". For ACME sources this collapses what would otherwise be N independent renewal loops — one per listener — into a single background task that hot-swaps one shared acceptor.

The body contains exactly one source child: acme, files, or self-signed. Each accepts the same properties and children as the corresponding inline form (tls-acme, tls-file, tls-self-signed).

// One ACME cert shared by two listeners
certificate "main" {
    acme {
        domain example.com www.example.com
        email  [email protected]
    }
}
listener {
    bind "tcp://[::]:443"
    tls cert="main"
}
listener {
    bind "tcp://[::]:8443"
    tls cert="main"
}

// File-based shared cert
certificate "internal" {
    files cert="/etc/aloha/internal.crt" key="/etc/aloha/internal.key"
}
When to use Use a named certificate whenever two or more listeners would otherwise need identical tls-acme or tls-file blocks. Aloha rejects such configurations at parse time because the duplicate ACME managers would race on the same on-disk directory.

vhost

Maps one or more hostnames to a set of URL routing rules. Requests are matched against the Host header (port suffix stripped). At least one vhost is required for HTTP mode; stream-proxy-only configs may omit them.

Name matching

The first argument is the primary name; alias adds extra names. Both support two matching modes:

  • Exact literal — the default. The full hostname must match exactly.
  • Regex — add regex=#true as a property. The string is compiled as a regular expression anchored at both ends (^...$). Invalid patterns are caught at startup.

For each request, matching proceeds in this order:

  1. Exact literal match across all vhosts — O(1) lookup.
  2. Regex patterns — in config declaration order; first match wins.
  3. Listener default-vhost fallback.
// Exact match for the bare domain plus an alias
vhost example.com {
    alias www.example.com
    location "/" {
        static { root "/var/www/example" }
    }
}

// Regex -- matches any subdomain of example.com
vhost ".+\.example\.com" regex=#true {
    location "/" {
        static { root "/var/www/wildcard" }
    }
}
ChildrenArgument Description
alias name repeatable Additional hostname or regex pattern that maps to this vhost.
alpn strings (positional) Override the ALPN protocols advertised when this vhost is selected via SNI on a TCP/TLS listener. Lets one host on a shared listener disable HTTP/2 (or any other protocol) without affecting its neighbours. Only literal-name vhosts participate; regex vhosts and QUIC listeners fall back to the listener default.
location path prefix at least one URL routing rule. Longest prefix match wins; declaration order does not matter.

location

Maps a URL path prefix to a handler. The location with the longest matching prefix wins — declaration order does not matter. Each location contains exactly one handler node.

In addition to a handler, a location may carry:

  • A policy block — access control rules. See Policy blocks.
  • A basic-auth block — HTTP Basic auth realm. See basic-auth.
  • request-headers and/or response-headers blocks — inject or modify headers. See Header injection.
  • One or more rate-limit blocks — token-bucket throttle keyed on client IP, authenticated user, or a named header. See Rate limiting.
  • A max-request-body override — tighter cap for one location than the listener-wide ceiling. See Body-size override.
  • An optional match block — narrow which requests this location accepts. Failing matches fall through to the next candidate location. See Request matchers.
  • An optional rewrite directive — substitute the request URI via a regex and re-route from the top. See URL rewrite.

Rate limiting and body-size limits

Each location may declare one or more rate-limit blocks and an optional per-location max-request-body override. Rate-limit blocks evaluate in declaration order after authentication and access control; the first denial short-circuits with 429 Too Many Requests and a Retry-After header.

location "/login" {
    // Tight body cap for a login form on an otherwise
    // permissive listener.
    max-request-body 4096

    // Token-bucket per peer IP.  5 tokens per minute,
    // bucket holds 5 (= rate when burst is omitted).
    rate-limit {
        rate 5 per="minute"
        key  client-ip
    }
    proxy { upstream "http://login/" }
}

location "/api/" {
    // Stacked limits: a strict per-IP rate plus a softer
    // per-user rate.  Both must Allow.
    rate-limit {
        rate 100 per="second"
        burst 200
        key client-ip
    }
    rate-limit {
        rate 1000 per="minute"
        key user
    }
    proxy { upstream "http://api/" }
}

location "/keyed/" {
    // Per-API-key tier: the request header value is the
    // bucket key.  Missing headers share the empty "" bucket.
    rate-limit {
        rate 500 per="hour"
        burst 100
        key header "X-API-Key"
    }
    proxy { upstream "http://api/" }
}
ChildrenType DefaultDescription
rate N per="unit" integer; per ∈ second | minute | hour required Tokens added per per window. Must be > 0. Internally normalised to tokens/second before the bucket runs.
burst N integer rate optional Maximum bucket size. Defaults to rate (non-bursty). Must be > 0 when given.
name="label" or name "label" string auto-generated optional Human-friendly identifier surfaced in access-log records (rate_limit=<name>) when this rule denies a request. Defaults to loc-<i>-rl-<j> based on the rule’s position in the config.
key form client-ip | user | header "Name" required Bucket key. client-ip uses the peer address; user uses the authenticated username (empty for anonymous — all anonymous callers share one bucket); header "Name" uses the request header value (empty when absent).
max-request-body N integer (bytes) listener-wide cap optional Per-location override; useful when one route on an otherwise permissive listener needs a tighter ceiling. Applies only to requests with a Content-Length header; streaming bodies stay bounded by the listener-wide cap. Returns 413 on excess.

Request matchers

A location may carry an optional match { } block. When present, the router only dispatches the location for requests that satisfy every predicate inside the block (AND across predicates). Within a single predicate, the listed alternatives combine with OR. When a matcher rejects a request the router falls through to the next candidate location (next-shortest matching prefix; declaration order on ties). Two locations may share the same prefix and be disambiguated by their matchers alone.

location "/api/" {
    match {
        method "POST" "PUT"
        header "X-API-Version" "~^v[23]$"
        query  "format" "json"
    }
    proxy { upstream "http://api/" }
}

// Anonymous requests to /app go to the login flow; everyone
// else proceeds to the app.
location "/app" {
    match { header-absent "Authorization" }
    redirect "/login"
}
location "/app" {
    proxy { upstream "http://app/" }
}

// Static image extensions get aggressive caching; everything
// else falls through to the default location.
location "/" {
    match { path "[.](jpg|png|gif|webp)$" }
    response-headers {
        set "Cache-Control" "public, max-age=31536000"
    }
    static { root "/var/www" }
}

// Sibling fallback at the same prefix: every other request
// to /api/ lands here.
location "/api/" {
    static { root "/var/www/api" }
}
PredicateForm Semantics
method "NAME"+ one or more method tokens Matches when the request method equals one of the listed names. Names are validated as HTTP method tokens at config load.
header "Name" "value"+ header name then one or more value patterns Matches when the named request header is present and its value satisfies one of the listed alternatives. A value prefixed with ~ is compiled as a regex (Rust regex syntax); any other value is matched exactly. Missing headers never match.
query "name" "value"+ query parameter name then one or more accepted values Matches when the first occurrence of the named query parameter equals one of the listed values. Missing parameters never match.
path "regex"+ one or more regex patterns Matches when the request URI path satisfies at least one of the patterns. Patterns are evaluated unanchored — operators wanting whole-path matches include ^…$ explicitly. Useful for content-typed routing ("[.](jpg|png|gif)$") without spinning up a separate location prefix.
header-absent "Name" header name Matches precisely when the named header is missing from the request. Use this for "anonymous client" routing (header-absent "Authorization") — the regular header predicate always fails on a missing header, so it cannot express absence on its own.
not { predicate+ } block containing one or more predicates The inner predicates are AND-evaluated and the result is inverted. Example: not { method "GET" } matches every method except GET; not { method "GET"; header "X" "y" } matches unless both inner predicates hold. Empty bodies are rejected at config load.

URL rewrite

A location may carry an optional rewrite directive that substitutes the request URI before any handler runs. The from regex is matched against the URI path (not the query string); when it matches, the path is replaced by the to template — which may reference capture groups via $1, $2, or $name — and the router re-evaluates the request from the top, potentially landing on a different location. When the regex does not match, the rewrite is a no-op and the location's own handler runs on the original URI.

The replacement template may include a query string after a literal ?; whatever follows replaces the request's query. When the template omits a query, the rewritten URI has no query — the original is not carried over. A rewrite chain that fails to settle on a non-rewriting location within ten hops is treated as a misconfiguration and yields 404; a tracing::warn! records the offending URI.

location "/legacy/" {
    // /legacy/users/42 -> /api/users/42, served by the next
    // location below.
    rewrite from="^/legacy/(.*)$" to="/api/$1"
    static { root "/var/www" }   // only used when
                                  // regex doesn't match
}
location "/api/" {
    proxy { upstream "http://api/" }
}
FieldType Description
from regex string (property or child) required Pattern compiled with the Rust regex crate. Matched against the request URI path only; capture groups may be referenced from the to template.
to template string (property or child) required Replacement template. Capture references use $1, $2, or $name. May include a query string after a literal ?; absent templates clear the request's query.

Handler: auth-request

The server-side counterpart to the subrequest authentication back-end. A location using this handler acts as an auth endpoint: it applies its own policy block and basic-auth realm to decide whether the caller is authenticated, then returns 200 OK on success with X-Auth-User and X-Auth-Groups response headers populated from the resolved identity.

The access policy machinery issues the 401 or 403 before the handler itself is reached, so the handler has no configuration.

location "/auth/validate" {
    auth-request
    basic-auth realm="My Service"
    policy {
        allow { authenticated }
        deny
    }
}

Complete two-server example

Auth server — validates HTTP Basic credentials via PAM and returns the identity:

// auth-server.kdl
server {
    auth pam { service login }
}
listener { bind "tcp://127.0.0.1:9000" }
vhost "127.0.0.1" {
    location "/validate" {
        auth-request
        basic-auth realm=Protected
        policy { allow { authenticated }; deny }
    }
}

App server — delegates auth to the auth server and restricts a location to group members:

// app-server.kdl
server {
    auth subrequest {
        url            "http://127.0.0.1:9000/validate"
        forward-header Authorization
        user-header    X-Auth-User
        groups-header  X-Auth-Groups
    }
}
listener { bind "tcp://[::]:80" }
vhost app.example.com {
    location "/admin/" {
        basic-auth realm="Admin Area"
        policy {
            allow { group admins }
            deny
        }
        static { root "/var/www/admin" }
    }
}

basic-auth

Configures the WWW-Authenticate realm sent in 401 responses. It does not by itself restrict access; pair it with a policy block containing a deny code=401 rule (or an authenticated predicate, which issues a 401 challenge automatically for anonymous users).

location "/members/" {
    basic-auth realm="Members Area"
    policy {
        allow { authenticated }
        deny  code=401
    }
    static { root "/var/www/members" }
}
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 must be configured for credentials to be validated. Without one, all requests are treated as anonymous and deny code=401 will always challenge without ever accepting credentials.

Handlers: cgi / fastcgi / scgi

These three handlers all speak CGI-adjacent protocols and share several characteristics. They all set REMOTE_ADDR to 0.0.0.0; use a request-headers block with {client_ip} to inject the real address (see Header injection). A new connection is opened per request (no connection pooling).

cgi Unix only

Executes a CGI script as a child process. One process is forked per request.

location "/cgi-bin/" {
    request-headers { set X-Real-IP "{client_ip}" }
    cgi {
        root "/usr/lib/cgi-bin"
    }
}
ChildrenType DefaultDescription
rootpath required Directory containing CGI scripts. The request path maps directly to a file under this directory. Path traversal is blocked.

fastcgi

Forwards requests to a FastCGI application server such as PHP-FPM using the binary FastCGI record protocol.

location "/" {
    request-headers { set X-Real-IP "{client_ip}" }
    fastcgi {
        socket "unix-stream:/run/php/fpm.sock"
        root   "/var/www/html"
        index  index.php
    }
}
ChildrenType DefaultDescription
indexstring optional Default script appended to directory requests (paths ending in /), e.g. "index.php".
rootpath required Document root; combined with the request path to build SCRIPT_FILENAME.
socketstring required FastCGI socket: unix:/path for a Unix domain socket or tcp:host:port for TCP.

scgi

Forwards requests to an SCGI application server (Gunicorn, uWSGI, etc.) using the netstring-framed SCGI protocol.

location "/" {
    request-headers { set X-Real-IP "{client_ip}" }
    scgi {
        socket "unix-stream:/run/myapp.sock"
        root   "/var/www/html"
        index  index.py
    }
}
ChildrenType DefaultDescription
indexstring optional Default script appended to directory requests.
rootpath required Document root for SCRIPT_FILENAME.
socketstring required SCGI socket: unix:/path or tcp:host:port.

Handler: proxy

Reverse-proxies HTTP requests to an upstream server. Connections to the upstream are pooled and reused across requests unless proxy-protocol is set.

location "/api/" {
    proxy {
        upstream     "http://127.0.0.1:3000"
        strip-prefix #true
    }
}

// With HAProxy PROXY protocol so backend sees the real client IP
location "/api/" {
    proxy "http://127.0.0.1:3000" {
        proxy-protocol v2
    }
}
ChildrenType DefaultDescription
proxy-protocol v1 | v2 optional Send a HAProxy PROXY protocol header on each upstream connection. Not supported for unix: upstreams. When set, connection pooling is disabled because the header is bound to the connection.
strip-prefixboolean #false optional Remove the matched location prefix before forwarding. With location "/api/" and strip-prefix #true, /api/users is forwarded as /users.
upstreamstring required Base URL of the upstream server (http://… or https://…), or a Unix domain socket path (unix:/path/to/socket). HTTPS backends are verified against Mozilla’s bundled root certificates. Unix socket backends receive Host: localhost.
scheme auto | h3 auto optional Wire protocol used to reach the upstream. auto (the default) lets hyper-util negotiate HTTP/2 or HTTP/1.1 via ALPN against https:// upstreams. h3 forces HTTP/3 over QUIC; the upstream URL must be https:// since QUIC mandates TLS.
pool-idle-timeout integer (seconds) 90 optional How long a cached upstream connection sits unused before it is closed. Applied to hyper-util's pool for h1/h2 and to aloha's per-handler reaper for h3. 0 disables idle reaping.
pool-max-idle integerhyper-util default optional Maximum idle upstream connections kept in the pool per host. Lower this when the pool is holding more sockets than the backend can comfortably keep alive; 0 disables pooling entirely. Applies to h1/h2 only — the h3 pool holds at most one connection per handler today.
connect-timeout integer (seconds)OS default optional Cap the TCP/TLS handshake time to an upstream. A slow or unreachable backend fails over to the next pool member (or returns 502) instead of hanging the request for the kernel’s default connect timeout.
tlsblock optional Upstream TLS settings for https:// upstreams. The only child today is skip-verify; using this block against a plaintext upstream is a parse-time error.
tls › skip-verify flag optional Disable certificate verification against upstream backends. Intended for internal services with self-signed or otherwise untrusted certificates. Write as tls { skip-verify }.

upstream may also be written as the handler’s positional argument: proxy "http://…". strip-prefix may be written as a property: proxy strip-prefix=#true.

The proxy unconditionally appends to X-Forwarded-For, sets X-Real-IP, and overrides Host with the upstream authority. Hop-by-hop headers are stripped from both the forwarded request and the backend response.

HAProxy PROXY protocol over QUIC is not supported. The wire format is an IETF draft and only HAProxy 2.9+ ships an implementation today — other major proxies (nginx, Caddy, Envoy, Traefik) don’t. For setups that need source-IP preservation on HTTP/3 traffic, terminate QUIC at HAProxy and forward decrypted HTTP/1.1 or HTTP/2 to aloha with PROXY protocol v2 over TCP.

Multi-upstream load balancing

upstream may appear multiple times to declare a pool of backends. The proxy picks one per request via the configured lb-policy and can eject failing upstreams (passively on request errors, actively via a background probe) and retry against the next.

location "/api/" {
    proxy {
        upstream "http://a:8080"
        upstream "http://b:8080" weight=2
        upstream "http://c:8080"

        lb-policy "least-conn"

        active-health {
            path          "/healthz"
            interval      10
            expect-status 200
        }
        passive-health { eject-after 3; eject-for 30 }
        retry { max 2; on-status 502 503 504 }
    }
}

// Header-hashed affinity: the request header to hash sits on the
// same node as the policy.
location "/api/" {
    proxy {
        upstream "http://a:8080"
        upstream "http://b:8080"
        lb-policy "header-hash" header="X-Session-Id"
    }
}
ChildrenType DefaultDescription
upstream … weight=N integer 1 optional Relative pick weight for this upstream. Heavier upstreams take a proportionally larger share of round-robin / random picks and a higher load-tolerance share under least-conn. 0 excludes the upstream from the picker (useful for parking).
lb-policy … header=name round-robin | least-conn | random | ip-hash | header-hash round-robin optional Picker policy. ip-hash sends all requests from one peer IP to the same upstream (sticky sessions without cookies). header-hash requires a header="X-Name" property on the same node; it hashes that header to select an upstream, falling back to round-robin when the header is absent on a given request.
active-health { … } block optional Active health-check tuning. Children: path (default /), interval seconds (default 10; 0 disables), timeout seconds (default 2), expect-status (default 200), unhealthy-after (default 2), healthy-after (default 1). Probes use a separate hyper-util client so a stall can never wedge real traffic.
passive-health { … } block optional Children: eject-after consecutive request errors before ejection (default: never), eject-for seconds the upstream stays out (default 30). An ejected upstream is skipped by the picker until the deadline passes.
retry { … } block optional Children: max additional attempts (default 0); on-status followed by one or more status codes (e.g. on-status 502 503 504) that trigger a retry. on-status is required whenever max>0 so it is explicit which response codes should retry. When retry is enabled the request body is buffered up-front so it can be replayed across attempts.

Retry is enabled by setting retry max=N with N>0. Because aloha must be able to replay the request body across attempts, enabling retry causes the body to be collected into memory before the first attempt. For requests with no body (GET / HEAD / empty POST) this is effectively free; for large request bodies it is a real cost and operators should leave retry max=0 (the default).

Handler: redirect

Returns an HTTP redirect response.

// Simple permanent redirect
location "/old/" {
    redirect {
        to   "/new/"
        code 301
    }
}

// Redirect all HTTP traffic to HTTPS using template variables.
// ACME challenges (/.well-known/acme-challenge/) are intercepted
// before routing and are never affected by a redirect handler.
location "/" {
    redirect {
        to   "https://{host}{path_and_query}"
        code 301
    }
}
ChildrenType DefaultDescription
codeinteger301 optional HTTP status code: 301 (permanent) or 302 (temporary).
toURL or path required Destination written to the Location header. Supports template variables such as {host} and {path_and_query}.

to may also be written as the handler’s positional argument: redirect "/new/". code may be written as a property: redirect "/new/" code=301.

Header injection

The request-headers and response-headers blocks inside a location add, replace, or remove HTTP headers before the request reaches the backend and before the response reaches the client. This works for all handler types.

location "/api/" {
    request-headers {
        set    X-Client-IP       "{client_ip}"
        set    X-Auth-User       "{username}"
        set    X-Auth-Groups     "{groups}"
        set    X-Forwarded-Proto "{scheme}"
        remove Authorization
    }
    response-headers {
        set    X-Frame-Options        DENY
        set    X-Content-Type-Options nosniff
        add    Vary                   Accept-Encoding
        remove Server
    }
    proxy { upstream "http://backend:8080" }
}

Operations (applied in declaration order):

OperationArguments Description
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 absent.
setname, value Set the header to this value, replacing any existing value. Creates the header if absent. Skipped silently when the rendered value is empty.

Value strings may contain {variable} placeholders expanded at request time. Unrecognised placeholders pass through unchanged. A fallback is written {variable|default}: if the variable resolves to an empty string, the default text is used instead.

set X-Auth-User   "{username|anonymous}"
set X-Auth-Groups "{groups|none}"
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
{host} Value of the Host request header
{scheme} "https" for TLS listeners, "http" for plain
{client_cert_subject} Verified client‑certificate subject DN (mTLS). Empty on plaintext / non‑mTLS connections.
{client_cert_sans} Comma‑joined SANs (DNS / URI / email) from the verified client certificate. Empty when no cert was presented.
Note Variables that reference authenticated identity ({username}, {groups}) cause the configured auth back-end to run even when there is no policy block. If no credentials are present the variables render as empty strings.

FastCGI, SCGI, and CGI handlers set REMOTE_ADDR = 0.0.0.0; use a request-headers rule with {client_ip} to pass the real address as HTTP_X_REAL_IP in the CGI environment. The proxy handler unconditionally appends to X-Forwarded-For and sets X-Real-IP after request-headers rules run, so a remove rule for those headers will be overwritten.

Handler: static

Serves files from a local directory. Supports Range requests, ETag conditional GET, and directory index files. Files are streamed in 64 KB chunks without buffering the entire file in memory.

location "/assets/" {
    static {
        root         "/var/www/assets"
        strip-prefix #true
        index-file   index.html
        index-file   index.htm
    }
}
ChildrenType DefaultDescription
index-filestring "index.html", "index.htm" repeatable Filenames tried in order for directory requests. Returns 403 if none exist. Supplying any index-file children replaces the defaults entirely.
rootpath required Filesystem directory to serve. Path traversal outside root is blocked.
strip-prefixboolean #false optional Remove the matched location prefix before resolving the file path. With location "/assets/" and strip-prefix #true, a request for /assets/app.js maps to {root}/app.js.
try-filesstring… none optional Ordered list of candidate path templates to test before serving 404. The literal token {path} is substituted with the request URI path (after any strip-prefix); all other characters are copied verbatim. The first candidate that exists under root as a regular file is served. Common pattern: client-side router fallback to /index.htmltry-files "{path}" "{path}.html" "/index.html". When set, this directive bypasses the default directory-index resolution; candidates that resolve to a directory are skipped.
directory-listingboolean #false optional When the resolved path is a directory and no index-file matches, render an HTML listing of the directory contents (one row per entry, with size and mtime). Hidden files (names beginning with .) are excluded; directories sort before files, then alphabetical. A GET to a directory without a trailing slash is redirected with 301 so relative links resolve.
userdirstring none optional Per‑user mode (Unix only). When set, URLs of the form /~<user>/<rest> serve HOME/<userdir>/<rest> from the named user’s account. Mutually exclusive with root. See the example below.
userdir-allowliststring… none optional Restrict ~user resolution to the listed usernames. When set, every other request returns 404 even if the named user exists.
userdir-min-uidinteger 1000 optional Minimum UID that may be served via ~user. Keeps system accounts (root, daemon, …) off the public web even if they have a home directory and a public_html.
// Public file share with a browsable listing.
location "/files/" {
    static {
        root "/srv/share"
        directory-listing #true
    }
}

// Per-user homepages: /~alice/foo.html serves ~alice/public_html/foo.html.
location "/" {
    static {
        userdir "public_html"
        userdir-allowlist "alice" "bob"
    }
}

root may also be written as the handler’s positional argument: static "/var/www/assets". strip-prefix may be written as a property: static "/var/www/assets" strip-prefix=#true.

Handler: status

Serves a live server status page. The HTML page auto-refreshes every 10 seconds. No children are required.

location "/status" {
    status
}

Protect with a policy block to restrict visibility. The handler responds to any HTTP method; Accept: application/json returns a JSON object with all the same data.

HTML output shows:

  • Uptime and total / active request counts
  • Request rate: last 5 s, 1/5/15-minute rolling averages
  • Status code distribution (2xx / 3xx / 4xx / 5xx)
  • Latency histogram (6 buckets from <1 ms to ≥1 s)
  • Resident memory in MiB (Linux only)
  • Server version and process ID
  • Listener table: address, protocol, ACME domain list
  • Virtual host table: names, aliases, locations with handler types
// JSON response (Accept: application/json)
{
    "version":         "0.2.0",
    "pid":             12345,
    "uptime_secs":     3661,
    "requests_total":  98765,
    "requests_active": 3,
    "status":          { "2xx": 95000, "3xx": 1200, "4xx": 500, "5xx": 65 },
    "rates":           { "current_per_sec": 12.5, "avg_1min": 10.2 },
    "auth":            "pam:login"
}
Policy blocks

Overview

A policy block is a sequence of statements evaluated top to bottom. The first statement whose predicate matches fires its action and evaluation stops. If no statement matches, the request is denied with 403.

Statements are of the form action [predicate [values…]]. A statement with no predicate is a catch-all that always matches, so placing a bare allow or deny last acts as the default outcome.

policy {
    allow address "10.0.0.0/8"   // allow internal
    allow authenticated            // allow any logged-in user
    deny  code=403                 // catch-all deny
}

Where policy blocks appear

ContextSyntax Available predicates
server policy "name" { … } Named definition — no predicates evaluated here. See Named policies.
location policy { … } All predicates
listener (stream mode) policy { … } address, country only. Identity predicates are rejected at startup. Denied connections are closed silently; redirect is treated as deny.

Statements

Every statement begins with an action keyword. All actions are terminal: when a statement fires, no further statements in the block are evaluated.

StatementArguments / properties Description
allow[predicate] Permit the request and pass it to the handler.
apply "policy-name" Inline the rules of a named policy at this point. The named policy’s statements are spliced into the list in place of the apply; first-match semantics apply across the combined sequence. If none of the spliced rules fire, evaluation continues with the next statement after the apply.
deny [predicate] code=N (default 403) Reject the request with the given HTTP status code. Use deny code=401 to issue an HTTP Basic auth challenge when the location also has a basic-auth node. Auth predicates (authenticated, user, group) issue a 401 challenge automatically for anonymous users, making an explicit deny code=401 often unnecessary.
redirect [predicate] to="…" code=N (default 302) Return an HTTP redirect to the given URL or path. Not available in stream mode.

Predicates

A predicate is an optional condition that guards a statement. Without a predicate the statement is a catch-all that always fires. There are two syntactic forms:

  • Inline — the predicate is written as arguments directly on the statement node. Multiple values within one predicate are OR-combined: allow country US CA GB fires when the client is in any of those three countries.
  • Block — the predicate is a child block { } containing multiple predicate nodes separated by ; or newlines. All nodes inside the block must match (AND, evaluated left-to-right with short-circuit): allow { address "10.0.0.0/8"; authenticated } fires only when the client is on the internal network and is authenticated.
// Inline OR: any of these countries
allow country US CA GB

// Block AND: internal network AND authenticated
allow { address "10.0.0.0/8"; authenticated }

// not: negates a predicate (suppresses 401 challenge for auth predicates)
deny code=403 not country US CA
PredicateArgument(s) Supported inDescription
address CIDR or IP (one or more) location, stream Client IP address falls within any of the listed CIDR ranges or equals any of the listed addresses. IPv4-mapped IPv6 addresses (e.g. ::ffff:192.0.2.1) are normalised to plain IPv4 before comparison.
authenticated location only The request carries valid credentials. For anonymous requests, the predicate fails and automatically issues a 401 challenge (with the realm from basic-auth if present). Use not authenticated to match anonymous users without challenging.
country ISO 3166-1 alpha-2 code (one or more) location, stream Client country matches any of the listed codes. Requires server { geoip { db … } }. Private and reserved IP ranges are not in the database and never match; combine with address to allow them explicitly. See geoip.
group name (one or more) location only The authenticated user is a member of any of the listed groups. Group membership is resolved by the active auth back-end at authentication time. Issues a 401 challenge for anonymous users.
not predicate [values…] both Negates the given predicate. When a negated auth predicate (not authenticated, not user, not group) encounters an anonymous user, the 401 challenge is suppressed and the predicate matches (an anonymous user is trivially “not authenticated”).
user name (one or more) location only The authenticated username matches any of the listed names. Issues a 401 challenge for anonymous users.
Lazy evaluation Auth predicates (authenticated, user, group) invoke the auth back-end only when they are actually reached during evaluation. A statement that appears earlier in the block and fires first short-circuits the rest, so the auth back-end is never called. This makes rule order significant for performance as well as correctness: put cheap address checks before expensive auth checks.

Named policies

Any number of reusable policy blocks can be defined in the server block by giving each a name as its first argument. Named policies are not evaluated at definition time; they are inlined wherever apply "name" appears in a concrete policy block.

// Definition (server block)
server {
    policy geo-filter {
        deny code=403 not country US CA GB
    }
    policy require-auth {
        deny code=401 not authenticated
    }
    policy admin-only {
        allow group admin
        deny  code=403
    }
}

// Usage -- the three apply statements expand to six rules:
//   deny code=403 not country US CA GB
//   deny code=401 not authenticated
//   allow group admin
//   deny code=403
location "/admin/" {
    basic-auth realm=Admin
    policy {
        apply geo-filter
        apply require-auth
        apply admin-only
    }
    static { root "/var/www/admin" }
}

Because apply splices rules in place, the effective rule list is exactly what you would get by writing all the rules out by hand. Named policies compose without any special scoping: if none of a named policy’s rules fire, evaluation falls through to the next statement after the apply.

More examples

// Block CN/RU; require auth for everyone else
policy {
    deny  code=403 country CN RU
    deny  code=401 not authenticated
    allow
}

// Allow internal IPs unconditionally; require auth for external
policy {
    allow address "10.0.0.0/8"
    allow authenticated            // issues 401 for anonymous
}

// AND: only authenticated users FROM the internal network
policy {
    allow { address "10.0.0.0/8"; authenticated }
    deny  code=403
}

// Redirect unauthenticated users to a login page instead of 401
policy {
    allow authenticated
    redirect to="/login/"
}

// Stream proxy: restrict by IP (listener level, no auth predicates)
listener "tcp://[::]:5432" {
    proxy "db.internal:5432"
    policy {
        allow address "10.0.0.0/8"
        deny  code=403
    }
}
Reference

Response compression

aloha automatically compresses responses when the client sends an Accept-Encoding header that includes zstd, br (brotli), or gzip. Preference is zstd › br › gzip when more than one is offered, which gives the best ratio at comparable CPU cost for text payloads. There is no per-location configuration; compression is always active for eligible responses.

Compression is applied to text-based content types:

  • text/*
  • application/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: zstd    (or br, or gzip)
Vary: Accept-Encoding

Full example

// aloha.kdl

server {
    state-dir "/var/lib/aloha"
    user      aloha

    // Validate credentials via PAM (uses /etc/pam.d/login)
    auth pam { service login }

    // Reusable policy blocks
    policy internal-only {
        deny code=403 not address "10.0.0.0/8"
    }
    policy require-auth {
        deny code=401 not authenticated
    }
    policy require-admin {
        allow group admin
        deny  code=403
    }

    // Custom error pages
    error-page 403 path="/var/www/errors/403.html"
    error-page 401 html="<h1>Authentication Required</h1>"
}

// Plain HTTP -- required for ACME HTTP-01 challenges
listener {
    bind          "tcp://[::]:80"
    default-vhost example.com
}

// HTTPS with a Let's Encrypt certificate
listener {
    bind          "tcp://[::]:443"
    default-vhost example.com
    tls-acme {
        domain example.com www.example.com
        email  [email protected]
    }
}

// Encrypted tunnel to an internal database (stream mode)
listener {
    bind     "tcp://[::]:5433"
    tls-file cert="/etc/aloha/db-cert.pem" key="/etc/aloha/db-key.pem"
    proxy "db.internal:5432" {
        proxy-protocol v2
    }
    policy {
        allow address "10.0.0.0/8"
        deny  code=403
    }
}

vhost example.com {
    alias www.example.com

    // Status page -- internal network only
    location "/status" {
        policy { apply internal-only; allow }
        status
    }

    // Admin area -- internal AND authenticated admin group
    location "/admin/" {
        basic-auth realm=Admin
        policy {
            apply internal-only   // 403 if external
            apply require-auth    // 401 if unauthenticated
            apply require-admin   // 403 if not in admin group
        }
        request-headers {
            set X-Auth-User   "{username}"
            set X-Auth-Groups "{groups}"
        }
        proxy "http://127.0.0.1:3000"
    }

    // API -- inject client info, enforce security headers
    location "/api/" {
        request-headers {
            set    X-Client-IP       "{client_ip}"
            set    X-Forwarded-Proto "{scheme}"
            remove Authorization
        }
        response-headers {
            set    X-Frame-Options        DENY
            set    X-Content-Type-Options nosniff
            remove Server
        }
        proxy {
            upstream     "http://127.0.0.1:4000"
            strip-prefix #true
        }
    }

    // PHP application via FastCGI
    location "/app/" {
        request-headers { set X-Real-IP "{client_ip}" }
        fastcgi {
            socket "unix-stream:/run/php/fpm.sock"
            root   "/var/www/html"
            index  index.php
        }
    }

    // Old URL redirect
    location "/old/" {
        redirect { to "/new/"; code 301 }
    }

    // Static files
    location "/" {
        static {
            root       "/var/www/example.com"
            index-file index.html
        }
    }
}

// Wildcard subdomain -- regex match
vhost ".+\.example\.com" regex=#true {
    location "/" {
        static { root "/var/www/wildcard" }
    }
}