ESC
Type to search...
S
Soli Docs

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

VariablePurposeDefault
SOLI_DB_ADAPTERSingle-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_URLConnection 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_SIZEDefault SQL pool size (single-connection mode). TOML pool = N overrides per connection.10
APP_ENVSelects .env.{APP_ENV} and marks test mode for features that need it.unset
SOLI_PROTECT_ENVComma-separated variable names that .env.{APP_ENV} must not override. Mostly used by the test runner.unset

Server And Development

VariablePurposeDefault
SOLI_HOSTIP 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_WORKERSNumber 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_LOCALEThe 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_WORKERSWorker 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_LOGEnables 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_LOGComma-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_FORMATShape 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_MSSlow-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_OTELSet 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_ENDPOINTOTLP 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_ENDPOINTFull traces URL override (takes precedence over OTEL_EXPORTER_OTLP_ENDPOINT).unset
OTEL_SERVICE_NAMEservice.name resource attribute on exported spans.soli
OTEL_RESOURCE_ATTRIBUTESExtra resource attributes as comma-separated key=value pairs (e.g. deployment.environment=prod).unset
OTEL_SDK_DISABLEDSet to true to force tracing off regardless of the other OTEL vars.unset
SOLI_DB_POOL_IDLE_SECSIdle 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_IDLEMax 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_REACTORSet 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_WARMSet 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_NAVControls 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_PREFETCHControls 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_TTLFreshness 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_HOSTHost used by *_url route helpers outside an active request.unset
SOLI_DEFAULT_URL_SCHEMEScheme used with SOLI_DEFAULT_URL_HOST.http
SOLI_DEV_REPL_ALLOW_REMOTEAllows 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_SECRETPins 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_OPENAPISet 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_TITLETitle of the generated OpenAPI document.Soli API
SOLI_SHUTDOWN_GRACE_SECSHow 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_BOOTPrints 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.

EndpointMeaningAnswers
GET /_healthLiveness — is this process alive?200 ok for as long as the server runs, including while it shuts down
GET /_readyReadiness — 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:

  1. /_ready starts answering 503 draining, so the load balancer stops routing here.
  2. New requests get 503 Server shutting down with Connection: close. Probes still answer.
  3. Requests already in flight run to completion and return their real response.
  4. Once the last one finishes — or SOLI_SHUTDOWN_GRACE_SECS elapses — the process exits 0.

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

VariablePurposeDefault
SOLI_DEFLATE_MAX_BYTESMaximum 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.

VariablePurposeDefault
SOLI_BUNDLE_KEYThe bundle AES key material itself. Simplest option; also handy for local testing.unset
SOLI_BUNDLE_AUTH_URLURL 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_KEYSent 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_DISKSet 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:

LeverEffect
SOLI_WORKERS=NThe 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=0Drops view helpers (incl. i18n locale tables) from every job interpreter when jobs don’t render helper-using templates.
Slim Cargo featuresBuild 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=0mimalloc 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 localesIf 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:

FeatureDefaultWhat it pulls in
embeddingonVector / embedding helpers
llmonllm_generate (OpenAI-compatible chat)
codegraphonsoli graph build on non-Soli repos (tree-sitter + grammars)
pasetoonPaseto class (pasetors crate)
postgresonPostgreSQL document adapter + client pool
mysqlonMySQL / MariaDB document adapter + client pool
sqliteonSQLite document adapter (bundled client — no system library needed)
euionEUI components — router_eui, eui_capabilities, the /_eui session endpoint
sqloffAlias for postgres + mysql + sqlite
solidb-driveronNative SoliDB TCP driver (MessagePack over pooled TCP). Compiled in by default; a server uses it only with SOLI_DB_DRIVER=1
eui-desktopoffsoli desktop build --eui — the native EUI window
fulloffAlias 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.

