Configuration
A single reference for Soli environment variables: app environment, database, sessions, jobs, cache, S3, deployment, and development tooling.
How Environment Files Load
Soli reads environment variables from the process, then loads .env, then loads .env.{APP_ENV} when APP_ENV is set. Environment-specific files override .env, except variables listed in SOLI_PROTECT_ENV.
APP_ENV=development
SOLIDB_HOST=http://localhost:6745
SOLIDB_DATABASE=myapp_development
Keys must match [A-Za-z_][A-Za-z0-9_]*. Values cannot contain \0, \r, or \n — entries with control characters are skipped at load time with a warning on stderr. This avoids HTTP-header-split / log-injection vectors when an env value flows downstream into responses or structured logs.
The files are read from the app folder passed to soli serve. When serving a bundle (soli serve app.soli), they are read from the directory containing the .soli file — dotfiles are never included in a bundle, so ship the .env alongside it.
Application Environment
| Variable | Purpose | Default |
|---|---|---|
SOLI_DB_ADAPTER | Single-connection backend when config/database.toml is absent: solidb (default), postgres, mysql, or sqlite (document subset). Multi-DB: Multiple Databases. Adapter notes: PostgreSQL, MySQL, SQLite. | solidb |
DATABASE_URL | Connection URL for SQL adapters (e.g. postgres://user:pass@localhost:5432/myapp, or a path such as sqlite://db/app.sqlite3). Required when SOLI_DB_ADAPTER is postgres, mysql, or sqlite. TOML named connections use url = per entry. | unset |
SOLI_DB_POOL_SIZE | Default SQL pool size (single-connection mode). TOML pool = N overrides per connection. | 10 |
APP_ENV | Selects .env.{APP_ENV} and marks test mode for features that need it. | unset |
SOLI_PROTECT_ENV | Comma-separated variable names that .env.{APP_ENV} must not override. Mostly used by the test runner. | unset |
Server And Development
| Variable | Purpose | Default |
|---|---|---|
SOLI_HOST | IP address the server binds to. Set 127.0.0.1 to keep a dev server off the LAN (only local processes can connect); the default listens on all interfaces. An invalid value is a startup error. | 0.0.0.0 |
SOLI_WORKERS | Number of request-handling worker threads. Each worker is a full interpreter copy (its own parsed app + builtins), so this is the primary lever on baseline RSS. Defaults to the number of CPU cores; when APP_ENV=production (or prod) and this is unset, defaults to 2 so a many-core box does not open one interpreter per core. Set explicitly (or pass --workers N) for more throughput. | CPU cores; 2 in production |
SOLI_DEFAULT_LOCALE | The locale a request starts from when nothing else decides, and the one a translation lookup falls back to when the active locale has no entry. Per request the order is session locale, then a locale cookie, then Accept-Language matched against the locales in config/locales/, then this. | en |
SOLI_WS_WORKERS | Worker threads reserved exclusively for realtime (WebSocket/LiveView) events, so a burst of them can’t starve HTTP and a slow handler can’t delay presence/broadcasts. The reservation costs a whole HTTP worker, so by default it only applies once the pool has 4 or more workers; below that every worker drains both channels and realtime shares the pool. Set it explicitly to force the split at any size (1 on a 2-worker pool leaves 1 HTTP worker), or 0 to disable it entirely. Always clamped so at least one HTTP worker remains. The startup line reports the resulting layout. Each EUI session is pinned to one of the realtime workers (by session id), so its renders always run on the thread that holds its kept subtrees and the application’s own per-worker objects. | a quarter of the pool (at least 1) when workers ≥ 4, else 0 |
SOLI_REQUEST_LOG | Enables per-request [LOG] METHOD PATH - STATUS (Xms) lines on stdout when set to 1 or true. Always on under --dev. Alias for SOLI_LOG=access. | false |
SOLI_LOG | Comma-separated production log channels: access (the request line), query (AQL queries with binds + duration), http (outgoing HTTP.* calls), timing (middleware/view/phase breakdown), or all. Each detail channel prints an indented block under the access line and implies access. Surfaces the rich per-request diagnostics — otherwise gated to --dev — without paying for full dev mode. | unset |
SOLI_LOG_FORMAT | Shape of production request (and error) logs: text (default multi-line human output) or json (one NDJSON object per event — ship to Loki, CloudWatch, Datadog, …). Detail channels become nested arrays on the same object; secret-bearing values stay redacted. | text |
SOLI_SLOW_REQUEST_MS | Slow-request threshold in milliseconds. A request whose total time (queue wait + handler) reaches it prints a full [SLOW] detail block — every SOLI_LOG channel plus the queue-wait split — while faster requests stay silent. Composes with SOLI_LOG. | unset |
SOLI_OTEL | Set to 1/true/yes to enable OpenTelemetry tracing. Reuses the same per-request span tree the dev-bar flamegraph builds. Honours inbound W3C traceparent, echoes it on the response, and exports spans over OTLP/HTTP JSON. With no endpoint set, defaults to http://127.0.0.1:4318/v1/traces. | unset |
OTEL_EXPORTER_OTLP_ENDPOINT | OTLP collector base URL (e.g. http://otel-collector:4318). Enables tracing even when SOLI_OTEL is unset. Soli appends /v1/traces unless the value already ends with it. | unset |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | Full traces URL override (takes precedence over OTEL_EXPORTER_OTLP_ENDPOINT). | unset |
OTEL_SERVICE_NAME | service.name resource attribute on exported spans. | soli |
OTEL_RESOURCE_ATTRIBUTES | Extra resource attributes as comma-separated key=value pairs (e.g. deployment.environment=prod). | unset |
OTEL_SDK_DISABLED | Set to true to force tracing off regardless of the other OTEL vars. | unset |
SOLI_DB_POOL_IDLE_SECS | Idle lifetime (seconds) of pooled SoliDB connections. A retired idle connection means the next query pays a fresh DNS + TCP (+ TLS) connect mid-request. Two defaults, because the two clients can afford different windows: the shared client holds a connection for 90s (its reactor runs continuously, so it sees the server close one and drops it), while a per-worker pool holds one for 25s — a worker's reactor only runs during a query, so between requests nothing notices the peer closing an idle connection and the pool must retire it first. SoliDB closes idle keep-alives after 30s. Setting this overrides both; keep it below the idle-close of whatever is on the other end. | 90 shared, 25 per-worker |
SOLI_DB_POOL_MAX_IDLE | Max idle SoliDB connections kept per host by the shared internal HTTP client. Per-worker DB clients hold one hot connection each and are unaffected; this sizes the pool for the paths that still share a client (async contexts, keep-warm). | 8 |
SOLI_DB_SHARED_REACTOR | Set to 1 to drive DB queries on the server's shared tokio runtime instead of each worker's own reactor. Escape hatch for the pre-worker-reactor behavior — the default is faster (readiness is polled by the thread that waits on it) and creates no TCP churn. | unset |
SOLI_DB_KEEP_WARM | Set to 0 to disable the periodic keep-warm ping that holds a live SoliDB connection in the pool between sparse requests. Only spawned when a DB is configured (SOLIDB_HOST or credentials set). | enabled |
SOLI_NAV | Controls instant-navigation injection (link clicks fetch + swap <body> in place instead of a full page load). Set off, false, 0, or no to disable and fall back to plain hover prefetch. Set morph to patch the body into the new page instead of replacing it, on every page (a page can still say <meta name="soli-nav" content="swap">; see Instant Navigation). Read once per process; changing it needs a restart. | enabled (swap) |
SOLI_PREFETCH | Controls hover prefetch injection (and hover warming inside instant navigation). Set off, false, 0, or no to disable. Read once per process; changing it needs a restart. | enabled |
SOLI_PREFETCH_TTL | Freshness window (seconds, clamped 1–300) for a prefetched HTML response, so the click reuses it without a revalidation round-trip — keeps prefetch working behind a CDN. Read once per process; changing it needs a restart. | 30 |
SOLI_DEFAULT_URL_HOST | Host used by *_url route helpers outside an active request. | unset |
SOLI_DEFAULT_URL_SCHEME | Scheme used with SOLI_DEFAULT_URL_HOST. | http |
SOLI_DEV_REPL_ALLOW_REMOTE | Allows the token-protected dev error-page REPL from non-loopback clients when set to 1, true, or yes. Requires SOLI_DEV_REPL_SECRET (SEC-051) — the server refuses to start otherwise. | false |
SOLI_DEV_REPL_SECRET | Pins the /__dev/repl token to an explicit shared secret instead of an auto-generated UUID. Required when SOLI_DEV_REPL_ALLOW_REMOTE=1 so the credential is never embedded in dev-mode HTML error pages. | unset |
SOLI_OPENAPI | Set to 1/true to expose an OpenAPI 3 spec at /openapi.json (from the routes) and a Scalar API-reference UI at /openapi. Opt-in (404 otherwise); served in every environment once on. | unset |
SOLI_OPENAPI_TITLE | Title of the generated OpenAPI document. | Soli API |
SOLI_SHUTDOWN_GRACE_SECS | How long a SIGTERM/SIGINT shutdown waits for in-flight requests to finish before exiting anyway. See Health checks and graceful shutdown. 0 exits immediately. | 25 |
SOLI_TRACE_BOOT | Prints boot timing trace when set. | unset |
Health checks and graceful shutdown
Two endpoints let an orchestrator or load balancer see the server's lifecycle. Both are plain text, need no authentication, and are always available — there is nothing to enable.
| Endpoint | Meaning | Answers |
|---|---|---|
GET /_health | Liveness — is this process alive? | 200 ok for as long as the server runs, including while it shuts down |
GET /_ready | Readiness — should traffic be routed here right now? | 200 ready, or 503 starting before workers finish booting, or 503 draining during shutdown |
The distinction matters. A shutting-down process is perfectly healthy — it just does not want new work. If /_health failed during shutdown, an orchestrator would restart a container that was already exiting cleanly. Point liveness probes at /_health and readiness probes at /_ready.
What happens on SIGTERM
On SIGTERM or SIGINT the server drains rather than cutting requests off:
/_readystarts answering503 draining, so the load balancer stops routing here.- New requests get
503 Server shutting downwithConnection: close. Probes still answer. - Requests already in flight run to completion and return their real response.
- Once the last one finishes — or
SOLI_SHUTDOWN_GRACE_SECSelapses — the process exits0.
A second signal skips the wait and exits immediately. The default 25s sits just under Kubernetes' default 30s terminationGracePeriodSeconds, so the process exits on its own rather than being SIGKILLed. If you raise one, raise both.
# Kubernetes
livenessProbe:
httpGet: { path: /_health, port: 3000 }
readinessProbe:
httpGet: { path: /_ready, port: 3000 }
terminationGracePeriodSeconds: 30 # must exceed SOLI_SHUTDOWN_GRACE_SECS
Parsing And Security Limits
| Variable | Purpose | Default |
|---|---|---|
SOLI_DEFLATE_MAX_BYTES | Maximum decompressed output (in bytes) that Deflate.inflate produces before it fails closed. A few-KB highly-repetitive raw-DEFLATE stream can inflate to many GB — a decompression bomb — and the SAML HTTP-Redirect binding feeds Deflate.inflate unauthenticated SAMLRequest/SAMLResponse payloads. Raise it only for legitimately large payloads. | 67108864 (64 MiB) |
Bundle protection
Used when serving an encrypted / protected .soli bundle (see Encrypted & Protected Bundles). Read at both soli build --encrypt/--protect and soli serve app.soli, and may live in the .env next to the bundle. Distinct from SOLI_ENCRYPTION_KEY, which encrypts model fields.
| Variable | Purpose | Default |
|---|---|---|
SOLI_BUNDLE_KEY | The bundle AES key material itself. Simplest option; also handy for local testing. | unset |
SOLI_BUNDLE_AUTH_URL | URL of a key server. Soli issues a GET; the response body (≤ 4 KB, trimmed) is the key material. Revoke the entry to lock the app out. Used only when SOLI_BUNDLE_KEY is unset. | unset |
SOLI_BUNDLE_API_KEY | Sent as the x-api-key header on the SOLI_BUNDLE_AUTH_URL request — this host's identity to the key server. | unset |
SOLI_BUNDLE_ALLOW_DISK | Set to 1 to allow a decrypted bundle to extract to the temp dir when /dev/shm is unavailable. Without it, such a boot is refused rather than writing plaintext to persistent disk. | unset |
Production logging (SOLI_LOG)
The AQL query log, the outgoing HTTP log, and the middleware/view/phase timing breakdown normally only feed the dev bar under --dev. SOLI_LOG turns those same channels on in production and prints them to stdout as an indented block under each request's access line — so you can debug a slow or failing route on a live server without redeploying in dev mode (which would also disable the VM, enable hot-reload, and inject the bar).
Set SOLI_LOG_FORMAT=json for one NDJSON object per request (and per production error on stderr) so a log shipper can ingest them. Detail channels become nested arrays on the same object; trace_id / span_id appear when OpenTelemetry is on. Full operator guide:
Observability.
# Just the access line (same as SOLI_REQUEST_LOG=1)
SOLI_LOG=access soli serve
# Queries + outgoing HTTP for the whole app
SOLI_LOG=query,http soli serve
# Everything
SOLI_LOG=all soli serve
A request with SOLI_LOG=query,http,timing prints:
[LOG] GET /posts - 200 (12.480ms)
db: 2 queries (8.210ms)
(5.110ms) FOR p IN posts FILTER p.published == @v0 RETURN p binds={"v0":true}
(3.100ms) FOR c IN comments FILTER c.post_id == @v0 RETURN c binds={"v0":"abc"}
http: 1 call (2.000ms)
(2.000ms) GET https://api.example.com/feed -> 200
timing:
middleware auth (0.420ms)
view posts/index (3.050ms)
view posts/_card (1.200ms)
The whole block is written with a single println! so concurrent worker threads never interleave their output. Bind variables and HTTP URLs are scrubbed of secret-bearing values before they reach the log.
Slow-request logging (SOLI_SLOW_REQUEST_MS)
SOLI_LOG=all prints a block for every request — too noisy to leave on in production. SOLI_SLOW_REQUEST_MS instead emits the full detail block only for requests whose total time (queue wait + handler) crosses the threshold, and nothing at all for fast ones:
# Log a full breakdown only for requests slower than 100ms
SOLI_SLOW_REQUEST_MS=100 soli serve
[SLOW] GET /gather/map - 200 (412.480ms + 0.320ms queue)
db: 3 queries (398.210ms)
(395.110ms) FOR p IN pins FILTER p.board == @v0 RETURN p binds={"v0":"x"}
...
timing:
view gather/map (10.050ms)
The access line shows handler time plus the time the request waited in the worker queue before being picked up, so a request stuck behind a busy worker is distinguishable from a genuinely slow handler. It composes with SOLI_LOG: explicitly requested channels still print for every request; the threshold adds the [SLOW] block on top.
OpenTelemetry tracing
Production can join a distributed trace without a heavyweight SDK: W3C traceparent, OTLP/HTTP JSON export of the same span tree the dev-bar flamegraph builds. Enable with SOLI_OTEL=1 or any OTEL_EXPORTER_OTLP_* endpoint. Operator guide (metrics, log fields, span kinds, limits):
Observability.
SOLI_OTEL=1 soli serve
# or
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 OTEL_SERVICE_NAME=myapp soli serve
DB connection keep-warm
Pooled SoliDB connections idle out after SOLI_DB_POOL_IDLE_SECS. On a quiet server, a request arriving after a longer gap used to pay a fresh DNS + TCP (+ TLS for remote hosts) connect mid-request — visible as intermittent latency spikes. When a DB is configured, soli serve now runs a periodic read-only RETURN 1 ping that keeps a live connection pooled at all times (and pre-warms the model DB at boot). Disable it with SOLI_DB_KEEP_WARM=0.
The ping runs on its own thread, and with one connection pool per worker it can only refresh its own — so a worker's connection is instead kept inside the 25s idle window described above, short enough that the pool retires it before SoliDB's 30s idle close. A worker idle longer than that pays one reconnect on its next query, which is the fresh-connect cost the ping avoids elsewhere.
Keeping memory low
soli serve runs a pool of worker threads in one process, and each worker holds its own copy of the parsed app plus the full builtin surface (Rc-based values can’t be shared across threads). So baseline RSS scales with the worker count and — for apps with lots of code or large in-memory data such as i18n locale tables — with the size of that app.
Within a worker the builtin surface is built once and shared as the enclosing scope of the interpreter’s globals, the template engine’s environment and the view helpers’ closure; those three used to be separate full registries. Don’t expect that to show up in your RSS — a registry turns out to be a few hundred KB, so removing two of them per thread sits below the noise of an ordinary measurement. The levers below are what actually move the number.
Measuring it at all takes more care than it looks. ps -o rss= cannot separate a process’s own heap from the file-backed pages every soli process shares; Pss_Anon from /proc/<pid>/smaps_rollup can, but counts only resident pages, so on a swapping machine a process reads as smaller the more pressure the box is under — add SwapPss. Transparent huge pages move anonymous RSS by tens of MiB between identical runs, so take a median of several. And compare two binaries by alternating them in one session, never by lining up two sweeps taken minutes apart: machine drift lands entirely on whichever ran later. scripts/mem-probe.sh in the repository does all of this, --ab included.
The levers, cheapest first:
| Lever | Effect |
|---|---|
SOLI_WORKERS=N | The biggest one — each worker is a full interpreter copy. With APP_ENV=production, the default is already 2 (not one-per-core). Raise it for throughput, or set 1 for a low-traffic service. Note the throughput floor: a worker blocks for the whole of each database round-trip, so a DB-backed route tops out near workers × (1 / query latency) — roughly 11k req/s per worker against a loopback SoliDB. Routes that never touch the DB are unaffected (a single worker serves >140k req/s). |
SOLI_JOB_WORKERS=1 (or 0) | The job worker pool is a second set of full interpreters. It defaults to 1; 0 disables the job engine in this process (run soli jobs separately). |
SOLI_JOB_VIEW_HELPERS=0 | Drops view helpers (incl. i18n locale tables) from every job interpreter when jobs don’t render helper-using templates. |
| Slim Cargo features | Build only the subsystems you need (see below). Omitting SQL clients and PASETO shrinks the binary and the code pages mapped into every worker. |
MIMALLOC_PURGE_DELAY=0 | mimalloc returns freed pages to the OS promptly instead of after its default delay — trims the RSS left over from the one-time boot-parse churn. Read by the allocator at startup, so set it in the environment before launch. Trade-off: a few more madvise/decommit syscalls under churny allocation. |
| Fewer/lazier locales | If most of an app’s per-worker memory is i18n tables, load only the locales you serve (or move them to config/locales/*.yml, which the framework loads once process-wide into a shared store rather than per-worker). |
Slim binary (Cargo features)
cargo install / CI use the default feature set so published binaries match a full product build. Optional subsystems can be dropped at compile time when you build from source:
| Feature | Default | What it pulls in |
|---|---|---|
embedding | on | Vector / embedding helpers |
llm | on | llm_generate (OpenAI-compatible chat) |
codegraph | on | soli graph build on non-Soli repos (tree-sitter + grammars) |
paseto | on | Paseto class (pasetors crate) |
postgres | on | PostgreSQL document adapter + client pool |
mysql | on | MySQL / MariaDB document adapter + client pool |
sqlite | on | SQLite document adapter (bundled client — no system library needed) |
eui | on | EUI components — router_eui, eui_capabilities, the /_eui session endpoint |
sql | off | Alias for postgres + mysql + sqlite |
solidb-driver | on | Native SoliDB TCP driver (MessagePack over pooled TCP). Compiled in by default; a server uses it only with SOLI_DB_DRIVER=1 |
eui-desktop | off | soli desktop build --eui — the native EUI window |
full | off | Alias for the default set (it used to add solidb-driver, which is now in it) |
SoliDB (HTTP) always stays linked. SoliDB-only install without PASETO or SQL clients:
cargo install --path . --locked --no-default-features \
--features embedding,llm,codegraph
Postgres only (no MySQL or SQLite client, no PASETO):
cargo install --path . --locked --no-default-features \
--features embedding,llm,codegraph,postgres
SQLite only — the client is compiled in, so the host needs no libsqlite3:
cargo install --path . --locked --no-default-features \
--features embedding,llm,codegraph,sqlite
If the binary was built without an adapter and you set SOLI_DB_ADAPTER=postgres
(or a database.toml entry for it), boot fails with a rebuild hint rather than a
missing symbol. The Paseto class is simply not registered when the paseto feature is off.
The boot process also builds one extra interpreter to register the shared route/model/controller/template registries before workers start; it is now reclaimed immediately after boot rather than parked for the process lifetime.
Hardening
Knobs that control how the request edge handles untrusted input. See the Server Hardening page for the full story.
| Variable | Purpose | Default |
|---|---|---|
SOLI_TRUST_PROXY | Honors X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-For when set to 1, true, or yes. Only enable when the deployment terminates these headers at a trusted proxy hop — on a directly-exposed app any client can spoof them, which downgrades the CSRF and origin checks, flips the cookie Secure flag, aims *_url helpers at a phishing host, and hands every request a fresh identity so per-IP rate limits never trip. | false |
SOLI_TRUSTED_PROXIES | Comma-separated IPs or CIDR blocks (10.0.0.0/8,127.0.0.1,::1) whose requests may carry X-Forwarded-*. With this set, a client reaching the app directly is not trusted even while SOLI_TRUST_PROXY is on. Unset, every peer is trusted when the flag is on. | unset |
SOLI_FORCE_SECURE_COOKIES | Set to 1/true/yes to add Secure to every cookie the process emits — the framework's session cookie and anything the app sets through set_cookie — regardless of detected scheme. Use when the deployment is always on TLS but the proxy doesn't forward X-Forwarded-Proto: https (or enable_trust_proxy() isn't on). Equivalent runtime call: enable_force_secure_cookies(). | false |
SOLI_MAX_BODY_SIZE | Maximum buffered request body, in bytes. Requests over the cap return 413 Payload Too Large. | 8388608 (8 MiB) |
SOLI_DISABLE_CSRF | Disables the same-origin CSRF check entirely when set to true. For API-only deployments where no cookie session is in play. Per-route opt-out via skip_csrf("/path") in config/routes.sl is preferred — see Routing → CSRF Protection. Read once per process; changing it needs a restart. | unset |
SOLI_CSRF_TOKENS | Set to require to make per-form CSRF tokens mandatory for browser form posts (urlencoded/multipart) — a form post without a valid token returns 403. Tokens are always verified when present regardless of this setting. soli new writes SOLI_CSRF_TOKENS=require into .env; the runtime default remains unset for existing apps. The built-in jobs, errors and slow-queries pages (/__soli/jobs, /__soli/errors, /__soli/slow_queries) are exempt from the requirement — they authenticate with Basic auth, not a cookie session, so they have no session token to embed — but keep the Origin/Referer gate. See Forms & CSRF. Read once per process; changing it needs a restart. | unset (new apps: require) |
SOLI_HTTP_MAX_RESPONSE_BYTES | Maximum bytes Soli will buffer from a single outbound HTTP response (HTTP.*, SOAP.*). A malicious or compromised upstream returning a multi-GB body would otherwise OOM the worker. | 52428800 (50 MiB) |
SOLI_IMAGE_MAX_ALLOC_BYTES | Maximum bytes the image decoder will allocate for a single image (Image.*, plan execution). Defends against decompression bombs — a 100 KB PNG declaring 65535×65535 pixels would otherwise allocate ~16 GB of RGBA pixels. | 268435456 (256 MiB) |
SOLI_IMAGE_MAX_DIMENSION_PX | Maximum pixel dimension on either axis for any decoded image. Images declaring more are rejected before allocation. | 16384 |
SOLI_PARALLEL_MAX_ITEMS | Maximum input list length accepted by HTTP.get_all, HTTP.get_all_json, HTTP.parallel, and Image.process_all. Calls with longer arrays are rejected before any thread is spawned. | 256 |
SOLI_PARALLEL_MAX_CONCURRENCY | Maximum OS threads alive at one time inside a parallel fan-out call. The runner consumes the input list in chunks of this size. | 16 |
SOLI_MAX_UPLOAD_FILES | Maximum number of file parts accepted per multipart request. A body packed with thousands of tiny parts would otherwise allocate a per-file Soli hash for each one and OOM the worker. | 32 |
SOLI_MAX_INFLIGHT_BODY_BYTES | Ceiling on the sum of request-body bytes buffered at once, across every connection. SOLI_MAX_BODY_SIZE bounds one request; this bounds the total, so a burst of large uploads is refused (503 + Retry-After) rather than swapping the box. A body is charged against it as its bytes arrive — a 64 KiB reservation up front, doubled as the body grows, up to SOLI_MAX_BODY_SIZE — rather than reserving the full cap per request. 0 disables it. | 16 × SOLI_MAX_BODY_SIZE |
SOLI_BODY_BUDGET_PER_IP_BYTES | Share of SOLI_MAX_INFLIGHT_BODY_BYTES one client may hold at once, so a single client cannot take the whole upload budget and turn every other upload away. Over it: the same 503 Server busy: too many uploads in flight with Retry-After: 1. The client is the TCP peer, or the right-most X-Forwarded-For entry when trust proxy is on (the same key the rate limiter uses); IPv6 clients are counted per /64, IPv4-mapped IPv6 as IPv4. Behind a proxy without trust proxy, every client is the proxy and shares one share — about a quarter of total upload capacity by default: enable trust proxy, raise this, or set 0. Values below 64 KiB make every request with a body a 503, since each upload claims a 64 KiB first slice. 0 disables it. | a quarter of SOLI_MAX_INFLIGHT_BODY_BYTES (at least one SOLI_MAX_BODY_SIZE, at most the global budget; 0 when the global budget is disabled) |
SOLI_MAX_CONNECTIONS | Maximum simultaneous TCP connections. Past the cap, new connections are closed immediately rather than queued: a client opening sockets and trickling bodies would otherwise exhaust file descriptors and memory without ever completing a request. 0 disables the cap. | 20000 |
SOLI_BODY_IDLE_TIMEOUT_SECS | How long a request body may stall between two frames before the request is answered 408 Request Timeout. SOLI_BODY_READ_TIMEOUT_SECS bounds the whole body; this bounds the silence, so a client that sends one byte and then nothing frees its connection and its memory reservation quickly. A body that breaks off with a transport error is answered 400 (it used to be 413). | 10 |
SOLI_BODY_READ_TIMEOUT_SECS | How long a request body may take to arrive in full. The header read was already bounded; the body was not, so a byte every thirty seconds held a connection and its buffer indefinitely. | 60 |
SOLI_MAX_PARAM_PAIRS | Maximum key=value pairs parsed from a query string or urlencoded body. Bounded only by the body cap, a=&a=&… produced millions of string pairs per request. Pairs past the cap are dropped. | 4096 |
SOLI_HANDLER_TIMEOUT_SECS | Wall-clock budget for executing one request handler. A runaway loop used to hold a worker forever, since the 504 the client receives does not stop the handler. 0 disables it. | 30 |
SOLI_WORKER_STACK_MB | Stack size for threads that run Soli code. The interpreter recurses on the native stack and its 256-frame budget does not fit the 2 MiB default, where an overflow aborts the whole process instead of failing one request. Virtual address space, committed only as used. | 64 |
SOLI_MAX_RANGE_LEN | Maximum elements a single range(a, b) / a..b may materialise. Both collect eagerly, so a request-supplied bound could ask the allocator for gigabytes — and an allocation failure aborts the process rather than the request. | 16777216 |
SOLI_MAX_STRING_ALLOC_BYTES | Maximum size of a string built by "x" * n. Same reasoning as SOLI_MAX_RANGE_LEN; a negative count is refused outright. | 67108864 |
SOLI_MAX_PAGE_SIZE | Ceiling on paginate({"per": n}) and vector-search top_k. per comes straight from request params, and an unbounded page loads the whole collection into one request. | 1000 |
SOLI_RATE_LIMIT_IPV6_PREFIX | Prefix length IPv6 clients are aggregated to for per-IP rate limiting. A residential allocation is a 64-bit prefix, so keying on the full address let one host take a fresh bucket per request and never trip the login throttle. Use 56 or 48 to aggregate a whole site. | 64 |
SOLI_METRICS_TOKEN | Bearer token required to read /_metrics. Unset, the endpoint is limited to loopback and private-range peers instead of being world-readable — and refused (404) outright when the request carries X-Forwarded-For, X-Real-IP or Forwarded, or trust_proxy is on, because behind a reverse proxy every peer looks local. A deployment behind a proxy must set the token. | unset |
SOLI_H2_KEEPALIVE_SECS | Interval between HTTP/2 (h2c) keep-alive PINGs. A connection that does not acknowledge one within 20 seconds is closed. | 30 |
SOLI_CONN_IDLE_TIMEOUT_SECS | An HTTP/2 connection with no activity for this long is closed, so idle multiplexed connections do not accumulate. | 60 |
SOLI_RELEASE_BASE_URL | Base URL that soli build --standalone --target <t> and the version pin download release runtimes from (layout {base}/v{version}/soli-{target}.tar.gz plus .sha256). For mirrors and air-gapped machines. Note the pin executes what it fetches, where a cross-target build only embeds it — point this at a host you trust. | GitHub releases |
SOLI_NO_PIN | Set to 1 to ignore an exact soli_version pin in soli.toml and run the soli you invoked. For CI, air-gapped machines, and bisecting a version-dependent bug. | unset |
SOLI_PINNED_EXEC | Set by soli on itself when it switches to a pinned version, carrying that version. Its presence stops the child switching again, so a toolchain whose compiled version disagrees with its release tag cannot loop. Not something you set. | unset |
SOLI_WS_MAX_CONNECTIONS | Maximum simultaneous WebSocket connections across all routes. Each holds a task and a channel; the registry was unbounded. | 10000 |
SOLI_WS_MAX_CONNECTIONS_PER_IP | Maximum simultaneous WebSocket connections from one peer address. 0 disables the per-IP cap. | 64 |
SOLI_WS_MAX_MESSAGES_PER_SEC | Sustained inbound frames per second allowed on one socket before it is closed, so a single connection cannot monopolise the shared realtime queue. 0 disables it. | 100 |
SOLI_WS_MESSAGE_BURST | Burst allowance on top of SOLI_WS_MAX_MESSAGES_PER_SEC. | 200 |
SOLI_WS_ENQUEUE_TIMEOUT_SECS | How long a WebSocket frame waits for room in the realtime worker queue before the socket is closed with 1013. | 5 |
SOLI_IMAP_MAX_LITERAL_BYTES | Maximum IMAP literal ({N}) accepted from a server. The size is server-supplied and allocated up front. | 33554432 |
SOLI_POP3_MAX_RESPONSE_BYTES | Maximum size of a dot-terminated POP3 multiline response, which has no declared length. | 33554432 |
Database
| Variable | Purpose | Default |
|---|---|---|
SOLIDB_HOST | SoliDB server URL. An explicit http:// / https:// prefix is preserved. When the scheme is omitted, the host defaults to https:// for remote DBs and http:// for loopback (localhost, 127.0.0.1, ::1) so the dev loop stays plaintext while remote DBs are TLS by default. Read once per process; changing it needs a restart. | http://localhost:6745 |
SOLIDB_DATABASE | Database name used by models, migrations, uploads, and jobs fallback. | default |
SOLIDB_API_KEY | API-key auth for SoliDB where supported. | unset |
SOLIDB_USERNAME | Username for SolidB login/basic auth. | unset |
SOLIDB_PASSWORD | Password paired with SOLIDB_USERNAME. | unset |
SOLI_DB_DRIVER | 1 routes the model layer over SoliDB’s native MessagePack driver (pooled TCP on the SOLIDB_HOST port) instead of HTTP: document CRUD and queries, with plain reads decoded straight into Soli values. Uses the same credentials. A driver that cannot connect falls back to HTTP for that worker; an https:// host is refused rather than downgraded. Read once per process. | unset (HTTP) |
SOLI_DB_DRIVER_QUERY | 0 keeps queries on HTTP while SOLI_DB_DRIVER=1 routes document CRUD over the driver. | queries on the driver |
SOLI_DB_ADAPTER | Single-connection backend when config/database.toml is absent: solidb, postgres, mysql, or sqlite. The SQL adapters are a document subset (CRUD, hash filters, aggregates, .includes batching, migrations) — see Multiple Databases for the capability matrix, and the PostgreSQL / MySQL / SQLite pages for per-adapter notes. Multi-database apps use config/database.toml instead. | solidb |
DATABASE_URL | Connection URL for the SQL adapters (postgres://user:pass@localhost:5432/myapp, or a path such as sqlite://db/app.sqlite3). Required when SOLI_DB_ADAPTER is postgres, mysql, or sqlite; ignored for SoliDB. Named TOML connections use url = per connection. Create the database itself with soli db:create. | unset |
SOLI_DB_POOL_SIZE | Default SQL pool size in single-connection mode. TOML pool = N overrides it per connection. | 10 |
Sessions
| Variable | Purpose | Default |
|---|---|---|
SOLI_SESSION_DRIVER | Session backend: in_memory, cookie, disk, solidb, or solikv. | in_memory |
SOLI_SESSION_SECRET | Secret for the cookie session driver (32+ characters, e.g. openssl rand -hex 32). The AES-256-GCM key sealing client-side sessions is HKDF-derived from it; rotating it invalidates every outstanding session. Required when the driver is cookie. | unset |
SOLI_SESSION_PATH | Directory for disk-backed session files. | ./sessions |
SOLI_SESSION_TTL | Session timeout in seconds. | 86400 |
SOLI_SESSION_MAX_LIFETIME | Absolute lifetime of a cookie-driver session, in seconds, counted from when it was issued however active it stays. session_regenerate() restarts it; a cookie issued before this setting existed counts from its iat. 0 disables it. | 2592000 (30 days) |
SOLI_SESSION_MAX_IN_MEMORY | Cap on sessions held by the in_memory driver. Past it the least-recently-used sessions are evicted — those users are logged out. Expired sessions are swept every 1000 creations or 30 seconds. 0 means unlimited. | 100000 |
SOLI_SESSION_SAMESITE | SameSite attribute on the session cookie: Lax, Strict, or None. Strict blocks the cookie on any cross-site navigation; None is intended for cross-site embeds and automatically pairs with Secure — Soli forces the flag on regardless of the detected request scheme so browsers don't silently drop the cookie. Unknown values fall back to Lax. | Lax |
SOLI_SESSION_HOST_PREFIX | Set to 1/true/yes to emit the cookie under the __Host- prefix (__Host-session_id). Browsers only accept __Host- cookies that are Secure, scoped to Path=/, and carry no Domain attribute — this prevents subdomain takeover from setting an attacker-controlled session cookie. Only applied when Secure is also active (i.e. behind HTTPS); otherwise the plain session_id name is used. | unset |
SOLI_SOLIDB_HOST | SolidB host for the solidb session driver. Must be https:// or a loopback (localhost, 127.0.0.1, ::1) — plaintext HTTP to a remote SolidB is rejected. | driver default |
SOLI_SOLIDB_DATABASE | SolidB database for sessions. | driver default |
SOLI_SOLIDB_COLLECTION | SolidB collection for sessions. | driver default |
SOLI_SOLIDB_API_KEY | API key the solidb session driver presents to SolidB. Required for non-loopback hosts. Falls back to SOLIDB_API_KEY (the same key the Model layer reads) when unset. | unset |
SOLI_SOLIDB_USERNAME | Basic-auth username for the solidb session driver (paired with SOLI_SOLIDB_PASSWORD). Falls back to SOLIDB_USERNAME. | unset |
SOLI_SOLIDB_PASSWORD | Basic-auth password for the solidb session driver. Falls back to SOLIDB_PASSWORD. | unset |
SOLI_SESSION_ALLOW_INSECURE_HTTP | Set to 1/true/yes to allow plaintext HTTP and missing auth on non-loopback session hosts. Only when the network path is operator-trusted. | unset |
SOLI_SOLIKV_HOST | SoliKV host for the solikv session driver. Must be a loopback (localhost, 127.0.0.1, ::1) — SoliKV uses plaintext RESP/TCP and the AUTH token transits in the clear, so non-loopback hosts are rejected. | localhost |
SOLI_SOLIKV_PORT | SoliKV port for sessions. | 6380 |
SOLI_SOLIKV_TOKEN | SoliKV auth token for sessions. Sent as a Redis-style AUTH command — same loopback-only constraint as the host. | unset |
Jobs
| Variable | Purpose | Default |
|---|---|---|
SOLI_JOBS_POLL_MS | How often the job poller looks for due work, in milliseconds. Under --dev the poller ticks every 5000 ms instead, so several dev apps don't hammer a shared database with idle claims; setting this variable overrides that too. | 1000 (5000 in --dev) |
SOLI_JOBS_DEFAULT_QUEUE | Queue used when no queue is specified. | default |
SOLI_JOBS_LEASE_SECS | Lease length for a claimed job. A running job whose lease expires is reclaimed by another poller — raise this for long jobs. | 60 |
SOLI_JOBS_MAX_RETRIES | Default retry budget per job; a job past it becomes dead. | 3 |
SOLI_JOBS_RETENTION_SECS | How long completed job rows are kept before pruning. | 604800 |
SOLI_JOB_WORKERS | Worker threads that run job code. Each worker is a full interpreter copy, so the default is conservative; raise it for higher throughput, or set 0 to disable the job engine in this process and run soli jobs separately. | 1 |
SOLI_JOB_VIEW_HELPERS | Whether background-job interpreters load view helpers (which include an app's i18n locale tables — often the largest per-interpreter cost). Set 0 to skip them when no job renders a helper-using template, dropping that memory from every job interpreter. | enabled |
SOLI_JOBS_USER | HTTP Basic username for the production /__soli/jobs dashboard. Pair with SOLI_JOBS_PASSWORD. Unset (and no token, and no SOLI_ADMIN_*) means the route 404s outside --dev. | unset |
SOLI_JOBS_PASSWORD | HTTP Basic password for /__soli/jobs. | unset |
SOLI_JOBS_TOKEN | Optional bearer token for /__soli/jobs (Authorization: Bearer …). Accepted alongside Basic when both are set. | unset |
SOLI_ERRORS | Error tracking for /__soli/errors. off stops recording; on forces it on under APP_ENV=test, where it is otherwise off. | on |
SOLI_ERRORS_USER | HTTP Basic username for the production /__soli/errors page. Pair with SOLI_ERRORS_PASSWORD. With no errors credentials and no SOLI_ADMIN_*, the route 404s outside --dev. | unset |
SOLI_ERRORS_PASSWORD | HTTP Basic password for /__soli/errors. | unset |
SOLI_ERRORS_TOKEN | Optional bearer token for /__soli/errors. | unset |
SOLI_SLOW_QUERIES | Slow-query tracking for /__soli/slow_queries. off stops recording; on forces it on under APP_ENV=test, where it is otherwise off. | on |
SOLI_SLOW_QUERY_MS | A query taking this many milliseconds or more is recorded as slow. Read once per process. | 200 |
SOLI_SLOW_QUERY_BINDS | off stores slow queries without their bind values. | on |
SOLI_SLOW_QUERIES_USER / _PASSWORD / _TOKEN | Credentials for the production /__soli/slow_queries page, like SOLI_ERRORS_*. With none and no SOLI_ADMIN_*, the route 404s outside --dev. | unset |
SOLI_ADMIN_USER / _PASSWORD / _TOKEN | One set of credentials accepted by every built-in operator page (/__soli/jobs, /__soli/errors, /__soli/slow_queries), in addition to each page's own. | unset |
SOLI_NOTIFY_WEBHOOKS | Comma-separated URLs that receive notifications. Slack, Microsoft Teams, Discord and Google Chat URLs get their own message format; any other URL gets the event as JSON. | unset |
SOLI_NOTIFY_EMAILS | Comma-separated addresses that receive notifications through the app's mailer (SOLI_SMTP_*). | unset |
SOLI_NOTIFY_FROM | Sender of notification emails. | SOLI_SMTP_FROM |
SOLI_NOTIFY_EVENTS | Which events are sent: any of error.new, error.regressed, error.spike, slow_query.new. | all four |
SOLI_NOTIFY_THROTTLE | At most one message per event and group within this window (90s, 15m, 1h). | 15m |
SOLI_NOTIFY_SPIKE | error.spike rule, <count>/<window>; off disables it. | 50/5m |
SOLI_NOTIFY_SECRET | Signs JSON webhook bodies: X-Soli-Signature is the hex HMAC-SHA256 of the body. | unset |
SOLI_NOTIFY_URL | Base URL for the links in notifications. | https:// + first SOLI_APP_HOSTS |
SOLI_NOTIFY_APP_NAME | The app's name in notifications. | the app directory's name |
Cache And KV
| Variable | Purpose | Default |
|---|---|---|
SOLIKV_RESP_HOST | SoliKV RESP host used by KV/cache builtins. | localhost |
SOLIKV_RESP_PORT | SoliKV RESP port. | 6380 |
SOLIKV_TOKEN | SoliKV auth token. | unset |
SOLI_KV_ALLOW_ADMIN | Set to 1/true/yes to lift the denylist on destructive/admin RESP commands (FLUSHALL, FLUSHDB, KEYS, SCAN, CONFIG, DEBUG, EVAL, etc.) reachable from KV.cmd, KV.flushdb, and KV.keys. Only set this on a trusted, non-user-facing process. | unset |
S3
| Variable | Purpose | Default |
|---|---|---|
AWS_ACCESS_KEY_ID | AWS-compatible access key. Alternative: S3_ACCESS_KEY. | required for S3 calls |
AWS_SECRET_ACCESS_KEY | AWS-compatible secret key. Alternative: S3_SECRET_KEY. | required for S3 calls |
AWS_REGION | AWS region. Alternative: S3_REGION. | us-east-1 |
S3_ACCESS_KEY | S3-compatible access key fallback. | unset |
S3_SECRET_KEY | S3-compatible secret key fallback. | unset |
S3_REGION | S3-compatible region fallback. | us-east-1 |
S3_ENDPOINT | Custom endpoint for MinIO or another S3-compatible service. | unset |
Deploy And Internals
| Variable | Purpose | Default |
|---|---|---|
SOLI_DEPLOY_API_KEY | API key required by soli deploy for proxy deployment. | required for deploy |
SOLI_COVERAGE_ENABLED | Enables the server-side coverage dump endpoint for test aggregation. Normally set by Soli tooling. The endpoint also requires SOLI_COVERAGE_TOKEN — without a matching X-Coverage-Token request header it returns 403. | unset |
SOLI_COVERAGE_TOKEN | Per-process secret gating /__coverage__. The test runner mints a fresh random token per run and sends it as X-Coverage-Token when scraping; without this token the endpoint refuses every caller, even when SOLI_COVERAGE_ENABLED is set. | required when SOLI_COVERAGE_ENABLED is set |
Runtime Overrides
The hardening knobs above also have function equivalents that override the env-driven default at runtime — useful when a single action needs a different limit, or when test setup needs to flip the gate without re-reading the environment.
Soli loads config/application.sl once at boot, before config/routes.sl, which makes it the natural place for app-wide startup config:
# Trust X-Forwarded-* only behind a trusted proxy.
enable_trust_proxy()
# Always emit Secure session cookies — appropriate when the deployment
# is always on TLS but the proxy doesn't forward X-Forwarded-Proto.
enable_force_secure_cookies()
# Raise the default 8 MiB body cap when an app needs larger uploads.
set_max_body_size(32 * 1024 * 1024)