Overview
Soli Proxy can host and manage web applications directly. Drop an app folder in sites/ with an app.infos configuration file, and the proxy automatically:
- Discovers the app and allocates ports
- Starts the app using its
start_script - Registers routes for its domain
- Issues TLS certificates (in production)
- Watches for file changes and re-discovers
Directory Structure
soli-proxy/
proxy.conf # static routing rules
config.toml # server configuration
sites/ # app hosting directory
myapp/ # one folder per app
app.infos # app configuration (TOML)
... # app source code
blog/
app.infos
...
_admin/ # built-in admin UI (underscore prefix)
app.infos
...
run/ # runtime state (auto-created)
ports.lock # persistent port assignments (JSON)
logs/ # per-app deployment logs
myapp/
blue.log
green.log
app.infos Reference
Each app folder may contain an app.infos file (TOML). Settings live at the top level, followed by up to three optional sections: [auth], and the [development] / [production] overlays. If the file is missing or empty, defaults are used.
Folder name rule: each app folder must be named after a valid domain (must contain at least one dot, e.g. myapp.example.com/). The proxy rejects folders that don't look like a domain.
# Required
name = "myapp" # unique identifier
domain = "myapp.example.com" # domain to route traffic to this app
# Startup
start_script = "soli serve . --port $PORT --workers $WORKERS"
stop_script = "" # optional: custom stop command
workers = 2 # worker processes (default: 1)
# Health
health_check = "/" # endpoint to probe (default: "/health"; auto-set to "/" for soli/luaonbeans apps)
graceful_timeout = 30 # seconds before SIGKILL (default: 30)
drain_delay = 5 # seconds to drain before SIGTERM (default: 5; clamped < graceful_timeout)
# Ports
port_range_start = 20000 # minimum port (default: 20000)
port_range_end = 30000 # maximum port (default: 30000)
# Docker (optional)
docker_image = "myapp:latest" # run app in Docker container
docker_options = "--memory=512m" # additional Docker run options
docker_network = "soli-apps" # Docker network (default: soli-apps)
# HTTP Basic Auth (optional)
[auth]
noauth = ["/webhooks/stripe", "/hooks/*"] # paths served without credentials
[auth.users] # username = bcrypt hash
admin = "$2b$12$..."
# Per-environment overrides (optional) — --dev picks [development]
[development]
workers = 1
[production]
workers = 8
| Field | Required | Description |
|---|---|---|
| name | Yes | Unique app identifier. Used in logs, API, and port assignment. |
| domain | Yes | Domain name to route to this app. Leave empty for internal apps (like _admin). |
| start_script | No | Shell command to start the app. Receives $PORT and $WORKERS env vars. |
| stop_script | No | Custom stop command. If empty, sends SIGTERM then SIGKILL. |
| workers | No | Number of worker processes. Default: 1. |
| user | No | OS user to drop privileges to. Falls back to [apps].default_user in config.toml. Required when the proxy runs as root. |
| group | No | OS group to drop privileges to. Falls back to [apps].default_group in config.toml. |
| health_check | No | HTTP path to probe after startup. Must return 2xx. Default: /health (auto-set to / for soli/luaonbeans apps). |
| graceful_timeout | No | Seconds to wait after SIGTERM before SIGKILL. Default: 30. |
| drain_delay | No | Seconds the old slot keeps draining connections before SIGTERM. Default: 5. Clamped to graceful_timeout / 2 if set >= graceful_timeout. |
| port_range_start | No | Minimum port for allocation. Default: 20000. |
| port_range_end | No | Maximum port for allocation. Default: 30000. |
| docker_image | No | Docker image to use for this app. When set, app runs inside a Docker container instead of as a local process. |
| docker_options | No | Additional options to pass to docker run. Example: --memory=512m --cpus=1. Whitespace-split, no shell. A denylist refuses --privileged, --cap-add, --device, --volumes-from, --env-file, host or foreign-container namespaces, and root / docker-socket mounts in any spelling. With [apps] multi_tenant = true only an allowlist is accepted (see the README). |
| [auth.users] | No | A username = "bcrypt hash" table. When non-empty, every request to this app's domains must present matching HTTP Basic Auth credentials. Generate a hash with soli-proxy hash-password. Apps are routed by the app manager rather than by proxy.conf rules, so a route's @auth cannot protect an app — this is the equivalent. |
| [auth] noauth | No | Paths served without credentials — the escape hatch for callers that cannot send a password (a payment webhook, a health probe). Each entry is an exact path (/webhooks/stripe) or a prefix ending in * (/hooks/*, which also matches the bare /hooks), same syntax as the @noauth: route directive. Matched against the URL as the client sent it. Fails closed: a path carrying percent-encoding or a .. segment is never exempt. A pattern that cannot be compared literally makes the app fail to load rather than silently widening into a bypass. |
| idle_timeout | No | Seconds without a request before the proxy stops the app and starts it again on the next one. 0 means it never sleeps — the default. Falls back to [apps].idle_timeout in config.toml. See Scale to zero. |
| [development] [production] | No | Per-environment overrides for any of the settings above. The proxy's --dev flag selects [development]; every other run selects [production]. Applied key by key over the top level — see Per-environment settings. |
| docker_network | No | Docker network to join. Apps on the same network can communicate by container name. A plain network name only: host and container:<id> are refused. Default: soli-apps |
Scale to zero
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 lets the proxy put such an app to sleep and start it again on the next request.
idle_timeout = 900 # sleep after 15 minutes without a request
- •Every request the proxy routes to an app resets that app's idle clock. A reaper runs every 30 s.
- •An app past its threshold is stopped the way
soli-proxy stopstops it, so the exit is not mistaken for 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. Concurrent first requests share one start.
- •A sleeping app keeps its certificate registered and keeps winning over static
proxy.confrules for its domains, exactly as a running one does.soli-proxy restartor a deploy wakes it and resets the clock.
The default is never to sleep
0 is right for anything that does work without being asked: cron jobs, background workers, WebSocket rooms, a warm cache that takes more than a moment to rebuild. Set a threshold only on apps whose whole life is answering requests. _admin never sleeps regardless of its manifest.
A fleet-wide default goes in config.toml under [apps] idle_timeout. An app that must stay up under one says so with idle_timeout = 0 in its own app.infos.
Per-environment settings
One manifest, two environments. The proxy's --dev flag — the flag that already appends --dev to an auto-detected Soli start script and registers each app's .test alias — decides which section is folded in. The alternative, a dev copy of app.infos and a prod copy, is two files nobody diffs until the day they disagree about something that matters.
workers = 4
idle_timeout = 1800
[development]
workers = 1 # one worker, and no sleeping, while developing
idle_timeout = 0
[production]
workers = 8
Run with --dev this app has one worker and never sleeps; run without it, eight workers and a 30-minute idle timeout.
- •The selected section is applied key by key over the top level. A key the section does not mention keeps its top-level value — above,
idle_timeoutin production. - •A nested table merges into its counterpart rather than replacing it, so
[production.auth.users]adds accounts without discarding thenoauthlist written under[auth]. - •The section that is not selected is dropped unread. A
[production]block naming a setting only a newer proxy understands will not stop a developer's machine from starting the app. - •Both sections are optional, and a manifest carrying neither parses exactly as it did before they existed.
The ordinary TOML trap
Every key after a [development] header belongs to that section until the next header. A setting meant for both environments goes above the first section, not below it.
Unknown settings
A key app.infos does not define is ignored — worker = 4 runs the app with one worker — but discovery logs it:
WARN app.infos for myapp.example.com: unknown setting "worker" — ignored
Ignoring rather than refusing is deliberate: a manifest that fails to load takes a running app off the routing table, which is a steep price for a typo. The warning is there so the typo costs five minutes instead of five hours. Unknown keys inside [development] or [production] are reported the same way, with the section named.
start_script Auto-Detection
If start_script is omitted, the proxy tries to infer one from the directory contents:
Soli app
When app/ and app/models/ exist:
start_script = "soli serve . --port $PORT --workers $WORKERS" # + --dev in dev mode
health_check = "/"
LuaOnBeans app
When a luaonbeans.org binary exists in the folder:
start_script = "./luaonbeans.org -D . -p $PORT -s"
health_check = "/"
If no start_script is set and neither layout matches, deployment fails with No start script configured.
Environment Variables
The following environment variables are set when running an app's start_script. $PORT and $WORKERS are also substituted as literal placeholders inside the script. Note: the start command is parsed without a shell — no pipes, redirects, or globs.
$PORT$WORKERSAuto-Discovery
The proxy automatically scans the sites/ directory for apps on startup and watches for changes at runtime. The discovery process:
Scan
Reads all subdirectories in sites/ and parses their app.infos files.
Allocate Ports
Each app gets two ports (blue and green slots). Assignments persist in run/ports.lock so they survive restarts.
Start Apps
Apps with a start_script are launched. The health check endpoint is polled for up to 30 seconds.
Register Routes
A routing rule is created for each app's domain, pointing to the active slot's port on localhost.
Issue Certificates
In production (tls.mode = "letsencrypt"), Let's Encrypt certificates are automatically requested for each app domain.
A file system watcher monitors sites/ and re-runs discovery when changes are detected (debounced to 500ms). Apps removed from disk are automatically cleaned up.
Blue-Green Deployment
Each app has two deployment slots: blue and green. Only one slot serves traffic at a time. Deploying starts the new version on the inactive slot, verifies health, then switches routing.
Blue Slot
Currently serving traffic on port 16401
Deploy
New version starts on green, passes health check, routes switch
Green Slot
New version now serving on port 16402
# Without an API key, mutations need X-Requested-With (CSRF guard); with one, X-Api-Key is enough
H='-H X-Requested-With:curl'
# Deploy new version to the inactive slot
curl -X POST $H http://127.0.0.1:9090/api/v1/apps/myapp/deploy
# Rollback to previous slot
curl -X POST $H http://127.0.0.1:9090/api/v1/apps/myapp/rollback
# Restart current slot
curl -X POST $H http://127.0.0.1:9090/api/v1/apps/myapp/restart
# Stop the app
curl -X POST $H http://127.0.0.1:9090/api/v1/apps/myapp/stop
# View deployment logs
curl http://127.0.0.1:9090/api/v1/apps/myapp/logs
Deployment Lifecycle
start_instance(slot)
|
+-- Set PORT and WORKERS env vars
+-- Run start_script in new process group
+-- Redirect stdout/stderr to run/logs/{app}/{slot}.log
|
wait_for_health(port, path)
|
+-- Poll http://localhost:{port}{health_check}
+-- Retry for up to 30 seconds
+-- Success (2xx) → mark slot as healthy
|
switch_routes()
|
+-- Update routing rule to point to new port
+-- Old slot receives SIGTERM
+-- Wait graceful_timeout seconds
+-- Force SIGKILL if still running
Process termination sends signals to the entire process group (not just the PID), ensuring child processes are also stopped.
Dev Mode
When the proxy is started with --dev, it enables development-friendly features:
.test Domain Aliases
Each app gets an additional .test domain alias by replacing the TLD:
App --dev Flag
The --dev flag is appended to each app's start_script:
# Start proxy in dev mode
./soli-proxy --dev
# Then point your .test domains to your local machine in /etc/hosts:
# 192.168.1.30 myapp.example.test
# 192.168.1.30 blog.solisoft.test
Tip: Use .test (RFC 6761) for local development instead of .dev. The .dev TLD is owned by Google and browsers force HTTPS via HSTS preloading.
WebSocket Support
WebSocket connections are transparently proxied to backend apps. The proxy detects the Upgrade: websocket header and establishes a bidirectional TCP tunnel between the client and backend. This works automatically for livereload, real-time updates, and any WebSocket protocol.
WebSocket proxying works on both HTTP and HTTPS connections. No additional configuration is needed.
Docker App Hosting
Apps can run inside Docker containers instead of as local processes. This provides isolation, consistent environments, and easier scaling. To enable Docker hosting, add docker_image to your app.infos:
name = "myapp"
domain = "myapp.example.com"
docker_image = "mycompany/myapp:latest"
docker_options = "--memory=512m --cpus=1"
start_script = "/start.sh"
How it works
- Proxy runs
docker runwith your image - Container joins the
docker_network(defaultsoli-apps, auto-created if missing) - Environment variables
$PORT,$WORKERS, and$HEALTH_CHECKare passed to the container - Blue-green deployments work the same — each slot is a separate container
FROM node:20-alpine
WORKDIR /app
COPY . .
EXPOSE $PORT
CMD ["node", "server.js"]
Prerequisites
The Docker socket must be mounted into the proxy container for Docker-based apps to work. In docker-compose, add: /var/run/docker.sock:/var/run/docker.sock
Automatic TLS Certificates
When tls.mode = "letsencrypt", the proxy automatically issues Let's Encrypt certificates for each app domain. The certificate renewal task runs every 12 hours and dynamically discovers new domains from the config.
Domains that are not eligible for ACME certificates are automatically excluded:
- ✗
localhost - ✗
*.localhost - ✗
*.test(dev mode aliases) - ✗ IP addresses
- ✓ All other domains get certificates automatically
Admin API Endpoints
Manage apps at runtime via the Admin REST API:
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/apps | List all discovered apps |
| GET | /api/v1/apps/{name} | App details: config, ports, slots, status |
| POST | /api/v1/apps/{name}/deploy | Deploy to inactive slot (blue-green) |
| POST | /api/v1/apps/{name}/restart | Restart the current active slot |
| POST | /api/v1/apps/{name}/rollback | Switch to the other slot |
| POST | /api/v1/apps/{name}/stop | Stop the app |
| GET | /api/v1/apps/{name}/logs | Blue and green deployment logs |
CLI Commands
Manage apps directly from the command line using the soli-proxy binary:
# Deploy an app (blue-green)
soli-proxy deploy myapp
# With custom config path
soli-proxy deploy -c /path/to/proxy.conf myapp
# Restart an app
soli-proxy restart myapp
# With custom config path
soli-proxy restart -c /path/to/proxy.conf myapp
# Stop an app
soli-proxy stop myapp
# With custom config path
soli-proxy stop -c /path/to/proxy.conf myapp
# View app logs (blue and green slots)
soli-proxy logs myapp
# With custom config path
soli-proxy logs -c /path/to/proxy.conf myapp
CLI vs Admin API
CLI commands are convenient for scripts and quick operations. The Admin REST API is better for programmatic access and integrations.
Complete Example
Here's a minimal Soli app deployed via the proxy:
name = "blog"
domain = "blog.example.com"
start_script = "soli serve . --port $PORT --workers $WORKERS"
workers = 2
health_check = "/"
graceful_timeout = 30
That's it. The proxy discovers the app, starts it, registers blog.example.com routing, and (in production) issues a TLS certificate. In dev mode, blog.example.test is also available.