Skip to content

Configuration

Three things, in the order you meet them: your app’s own settings (anole.config), .env files, and the two modes Anole itself has, environment (development or production) and deployment (what sits in front of the server). A mode only fills what you left unset: a value you write in code, JSON or a variable is never changed, and everything a mode changed is printed once at startup.

Declare a struct and load it:

const Config = struct {
database_url: anole.Secret, // required: DATABASE_URL
port: u16 = 3000,
stripe_key: ?anole.Secret = null,
request_timeout: anole.Duration = .fromSeconds(30),
cache: struct { ttl: anole.Duration = .fromSeconds(60) } = .{},
pub const development = .{ .cache = .{ .ttl = anole.Duration.fromSeconds(1) } };
};
pub fn main(init: std.process.Init) !void {
const cfg = try anole.config.load(Config, init, .{});
var app = try anole.App.init(init.gpa, .{ .timeouts = .{ .request = cfg.request_timeout } });
defer app.deinit();
state.db = try app.postgres(.{ .url = cfg.database_url.expose() });
try app.listen(":3000");
}

load reads, lowest first: the field defaults, the per-environment defaults, Options.file, the .env files and the process environment. It returns error.InvalidConfig after logging every problem at once (try in main then exits with status 1), and never puts a value in a message. Strings live in init.arena, for the life of the process.

Field types: bool, integers, floats, enums, []const u8, anole.Secret, anole.Duration, a []const slice of those (comma-separated in .env and the environment, an array in JSON), nested structs, and ?T of any. Booleans accept true/false/yes/no/on/off/1/0.

Names: the field path joined by _ and upper-cased, with Options.prefix in front: database_url is DATABASE_URL; db.port is DB_PORT (APP_DB_PORT with .prefix = "APP_") and "db": {"port": ...} in JSON or port in the [db] section of an INI file. Two fields that map to one variable are a compile error. Unknown keys in a JSON or INI file are problems with a “did you mean” hint; unknown variables are ignored (they hold other programs’ settings).

anole.Duration is written as a string with units in a file or variable: "30s", "250ms", "1m30s", "1h". A bare number is refused, because 30 could be seconds or milliseconds. In code, .fromSeconds(30).

anole.Secret wraps a string that never prints: {f} writes [REDACTED], {s} does not compile, {any} shows only an address and a length, and the JSON Lines logger redacts it wherever it sits in a logged value. Read it with .expose(), so every use is easy to find. It guards against accidental logging, not against code that wants the value.

Per-environment defaults: pub const development = .{...} and pub const production = .{...} on the struct (or any nested struct) override field defaults in that environment only; a nested struct literal overrides some of its fields. They are defaults: the file, .env and the environment still win. A name that matches no field is a compile error.

Options has prefix, file (a .json or .ini file that must exist), environment (null: the resolved one), env_files and dir.

A .env file is KEY=value per line:

  • keys match [A-Za-z_][A-Za-z0-9_]*; export KEY=value is accepted;
  • # starts a comment at the start of a line, or after whitespace in an unquoted value;
  • "double quotes" unescape \n \t \r \" \\ and may span lines (paste a PEM key straight in); 'single quotes' are literal and one line;
  • KEY= is the empty string; there is no ${VAR} expansion;
  • a line without =, a bad name, an unterminated quote, a key twice in one file or a file over 1 MiB is a problem; every problem is reported, and any problem fails the whole read (nothing is applied).

Which files, and when. In development, from the working directory, the first file that has a key wins: .env.development.local, .env.local, .env.development, .env. In production and in tests, none, unless you name them (ServerOptions.env_files, config.Options.env_files; &.{} reads none). Missing files are skipped. A production process reads the real environment; a development .env copied into an image must not supply a production secret. Ignore .env and .env*.local in version control and commit a .env.example.

