A high-performance, production-ready forward proxy server built in Rust with HTTP/2+ support, automatic HTTPS, and hot config reload.
- HTTP/2+ Support: Native HTTP/2 with automatic fallback to HTTP/1.1
- Automatic HTTPS: Self-signed certificates for development, Let's Encrypt for production
- Hot Config Reload: Update configuration without dropping connections
- Simple Configuration: Custom config format with comments support
- Load Balancing: Round-robin, weighted, and health-checked backends
- WebSocket Support: Full WebSocket proxy capabilities
- Middleware: Authentication (Basic, API Key, JWT), Rate Limiting, JSON Logging
- Health Checks: Kubernetes-compatible liveness and readiness probes
- App Health Monitoring: Automatic health checks with auto-restart for managed apps
- High Performance: Built on Tokio and Hyper for maximum throughput
# Build and run in dev mode
cargo run --bin soli-proxy -- --dev
# With custom config and sites directory
cargo run --bin soli-proxy -- --conf ./my-proxy.conf --sites-dir ./my-sites# Build release
cargo build --release
# Run in production mode (requires Let's Encrypt config)
./target/release/soli-proxy
# Run as daemon
./target/release/soli-proxy -d
# With custom paths
./target/release/soli-proxy -c /etc/proxy.conf --sites-dir /var/sitessoli-proxy [OPTIONS] [COMMAND]
Options:
-c, --conf <CONF> Config file [default: ./proxy.conf]
-d, --daemon Run as daemon
--dev Development mode
--watch <WATCH> Watch config & sites for changes [default: true]
--sites-dir <SITES_DIR> Sites directory [default: ./sites]
-h, --help Print help
-V, --version Print version
App lifecycle commands operate on apps discovered in --sites-dir and can be
run while the proxy is running (they read shared state from ./run):
soli-proxy deploy [-c <conf>] <app_name> # Blue-green deploy: build & switch to the other slot
soli-proxy restart [-c <conf>] <app_name> # Restart the currently active slot
soli-proxy stop [-c <conf>] <app_name> # Stop the app
soli-proxy logs [-c <conf>] <app_name> # Print deployment logs for both slots
Other subcommands:
soli-proxy tui [-c <conf>] [--sites-dir <DIR>] [--dev] # Interactive terminal UI
soli-proxy update [--reinstall] # Self-update from GitHub releases
[server]
bind = "0.0.0.0:8080"
https_port = 8443
worker_threads = "auto"
[tls]
mode = "auto" # "auto" for dev, "letsencrypt" for production
[letsencrypt]
email = "admin@example.com"
staging = false
[logging]
level = "info"
format = "json"
log_endpoints = true # log one line per request (method, path, host, status, latency)
[metrics]
enabled = true
endpoint = "/metrics"
[health]
enabled = true
liveness_path = "/health/live"
readiness_path = "/health/ready"
[rate_limiting]
enabled = true
requests_per_second = 1000
burst_size = 2000The proxy stores certificates flat in tls.cache_dir (default ./certs).
| Filename pattern | What it is | Example |
|---|---|---|
<domain>.cert.pem + <domain>.key.pem |
Per-domain cert. Matches SNI exactly. The cert MUST list <domain> in its SANs. |
crm.example.com.cert.pem |
_wildcard.<parent>.cert.pem + _wildcard.<parent>.key.pem |
Wildcard cert covering *.<parent>. Matches one label deep per RFC 6125. The cert MUST list *.<parent> in its SANs. |
_wildcard.example.com.cert.pem covers crm.example.com, api.example.com, etc. |
self-signed.cert.pem + self-signed.key.pem |
Reserved fallback name. Used when no per-domain or wildcard match. Don't use for a real domain. | (auto-generated) |
Resolution order on a TLS handshake: exact-match certs/<sni>.cert.pem β wildcard certs/_wildcard.<parent>.cert.pem (one label deep) β self-signed fallback. Cert files are scanned once at startup β SIGUSR1 and the admin reload endpoint only refresh routing, so adding/replacing a cert file requires a full proxy restart.
For local dev with mkcert β install the local CA on your machine (mkcert -install) and drop wildcard certs in:
mkcert "*.example.test"
mv _wildcard.example.test.pem ./certs/_wildcard.example.test.cert.pem
mv _wildcard.example.test-key.pem ./certs/_wildcard.example.test.key.pemAfter restart, every *.example.test alias is served with a Mac/Linux-trusted cert (no browser warning).
See docs/tls-mkcert.md for the full mkcert workflow, the CA-rotation pitfall (identical issuer string, different key β bad signature), and the scripts/diag-mkcert-mac.sh / scripts/regen-mkcert-and-deploy.sh helpers.
# Comments are supported
default -> http://localhost:3000
/api/* -> http://localhost:8080
/ws -> ws://localhost:9000
# Load balancing
/api/* -> http://10.0.0.10:8080, http://10.0.0.11:8080, http://10.0.0.12:8080
# Weighted routing
/api/heavy -> weight:70 http://heavy:8080, weight:30 http://light:8080
# Regex routing
~^/users/(\d+)$ -> http://user-service:8080/users/$1
# External https backend (Host/Origin are rewritten to the target's own
# authority; the client's host is forwarded via X-Forwarded-Host)
mirror.example.com -> https://origin.example.net
# Permanent redirect to a new canonical domain (301, path and query preserved)
old.example.com -> redirect://new.example.com
# Headers to add
headers {
X-Forwarded-For: $client_ip
X-Forwarded-Proto: $scheme
}
# Authentication
/auth/* {
auth: basic
realm: "Restricted"
}
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Soli Proxy Server β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β βββββββββββββββ βββββββββββββββ ββββββββββββββββ β
β β Config β β TLS/HTTPS β β HTTP/2+ β β
β β Manager β β Handler β β Listener β β
β β (hot reload)β β (rcgen/LE) β β (tokio/hyper)β β
β βββββββββββββββ βββββββββββββββ ββββββββββββββββ β
β β β β β
β ββββββββββββββββββΌββββββββββββββββ β
β β β
β ββββββββΌβββββββ β
β β Router β β
β β (matching) β β
β βββββββββββββββ β
β β β
β ββββββββββββββββββΌβββββββββββββββββ β
β β β β β
β ββββββΌβββββ βββββββΌββββββ ββββββΌβββββ β
β β Auth β β Rate β β Logging β β
β β Middle β β Limit β β JSON β β
β βββββββββββ βββββββββββββ βββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
soli-proxy [dev|prod] [OPTIONS]
Modes:
dev Development mode with self-signed certificates
prod Production mode with Let's Encrypt support
Environment Variables:
SOLI_CONFIG_PATH Path to proxy.conf (default: ./proxy.conf)soli-proxy/
βββ Cargo.toml
βββ config.toml # Main configuration
βββ proxy.conf # Proxy rules
βββ src/
β βββ main.rs # Entry point
β βββ lib.rs # Library root
β βββ bin/
β β βββ httptest.rs # End-to-end proxy throughput test
β β βββ hash-password.rs
β βββ config/ # Config parsing & hot reload
β βββ server/ # HTTP/HTTPS server
β βββ admin/ # Admin API server
β βββ acme/ # ACME / Let's Encrypt
β βββ tls.rs # TLS & certificate management
β βββ circuit_breaker.rs
β βββ metrics.rs # Prometheus-format metrics
β βββ pool.rs # Connection pool
β βββ auth.rs # Authentication
β βββ app/ # App management & blue-green deploy
β βββ shutdown.rs # Graceful shutdown
βββ benches/
β βββ routing.rs # Rule matching & scaling benchmarks
β βββ components.rs # Circuit breaker, load balancer, metrics
β βββ config_parsing.rs # Config file parsing benchmarks
βββ scripts/ # Helper scripts
Built on Tokio and Hyper with SO_REUSEPORT multi-listener architecture.
| Endpoint | Throughput | p50 | p95 | p99 |
|---|---|---|---|---|
| Proxy (default route β backend) | 228,196 req/s | 0.64 ms | 0.92 ms | 1.20 ms |
| Admin API (GET /api/v1/status) | 508,049 req/s | 0.37 ms | 0.58 ms | 0.71 ms |
| Component | Operation | Time |
|---|---|---|
| Routing | Domain match | 54 ns |
| Routing | Regex match | 57 ns |
| Routing | 500 rules worst-case | 587 ns |
| Circuit breaker | is_available (1k targets) | 18 ns |
| Load balancer | select_index (round-robin) | 1.6 ns |
| Metrics | record_request | 29 ns |
| Metrics | format_metrics (1k requests) | 601 ns |
| Config parsing | 5 rules | 6.9 Β΅s |
| Config parsing | 100 rules | 45 Β΅s |
# Criterion micro-benchmarks (routing, components, config parsing)
cargo bench
# End-to-end proxy throughput test
cargo run --release --bin httptest -- --requests 50000 --concurrency 200Configuration changes are detected automatically:
- File watcher monitors proxy.conf
- On change, config is reloaded atomically
- New connections use new config
- Existing connections continue with old config
- Graceful draining of old connections
When apps are managed by the proxy (via the sites directory, e.g. ./www), each app directory must be named after its domain (must contain at least one dot, e.g. myapp.example.org/) and may contain an app.infos file describing how to run it.
app.infos is a flat TOML file (no section headers β keys live at the top level). The file is optional; if missing or empty, defaults are used.
# www/proxy.solisoft.net/app.infos
name = "proxy.solisoft.net"
domain = "proxy.solisoft.net"
start_script = "soli serve . --port $PORT --workers $WORKERS"
workers = 2
stop_script = ""
health_check = "/health"
graceful_timeout = 30
port_range_start = 20000
port_range_end = 30000| Field | Type | Default | Description |
|---|---|---|---|
name |
string | directory name | Logical app name (used in logs, admin API). |
domain |
string | directory name (when auto-detected) | Domain the app serves. Matched against the Host header. |
start_script |
string | auto-detected (see below) | Command used to launch the app. Supports $PORT and $WORKERS substitution. Parsed without a shell β no pipes/redirects/globs. |
stop_script |
string | none | Optional command to run when stopping the app. |
health_check |
string | "/health" (or "/" when auto-detected) |
HTTP path the proxy polls every 30s to decide if the app is alive. |
graceful_timeout |
int (seconds) | 30 |
Time given to the old process to exit cleanly during a blue/green swap. |
drain_delay |
int (seconds) | 5 |
Time to keep the old process draining existing connections before shutdown. Clamped to < graceful_timeout (set to graceful_timeout / 2 if too large). |
port_range_start |
int | 20000 |
Lower bound of the port range used to allocate blue/green slots. |
port_range_end |
int | 30000 |
Upper bound of the port range. |
workers |
int | 1 |
Number of worker processes the app should spawn. Exposed as $WORKERS in start_script and as the WORKERS env var. |
user |
string | [apps].default_user from config.toml |
OS user to drop privileges to (required when running the proxy as root). |
group |
string | [apps].default_group from config.toml |
OS group to drop privileges to. |
docker_image |
string | none | If set, the app runs inside Docker using this image instead of a host process. |
docker_options |
string | none | Extra flags appended to docker run (validated against an allowlist). |
docker_network |
string | "soli-apps" |
Docker network the container joins (created automatically if missing). |
start_script supports two placeholders, replaced before the process is launched:
$PORTβ the slot port allocated by the proxy (fromport_range_start..port_range_end).$WORKERSβ the value of theworkersfield.
The same values are also exported as environment variables (PORT, WORKERS, and HEALTH_CHECK for Docker), so scripts that don't use $VAR substitution can still read them from the environment.
If start_script is omitted, the proxy tries to infer one from the app directory:
- Soli app β when
app/andapp/models/exist:start_scriptβsoli serve . --port $PORT --workers $WORKERS(with--devappended in dev mode)health_checkβ/
- LuaOnBeans app β when a
luaonbeans.orgbinary exists in the directory:start_scriptβ./luaonbeans.org -D . -p $PORT -shealth_checkβ/
If no start_script is set and neither layout is detected, deployment fails with No start script configured.
When apps are managed by the proxy, it automatically:
- Polls each app's
health_checkpath every 30 seconds - Auto-restarts any app that fails (connection refused, timeout, etc.)
- Only restarts on actual failures, not on non-2xx responses
See App Configuration above for how to set health_check per app.
Install soli-proxy as a systemd service for automatic restart on failure:
# Copy the service file
sudo cp scripts/soli-proxy.service /etc/systemd/system/
# Reload systemd
sudo systemctl daemon-reload
# Enable and start
sudo systemctl enable soli-proxy
sudo systemctl start soli-proxy
# Check status
sudo systemctl status soli-proxy
# View logs
journalctl -u soli-proxy -fThe service file is located at scripts/soli-proxy.service.
This project uses Conventional Commits for semantic release. Use the format type(scope): description (e.g. feat(proxy): add retry). Allowed types: feat, fix, docs, style, refactor, perf, test, chore, ci, build.
Optional setup:
- Commit template (reminder in the message box):
git config commit.template .gitmessage - Auto-fix non-conventional messages (prepend
chore:if the first line doesnβt match):cp scripts/git-hooks/prepare-commit-msg .git/hooks/prepare-commit-msg && chmod +x .git/hooks/prepare-commit-msg
MIT