VariablePurposeDefault
SOLI_TRUST_PROXYHonors 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_PROXIESComma-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_COOKIESSet 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_SIZEMaximum buffered request body, in bytes. Requests over the cap return 413 Payload Too Large.8388608 (8 MiB)
SOLI_DISABLE_CSRFDisables 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_TOKENSSet 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_BYTESMaximum 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_BYTESMaximum 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_PXMaximum pixel dimension on either axis for any decoded image. Images declaring more are rejected before allocation.16384
SOLI_PARALLEL_MAX_ITEMSMaximum 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_CONCURRENCYMaximum 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_FILESMaximum 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_BYTESCeiling 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_BYTESShare 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_CONNECTIONSMaximum 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_SECSHow 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_SECSHow 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_PAIRSMaximum 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_SECSWall-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_MBStack 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_LENMaximum 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_BYTESMaximum 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_SIZECeiling 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_PREFIXPrefix 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_TOKENBearer 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_SECSInterval between HTTP/2 (h2c) keep-alive PINGs. A connection that does not acknowledge one within 20 seconds is closed.30
SOLI_CONN_IDLE_TIMEOUT_SECSAn HTTP/2 connection with no activity for this long is closed, so idle multiplexed connections do not accumulate.60
SOLI_RELEASE_BASE_URLBase 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_PINSet 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_EXECSet 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_CONNECTIONSMaximum simultaneous WebSocket connections across all routes. Each holds a task and a channel; the registry was unbounded.10000
SOLI_WS_MAX_CONNECTIONS_PER_IPMaximum simultaneous WebSocket connections from one peer address. 0 disables the per-IP cap.64
SOLI_WS_MAX_MESSAGES_PER_SECSustained 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_BURSTBurst allowance on top of SOLI_WS_MAX_MESSAGES_PER_SEC.200
SOLI_WS_ENQUEUE_TIMEOUT_SECSHow long a WebSocket frame waits for room in the realtime worker queue before the socket is closed with 1013.5
SOLI_IMAP_MAX_LITERAL_BYTESMaximum IMAP literal ({N}) accepted from a server. The size is server-supplied and allocated up front.33554432
SOLI_POP3_MAX_RESPONSE_BYTESMaximum size of a dot-terminated POP3 multiline response, which has no declared length.33554432

Database

VariablePurposeDefault
SOLIDB_HOSTSoliDB 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_DATABASEDatabase name used by models, migrations, uploads, and jobs fallback.default
SOLIDB_API_KEYAPI-key auth for SoliDB where supported.unset
SOLIDB_USERNAMEUsername for SolidB login/basic auth.unset
SOLIDB_PASSWORDPassword paired with SOLIDB_USERNAME.unset
SOLI_DB_DRIVER1 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_QUERY0 keeps queries on HTTP while SOLI_DB_DRIVER=1 routes document CRUD over the driver.queries on the driver
SOLI_DB_ADAPTERSingle-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_URLConnection 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_SIZEDefault SQL pool size in single-connection mode. TOML pool = N overrides it per connection.10

Sessions

VariablePurposeDefault
SOLI_SESSION_DRIVERSession backend: in_memory, cookie, disk, solidb, or solikv.in_memory
SOLI_SESSION_SECRETSecret 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_PATHDirectory for disk-backed session files../sessions
SOLI_SESSION_TTLSession timeout in seconds.86400
SOLI_SESSION_MAX_LIFETIMEAbsolute 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_MEMORYCap 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_SAMESITESameSite 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_PREFIXSet 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_HOSTSolidB 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_DATABASESolidB database for sessions.driver default
SOLI_SOLIDB_COLLECTIONSolidB collection for sessions.driver default
SOLI_SOLIDB_API_KEYAPI 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_USERNAMEBasic-auth username for the solidb session driver (paired with SOLI_SOLIDB_PASSWORD). Falls back to SOLIDB_USERNAME.unset
SOLI_SOLIDB_PASSWORDBasic-auth password for the solidb session driver. Falls back to SOLIDB_PASSWORD.unset
SOLI_SESSION_ALLOW_INSECURE_HTTPSet 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_HOSTSoliKV 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_PORTSoliKV port for sessions.6380
SOLI_SOLIKV_TOKENSoliKV auth token for sessions. Sent as a Redis-style AUTH command — same loopback-only constraint as the host.unset