The values go into a read-only overlay that Anole’s environment reads consult after the real environment: DATABASE_URL, PG*, REDIS_URL, MONGODB_URI, OTEL_*, ANOLE_*, PORT. They are never put in the process environment (a child process does not see them; pass what it needs explicitly). anole.env.get("NAME") reads the same way. ANOLE_ENV in a .env file is ignored, because it decides whether .env is read; the startup line says so.

Variable Read when Effect From .env too
ANOLE_ENV environment is null development or production; anything else refuses to start no
ANOLE_DEPLOYMENT deployment is null standalone, behind_proxy, anole_lb, platform, lambda; else refuses to start yes
ANOLE_PROXY_TRUSTED proxy.trusted is null comma-separated entries as proxy.trusted; "" trusts nobody yes
PORT deployment platform, for listeners whose port is null that port yes
DATABASE_URL, PG*, REDIS_URL, MONGODB_URI the connectors as before yes
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME tracing as before yes
NATS_URL, KAFKA_BOOTSTRAP_SERVERS (then KAFKA_URL) the brokers as before yes
SMTP_URL, SMTP_FROM anole.smtp (docs/email.md) Mailpit’s smtp://127.0.0.1:1025 outside production; in production a missing SMTP_URL stops app.smtp yes
ANOLE_LAMBDA_INVOKE_MODE deployment lambda, lambda.invoke_mode is null buffered or response_stream; else refuses to start yes
AWS_LAMBDA_RUNTIME_API deployment lambda, lambda.runtime_api is null the Runtime API (host:port); Lambda and the Runtime Interface Emulator set it. Under standalone by default, a hint never
AWS_LAMBDA_HTTP_ENDPOINT deployment lambda, lambda.runtime_api is null a Lambda web function: one HTTP/1.1 listener there (wins over AWS_LAMBDA_RUNTIME_API). Under standalone by default, a hint never
AWS_LAMBDA_MAX_CONCURRENCY deployment lambda, lambda.concurrency is null invocations at once (1 to 4096; Lambda Managed Instances); else refuses to start never
LISTEN_PID, LISTEN_FDS, LISTEN_FDNAMES, NOTIFY_SOCKET, ANOLE_READY_FD, ANOLE_H3_SECRET_FD process control from systemd or a predecessor as before never

Values are case-sensitive and lower case (ANOLE_DEPLOYMENT=behind_proxy). They apply in Zig and in the C ABI alike. No other option has a variable: an option you want operations to tune goes in your own Config and is passed to Anole, as in section 1.

For Anole’s own settings, lowest first: the built-in default, the mode’s default, the .env files, the process environment, then the explicit option (the Zig field or the JSON key). “Explicit” is exact: a mode or variable fills a field only when it is null, so a value you wrote that happens to equal the default is still yours.

For your app’s typed config, lowest first: the field default, the per-environment default, the config file, the .env files, the process environment, then code.

ServerOptions.environment is development or production. Null resolves as: ANOLE_ENV (the real environment only), else development in a Debug build and production in any release build, so zig build run is development and a deployed release build is production. Tests and the C ABI fall back to production, which keeps them deterministic (no .env, no detail in 500s).

development production
.env files read none
error_detail on off
Startup the environment line the environment line; a Debug build warns

error_detail (null: on in development) makes a 500 for an error with no status of its own name the error (Internal Server Error: ConnectionRefused) instead of the bare phrase. Errors that map to a status (error.NotFound) are unaffected. app.environment() and anole.env.environment() give the resolved value.

ServerOptions.deployment says what sits in front. Null resolves from ANOLE_DEPLOYMENT, else the build’s default deployment, else standalone; Anole never guesses from the platform (it prints a hint when it sees Cloud Run’s, Heroku’s, Fly.io’s, Render’s or AWS Lambda’s variables).

The build’s default is the -Ddefault-deployment option of Anole’s dependency (standalone unless set). zig build lambda (an addApp with .lambda = .{}) builds its bootstrap with lambda, so the same main serves Lambda invocations there and listens everywhere else; the deployment line then says (lambda build). ANOLE_DEPLOYMENT still wins over it.

