Changelog
Release history for soli-proxy. Binaries for Linux (amd64, arm64) and macOS (arm64) are attached to every GitHub release.
v0.34.0
latest 2026-09-15Scale to zero — sleep an idle app, wake it on the next request
Most fleets are mostly idle. On a box hosting thirty small sites a day's traffic typically touches a handful, and every one of the
others holds its full runtime in memory for nothing. idle_timeout = 900 in
app.infos stops such an app after fifteen quiet minutes and starts it again when
someone asks for it. A reaper sweeps every 30 s; the stop goes through the same path as
soli-proxy stop, so the exit is not read as a crash — no failover, no quarantine.
The next request for one of its domains is held while the app starts on its current slot and is polled for health,
then forwarded as usual: the first visitor waits about a second, everyone else finds it running, and concurrent first requests share
one start. A sleeping app keeps its certificate registered and keeps winning over static
proxy.conf rules for its domains, exactly as a running one does. The default is
0 — never sleep — which is the right value for anything that works
without being asked: cron jobs, background workers, WebSocket rooms, a cache that cannot be rebuilt in a moment.
[apps] idle_timeout in config.toml sets a
fleet-wide default; _admin never sleeps regardless.
Per-environment sections in app.infos
One manifest, two environments. A manifest may carry [development] and
[production] sections, and the proxy's
--dev flag picks one — the same flag that already appends
--dev to an auto-detected Soli start script and registers each app's
.test alias. The alternative, a dev copy of the file and a prod copy, is two
files nobody diffs until the day they disagree about something that matters.
The selected section is applied key by key over the top level, so a key it does not mention keeps the value written above, and a nested
table merges into its counterpart rather than replacing it — [production.auth.users]
adds accounts without discarding the noauth list under
[auth]. The section that is not selected is dropped unread, so a
[production] block written for a newer proxy never stops a developer's machine
from starting the app. A manifest carrying neither section parses exactly as before.
Discovery says which app.infos keys it did not understand
An unrecognised key was dropped without a word — worker = 4 ran the app with one
worker and said nothing — and the new overlay sections raise the stakes, since a key that lands in the wrong section is ignored just as
quietly. Unknown keys are now logged at startup. They stay ignored rather than fatal: refusing the manifest would take a running app off
the routing table over a typo.
v0.33.0
2026-09-10HTTP Basic Auth per app, in app.infos
Apps are routed by the app manager rather than by proxy.conf rules — static rules for
app-managed domains are pruned — so a route's @auth could never protect an app. An app now
declares its own, with an [auth.users] table of username = "bcrypt hash"
entries and an [auth] noauth list of paths served without credentials. Auth follows the app's
derived domains (www.-stripped, .test in dev) and any admin-managed
alias, and is enforced on WebSocket upgrades as well as plain requests.
An [auth] section the proxy cannot enforce as written — an empty hash, a pattern that does not
compare literally — makes the app fail to load rather than come up unprotected.
GET /api/v1/apps reports the configured usernames and carve-outs, never the hashes, and the admin UI
shows an auth badge on protected apps.
A contested domain resolves to one app, deterministically
The host → app lookup iterated a hash map, so when two apps declared one domain the app it returned could differ from the one actually routed to (which picks by name order). That already misattributed per-app metrics; with per-app auth reading the same lookup it would have meant answering a protected app's traffic with another app's credentials, or none.
v0.32.0
2026-09-09Exempt paths from a route's Basic Auth
A whole domain can be password-protected while the endpoints machines call — a payment webhook, a health probe —
stay reachable without credentials:
app.example.com -> http://app:8080 @auth:admin:$2b$12$... @noauth:/webhooks/stripe,/hooks/*.
Entries are comma-separated and each is either an exact path or a prefix ending in
* (which also matches the bare prefix). Paths are matched against the URL as the client sent it,
before any prefix stripping, so what you write is what you see in the browser.
@noauth without @auth does nothing. Editable from the admin UI
(Routes → Basic Auth) and the TUI route form, and exposed as auth_exempt on the admin API.
The match is deliberately literal and fails closed: a path carrying percent-encoding or a
.. segment is never exempt, so a request cannot walk out of the carve-out into a protected path
that a normalising backend would resolve differently. Patterns that cannot be compared literally are refused by the admin API
and dropped with a warning by the .conf parser, leaving the path protected.
v0.31.0
2026-09-04Path traversal rejected before routing
Rules match on the raw path and per-route auth binds to the matched rule, so
/api/../admin/users could pass an open
/api/ rule and land on a protected route at any backend that
normalises. Dot segments in every spelling (literal, %2e,
..;, ..\)
and encoded slashes are answered 400. Backends that carry %2F
as data can set [server] allow_encoded_slash = true.
Multi-tenant mode for untrusted apps
[apps] multi_tenant = true treats every app as untrusted code:
a docker_image is mandatory, every container gets a read-only
root, dropped capabilities, no-new-privileges, pid/memory/cpu ceilings and a non-root uid that tenant config cannot weaken, and
docker_options is validated against an allowlist. Bind mounts may only be
the app's own site directory, passed to docker by its canonical path so a running container cannot race the check with a symlink.
docker_network may not join the host or another container in any mode,
and the single-tenant denylist now reads docker's syntax (-v/:/host,
--mount type=bind,source=/).
Admin API hardening
Mutating requests without X-Api-Key must carry an
X-Requested-With header, which an HTML form cannot set.
Route password hashes are never returned by the API; a re-submitted entry with an empty hash keeps the existing one, matched by
the rule's matcher rather than its position so a whole-table update that deletes or reorders rules cannot hand a route another
route's password. Empty admin credentials (api_key = "")
count as unset instead of producing a server that rejects everything. Lua hook errors fail closed with a 500.
Apps get their own HOME and an environment allowlist
The proxy drops privileges to the app's user but handed the child its own
HOME (/root under systemd),
silently breaking soli's package cache, registry credentials and the Tailwind CLI. It now comes from the passwd entry of the user the
app runs as. A short allowlist (HTTP(S)_PROXY,
SSL_CERT_FILE,
XDG_CACHE_HOME, the soli toolchain variables) survives the
cleared environment, for native and Docker apps alike, so pinned interpreter versions and egress proxies work on a server.
v0.29.1
2026-07-28Deploy trigger file
Touching restart.txt at the root of a site triggers a
zero-downtime blue/green deploy, so a deploy script can end with
touch <site>/restart.txt instead of an SSH-side restart.
The file is polled rather than watched, because inotify does not traverse the symlinks that sites usually are. The first poll after
startup only records a baseline, so an existing trigger file never redeploys everything on daemon restart. Configurable through
[apps].restart_trigger_file and
restart_trigger_poll_secs.
Quarantine on failed start
An app that fails to start is no longer restarted forever. The health loop previously retried every 30 seconds with no failure cap,
leaving a broken deploy flapping indefinitely. The failed slot is now killed and marked failed, the previous
slot keeps serving, and every automatic remediation path skips the app until an explicit deploy. Surfaced as
"quarantined": true on the apps API.
systemd unit and Docker entrypoint repaired
Both shipped deployment paths could never start: the systemd unit invoked a subcommand that does not exist, and the Docker
CMD passed a flag that does not exist. Both now use
--conf and
--sites-dir, with a working directory so
run/ and
certs/ resolve to the mounted volumes.
v0.29.0
Security hardeningStreaming request-body limits
Body-size limits are enforced on chunked and HTTP/2 bodies by streaming through a limiter and returning
413, instead of blanket-rejecting chunked encoding.
A body that omits or misreports its length can no longer buffer without bound.
Header and target validation
WebSocket upgrade requests and forwarded headers are guarded against CR/LF injection, and proxy target URLs from configuration and Lua overrides are restricted to safe schemes, closing an SSRF avenue.
Shared connection pool, faster routing
All listeners share a single connection pool with a per-host idle cap. Host matching is a single case-insensitive scan again, replacing an optimization that rebuilt a throwaway hash map on every request.
Full history in CHANGELOG.md.