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.
1. Your app’s settings
Section titled “1. Your app’s settings”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.
2. .env files
Section titled “2. .env files”A .env file is KEY=value per line:
- keys match
[A-Za-z_][A-Za-z0-9_]*;export KEY=valueis 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.
3. Anole’s own variables
Section titled “3. Anole’s own variables”| 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.
4. Precedence
Section titled “4. Precedence”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.
5. environment
Section titled “5. environment”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.
6. deployment
Section titled “6. deployment”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.
7. What is printed at startup
Section titled “7. What is printed at startup”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 errorenvironment 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/32this looks like Cloud Run (K_SERVICE is set): set ANOLE_DEPLOYMENT=platform to listen on $PORT and trust its proxyAWS_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 theredeployment 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 msdeployment 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 msdeployment lambda: no listener opens here, so listen(":3000") and https are set aside; requests come from LambdaANOLE_ENV in .env.local is not used: it decides whether .env files are read, so set it in the environmentA 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 C ABI
Section titled “The C ABI”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.