Setting standalone behind_proxy anole_lb platform
proxy.trusted (after ANOLE_PROXY_TRUSTED) none: no gate loopback, private loopback, private loopback, private
X-Forwarded-* read only with trusted_hops (as 0.2) from trusted peers from trusted peers (after the PROXY header) from trusted peers
proxy_protocol on each TCP listener off off on off
listener address 127.0.0.1 127.0.0.1 "" (every interface) ""
listener port 8080 8080 8080 $PORT, else 8080
listenTls adds HTTP/3 yes no yes no
warning for an untrusted peer no once once once

Only null fields are filled: set .address = "127.0.0.1" under platform and it stays. proxy.trusted takes addresses, CIDR networks, and the names loopback, private (RFC 1918 and fc00::/7) and link_local. The client is the rightmost X-Forwarded-For address that is not trusted, so a client cannot forge the address by sending its own header; from a peer that is not trusted the headers are ignored and the first such request logs one warning (a public address: “firewall this port or add your proxy”; a private one: “add it to proxy.trusted”). With proxy_protocol on and no proxy_protocol_from, only proxy.trusted peers may connect; an empty trusted with PROXY on is ProxyProtocolNoPeers.

standalone is the default and changes nothing from 0.2.

behind_proxy: nginx, Caddy, Traefik or a cloud L7 balancer sets X-Forwarded-For and X-Forwarded-Proto. The listener stays on 127.0.0.1 (a proxy on the same machine); a proxy in another container or host needs address = "", which the startup line reminds you of.

location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

Caddy sets both headers by default. Keep timeouts.keepalive (75 s by default) above the balancer’s idle timeout (nginx and AWS ALB: 60 s), or the balancer can reuse a connection Anole just closed and see a 502.

anole_lb: an L4 balancer in front sends a PROXY protocol v2 header on every connection (anole-lb when it ships; HAProxy mode tcp with send-proxy-v2; AWS NLB with proxy protocol v2). Every TCP listener then expects the header unless it sets proxy_protocol = false, so an admin or metrics port scraped directly must say so.

platform: Render, Heroku, Fly.io, Cloud Run and Railway run the app behind their own proxy and name the port in PORT. Leave the port to it (app.run() or .http = .{}): a port you name in code (listen(":3000")) wins, with a startup warning when PORT differs. TLS is the platform’s, so listenTls adds no HTTP/3.

lambda: AWS Lambda (AWS Lambda). There is no inbound connection to accept, so app.run() (and listen, listenTls) polls the Lambda Runtime API at AWS_LAMBDA_RUNTIME_API and serves each invocation through the router. When AWS_LAMBDA_HTTP_ENDPOINT is set instead (a Lambda web function), Anole opens one HTTP/1.1 listener there and nothing else. This is the one place where a mode overrides an explicit option: every listener the options name (http, https, listeners, http3, listen(":3000"), listenTls, raw TCP and UDP) is set aside, and one startup line says which. It applies to null fields like the other modes, except the two rows marked “always”.

Setting lambda (Runtime API) lambda (web function: AWS_LAMBDA_HTTP_ENDPOINT)
proxy.trusted (after ANOLE_PROXY_TRUSTED) none: the client address comes from the event loopback, private (Lambda’s local proxy)
X-Forwarded-* read none by default; with proxy.trusted set, the walk starts at the event’s client address from trusted peers, as platform
proxy_protocol n/a (no listener) off
listener address / port always: every listener named is set aside always: one HTTP/1.1 listener at AWS_LAMBDA_HTTP_ENDPOINT, the others set aside
h2c on that listener n/a off
listenTls adds HTTP/3 no no
warning for an untrusted peer no no
executors when .auto 1 at concurrency 1; the CPU count above that the CPU count
invocations at once lambda.concurrency, else AWS_LAMBDA_MAX_CONCURRENCY, else 1 the platform’s
timeouts.request the smaller of it and the invocation’s deadline minus lambda.deadline_margin as configured
background work between invocations none started; spans flushed before the next invocation normal
shutdown SIGTERM (through a no-op extension, lambda.sigterm): stop polling, flush, return within lambda.shutdown_timeout SIGTERM: drain within min(timeouts.shutdown, 900 ms)
req.id Lambda-Runtime-Aws-Request-Id lambda-web-request-id
environment unchanged by the mode unchanged