Jobs

VariablePurposeDefault
SOLI_JOBS_POLL_MSHow 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_QUEUEQueue used when no queue is specified.default
SOLI_JOBS_LEASE_SECSLease 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_RETRIESDefault retry budget per job; a job past it becomes dead.3
SOLI_JOBS_RETENTION_SECSHow long completed job rows are kept before pruning.604800
SOLI_JOB_WORKERSWorker 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_HELPERSWhether 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_USERHTTP 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_PASSWORDHTTP Basic password for /__soli/jobs.unset
SOLI_JOBS_TOKENOptional bearer token for /__soli/jobs (Authorization: Bearer …). Accepted alongside Basic when both are set.unset
SOLI_ERRORSError tracking for /__soli/errors. off stops recording; on forces it on under APP_ENV=test, where it is otherwise off.on
SOLI_ERRORS_USERHTTP 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_PASSWORDHTTP Basic password for /__soli/errors.unset
SOLI_ERRORS_TOKENOptional bearer token for /__soli/errors.unset
SOLI_SLOW_QUERIESSlow-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_MSA query taking this many milliseconds or more is recorded as slow. Read once per process.200
SOLI_SLOW_QUERY_BINDSoff stores slow queries without their bind values.on
SOLI_SLOW_QUERIES_USER / _PASSWORD / _TOKENCredentials 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 / _TOKENOne 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_WEBHOOKSComma-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_EMAILSComma-separated addresses that receive notifications through the app's mailer (SOLI_SMTP_*).unset
SOLI_NOTIFY_FROMSender of notification emails.SOLI_SMTP_FROM
SOLI_NOTIFY_EVENTSWhich events are sent: any of error.new, error.regressed, error.spike, slow_query.new.all four
SOLI_NOTIFY_THROTTLEAt most one message per event and group within this window (90s, 15m, 1h).15m
SOLI_NOTIFY_SPIKEerror.spike rule, <count>/<window>; off disables it.50/5m
SOLI_NOTIFY_SECRETSigns JSON webhook bodies: X-Soli-Signature is the hex HMAC-SHA256 of the body.unset
SOLI_NOTIFY_URLBase URL for the links in notifications.https:// + first SOLI_APP_HOSTS
SOLI_NOTIFY_APP_NAMEThe app's name in notifications.the app directory's name

Cache And KV

VariablePurposeDefault
SOLIKV_RESP_HOSTSoliKV RESP host used by KV/cache builtins.localhost
SOLIKV_RESP_PORTSoliKV RESP port.6380
SOLIKV_TOKENSoliKV auth token.unset
SOLI_KV_ALLOW_ADMINSet 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

VariablePurposeDefault
AWS_ACCESS_KEY_IDAWS-compatible access key. Alternative: S3_ACCESS_KEY.required for S3 calls
AWS_SECRET_ACCESS_KEYAWS-compatible secret key. Alternative: S3_SECRET_KEY.required for S3 calls
AWS_REGIONAWS region. Alternative: S3_REGION.us-east-1
S3_ACCESS_KEYS3-compatible access key fallback.unset
S3_SECRET_KEYS3-compatible secret key fallback.unset
S3_REGIONS3-compatible region fallback.us-east-1
S3_ENDPOINTCustom endpoint for MinIO or another S3-compatible service.unset

Deploy And Internals

VariablePurposeDefault
SOLI_DEPLOY_API_KEYAPI key required by soli deploy for proxy deployment.required for deploy
SOLI_COVERAGE_ENABLEDEnables 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_TOKENPer-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)