lambda.runtime_api set in the options wins over both variables. The platform’s variables (AWS_LAMBDA_*) are read from the real environment only: a .env line never makes a development run poll a Runtime API. ANOLE_LAMBDA_INVOKE_MODE may come from .env. The other lambda options (invoke_mode, concurrency, event_path, base_path, keep_stage, context_headers, deadline_margin, idle_reset, sigterm, shutdown_timeout, flush_timeout) are described in lambda.md.

A build without the adapter (-Dlambda=false) refuses lambda with LambdaNotBuilt; lambda with neither variable nor lambda.runtime_api refuses with LambdaRuntimeApiMissing; a variable that is not host:port, an ANOLE_LAMBDA_INVOKE_MODE that is not buffered or response_stream, and an AWS_LAMBDA_MAX_CONCURRENCY outside 1 to 4096 refuse with InvalidEnvironmentVariable.

Each line is logged once at info (scope anole). A warning is warn.

environment development (Debug build): read .env (3 values), .env.local (1 value); 500 answers name the error
environment production (ANOLE_ENV)
environment production (release build)
deployment behind_proxy (ANOLE_DEPLOYMENT): X-Forwarded-For and X-Forwarded-Proto from 127.0.0.0/8, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 (proxy.trusted); 127.0.0.1 only: a proxy in another container or on another host needs address ""
deployment platform (ANOLE_DEPLOYMENT): X-Forwarded-For and X-Forwarded-Proto from ...; every interface (address ""); port 41234 from PORT; no HTTP/3 from listenTls (listen_tls_http3)
proxy.trusted from ANOLE_PROXY_TRUSTED: 10.0.0.0/8, 203.0.113.7/32
this looks like Cloud Run (K_SERVICE is set): set ANOLE_DEPLOYMENT=platform to listen on $PORT and trust its proxy
AWS_LAMBDA_RUNTIME_API is set: to serve Lambda invocations, set ANOLE_DEPLOYMENT=lambda or build with zig build lambda (behind the Lambda Web Adapter, leave it as is)
AWS_LAMBDA_HTTP_ENDPOINT is set: set ANOLE_DEPLOYMENT=lambda to listen there
deployment lambda (lambda build): invocations from the Lambda Runtime API at 127.0.0.1:9001, 1 at a time on 1 executor; buffered answers; other events to POST /_lambda/event; SIGTERM through a no-op extension; ready in 3.4 ms
deployment lambda (ANOLE_DEPLOYMENT): a Lambda web function: HTTP/1.1 on 127.0.0.1:9002 from AWS_LAMBDA_HTTP_ENDPOINT; X-Forwarded-For and X-Forwarded-Proto from 127.0.0.0/8, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 (proxy.trusted); drain at most 900 ms
deployment lambda: no listener opens here, so listen(":3000") and https are set aside; requests come from Lambda
ANOLE_ENV in .env.local is not used: it decides whether .env files are read, so set it in the environment

A Debug build that runs as production warns: “a Debug build running as production: Debug builds are several times slower; build with -Doptimize=ReleaseSafe”. Nothing in these lines is a .env value, or any variable’s value other than ANOLE_*, PORT and the trusted networks.

The same keys exist in the JSON configuration (environment, deployment, error_detail, proxy.trusted, nullable listener fields), and anole_dotenv_read lets a binding read .env files with this page’s dialect and order. See The C ABI.