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"
# Paths with a dot segment (`/api/../admin`, `%2e%2e`, `..;`, `..\`) are answered 400 before
# any rule matches. So is an encoded slash (`%2F`) anywhere, unless the backend needs them as
# data (GitLab's `group%2Fproject`, S3-style keys); `..%2F` stays rejected either way.
allow_encoded_slash = false
[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.
For a single Arch/Omarchy workstation — wildcard .test DNS, binding 80/443 as a normal user, and getting Chrome/Brave to trust the dev CA — see docs/omarchy-dev-setup.md.
# 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
}
# HTTP Basic Auth on a route (hash from: soli-proxy hash-password)
secure.example.com -> http://localhost:9000 @auth:admin:$2b$12$...
# ...with carve-outs for callers that cannot send credentials.
# Exact path, or a prefix ending in *. Only meaningful next to @auth.
app.example.com -> http://localhost:8080 @auth:admin:$2b$12$... \
@noauth:/webhooks/stripe,/hooks/*
┌─────────────────────────────────────────────────────┐
│ 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 TOML file whose settings live at the top level; the optional [auth] section below is the only nested one. The file itself is optional too — 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
# Optional: HTTP Basic Auth on this app's domains.
[auth]
noauth = ["/webhooks/stripe", "/hooks/*"]
[auth.users]
admin = "$2b$12$..." # generate with: soli-proxy hash-password| 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. Whitespace-split, no shell. Single-tenant: a denylist rejects --privileged, --cap-add, --device, --security-opt, --userns, --volumes-from, --env-file, --group-add, joining the host or another container's namespaces, and docker-socket / root mounts in every spelling (-v/:/x, --mount type=bind,source=/, /./, /etc/..). Multi-tenant: only the allowlist below is accepted. |
docker_network |
string | "soli-apps" |
Docker network the container joins (created automatically if missing). A plain network name only: host and container:<id> are refused in every mode, since the value goes straight to --network. |
[auth.users] |
table | empty | username = "bcrypt hash" entries. 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. |
[auth] noauth |
list of strings | empty | Paths served without credentials, for callers that cannot send a password (a payment webhook, a health probe). Exact path, or a prefix ending in * — the same syntax as the @noauth: route directive, and the same fail-closed rule: a path carrying percent-encoding or a .. segment is never exempt. |
Apps are routed by the app manager rather than by proxy.conf rules — sync_routes prunes
static rules for app-managed domains — so a route's @auth cannot protect an app. [auth] is
the equivalent for apps, and it covers the app's derived domains (www.-stripped, .test in
dev) and any admin-managed alias pointing at it. A [auth] section the proxy cannot enforce as
written (an empty hash, a noauth pattern that does not compare literally) makes the app fail
to load and be skipped, rather than come up unprotected.
An app is started with a cleared environment, so nothing the proxy happens to inherit leaks into it. The child gets:
| Variable | Value |
|---|---|
PORT |
The blue/green slot's port. |
WORKERS |
The workers setting. |
HOME |
The home directory of the user the app runs as, read from the passwd database — not the proxy's own. |
PATH, LANG, TZ |
Copied from the proxy. |
HOME matters more than it looks. The proxy usually runs as root and drops
privileges to the app's user, so handing the child the proxy's own HOME
(/root under systemd) pointed every ~-resolved path at a directory the app
cannot read. That silently broke soli's package cache (~/.soli/packages), its
registry credentials, the Tailwind CLI it downloads to ~/.soli/bin, and the
cache for pinned interpreter versions.
A short allowlist also survives the clear, when it is set on the proxy:
| Variable | Why |
|---|---|
XDG_CACHE_HOME |
Points at a shared soli toolchain cache, so a pinned app does not download its interpreter on the server and several apps running as different users can share one. |
SOLI_RELEASE_BASE_URL |
An internal mirror for those downloads. |
SOLI_NO_PIN |
Operator override for a version pin, e.g. during an incident. |
HTTP_PROXY, HTTPS_PROXY, NO_PROXY (and lowercase) |
Outbound egress proxy. |
SSL_CERT_FILE, SSL_CERT_DIR |
Custom CA bundle. |
Anything else stays cleared. Put per-app configuration in the app's own .env,
not in the proxy's environment.
A Docker app gets the same treatment, minus the entries that name a host path: the proxy-family
variables, SOLI_RELEASE_BASE_URL and SOLI_NO_PIN are passed as -e flags into the
container, while XDG_CACHE_HOME, SSL_CERT_FILE and SSL_CERT_DIR are not (the container
cannot see those directories; bake a CA bundle into the image instead).
A Soli app can pin the exact interpreter it runs on, with
soli_version = "=2.0.3" in its soli.toml. The proxy needs no configuration
for this: it starts an app with the app directory as the working directory, and
soli resolves the pin from there — the same as on a developer machine.
Two things to get right on a server:
- Provision the toolchain during deployment, not at start-up. A new instance has 30 seconds to pass its health check. A first start after changing a pin spends part of that window downloading, and a slow link can push it over; the deploy then fails and succeeds on the retry, once the cache is warm.
- Make the cache readable by the app's user. With
HOMEnow resolved correctly this works by default, but several apps running as different users will each download their own copy. PointXDG_CACHE_HOMEat a shared directory readable by all of them to avoid that.
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.
The native start path is not a sandbox. It clears the environment, calls setsid() and sets
PR_SET_NO_NEW_PRIVS, but the process still reads the host filesystem — other tenants' site
directories, certs/, and this proxy's own config.toml, which contains the admin API key — and
can connect to anything on localhost. That is fine when you wrote every app, and unacceptable when
you did not.
[apps]
multi_tenant = true # default false; existing deployments are unchanged
tenant_memory = "512m"
tenant_cpus = "1.0"
tenant_user = "10000:10000"With it on:
- An app without a
docker_imagefails to deploy rather than falling back to the native path. Failing loudly is the point — a silent fallback would undo the isolation. - Every container gets
--read-only, anoexec,nosuidtmpfs at/tmp,--cap-drop ALL,--security-opt no-new-privileges,--pids-limit 256, plus the memory/cpu/user limits above. - These are appended after the app's own
docker_options, anddocker runhonours the last occurrence of a repeated flag, so an app cannot raise its own ceiling or run as root. The image is passed after a--terminator, anddocker_imagemust be a well-formed image reference, so neither it nor the start script can smuggle in further flags. docker_optionsis validated against an allowlist — anything not listed fails the deploy, naming the offending token. Every flag must carry a value (a trailing flag would swallow the platform's hardening). Permitted:-e/--env KEY=VALUE,-l/--label,--restart,--stop-timeout,--health-*-m/--memory,--cpus,--cpu-shares,--pids-limit,--shm-size(the platform's limits still win, see above)- no
-p/--publish: the proxy publishes the allocated slot port as127.0.0.1:$PORT:$PORTitself, so a tenant cannot bind a host port that belongs to another tenant's slot -v/--volume SRC:DST[:ro|rw]and--mount type=bind,source=SRC,target=DST[,readonly]only whenSRCcanonicalises (symlinks resolved) to the app's own site directory — the directory itself, not a path inside it. Everything under the site directory is writable by the tenant's running container, which could swap a sub-directory for a symlink between the check and docker's own path resolution at mount time; the site directory's own path has no tenant-writable component. The canonical path is what reachesdocker run, never the tenant's spelling. Named volumes, other mount types, propagation and relabel options are rejected.
nameanddomaininapp.infosare bound to the site directory:namemust equal it,domainmust be it or itswww.twin (or empty). A tenant cannot claim another site'sHostor take over another app's entry; a directory whose manifest breaks the rule is skipped and logged. Names starting with_are reserved for bundled apps (_admin) in every mode.name,domainandhealth_checkare checked at load time in every mode: hostname characters (plus_, for existingmy_app.example.comdirectories) for the first two, an absolute URL path for the third.
Docker has a long history of container escapes. This raises the cost of one; it is not a VM boundary. For genuinely hostile code, treat it as the first step toward gVisor or Firecracker.
Certificate files in certs/ are scanned at startup. To install one added or renewed since —
a wildcard from an external DNS-01 client, or a mkcert certificate for local development —
rescan without restarting:
curl -X POST http://127.0.0.1:9090/api/v1/certs/reload -H "X-Api-Key: $KEY"The reload is visible to live TLS handshakes immediately; connections are not dropped. Prior to this, installing a certificate meant a full restart.
ACME-issued certificates do not need this — the renewal task injects them into the resolver directly. Call it from your renewal hook when an external tool writes the files instead.
A site directory gives an app exactly one domain, which ties the URL to the checkout behind it. Aliases break that coupling: several domains can point at one running app, and repointing an alias is an atomic map swap — no restart, no rebuild, effective on the next request. That is what makes instant rollback and per-branch preview URLs possible.
# Point a domain at a running app
curl -X POST http://127.0.0.1:9090/api/v1/apps/myapp.example.com/aliases \
-H 'Content-Type: application/json' -H "X-Api-Key: $KEY" \
-d '{"domain":"www.example.com"}'
curl http://127.0.0.1:9090/api/v1/aliases # domain -> app
curl -X DELETE http://127.0.0.1:9090/api/v1/apps/myapp.example.com/aliases/www.example.com \
-H "X-Api-Key: $KEY"Every non-GET request must carry X-Api-Key, or — when no key is configured, or with Basic
auth — an X-Requested-With header of any value. That is the CSRF guard: an HTML form cannot
set either header, so a page the operator happens to visit cannot drive the admin API with
the browser's cached credentials or the open loopback default.
Rollback is the same POST with a different app: send {"domain":"www.example.com"} to the
previous deployment and traffic moves back, with both processes left running.
Notes:
- Aliases are stored in
run/aliases.jsonand reloaded at startup. A missing or malformed file is ignored with a log line rather than being fatal — aliases are additive routing, so losing them degrades to site-domain-only. - An alias can never shadow an app's own site domain; that is rejected, since otherwise routing would depend on map iteration order.
- Aliases are registered for ACME exactly like site domains, so a public alias gets its own certificate automatically.
- Traffic arriving on an alias is attributed to its app, so per-app metrics and request-triggered failover behave the same as on the site domain.
_admincannot be aliased: it does no auth of its own and is only safe behind the admin listener.
Touching restart.txt at the root of a site triggers a zero-downtime blue/green deploy of
that app — the same thing soli-proxy restart <app> does, with no SSH-side knowledge of the
app name required:
# at the end of any deploy script
rsync -rzuv ./ server:/home/rocky/sites/myapp.example.org/
ssh server 'touch /home/rocky/sites/myapp.example.org/restart.txt'- The trigger is polled, not watched. Sites are usually symlinks into out-of-tree
repositories and inotify does not traverse symlinks, so the
proxy.conf/sites watcher never sees files inside them. - Detection latency is the poll interval (2s by default).
- The first poll after a daemon start only records a baseline: an already-present
restart.txtdoes not cause a deploy on startup. - Creating the file for the first time triggers a deploy; deleting it does not.
Both the file name and the interval are configurable under [apps] in config.toml. Setting
restart_trigger_poll_secs = 0 disables the mechanism:
[apps]
restart_trigger_file = "restart.txt"
restart_trigger_poll_secs = 2When 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.
When an app fails to start — the process cannot be spawned, or the new slot never passes its health check — the proxy stops trying instead of restarting it in a loop:
- The new slot is killed and marked
Failed. The previous slot keeps serving, so a bad deploy never takes the site down. - The app is put in quarantine: the 30s health loop, the process-exit monitor, and the request-triggered failover all skip it. An app is also quarantined after 3 consecutive unexpected exits.
GET /api/v1/appsand/api/v1/apps/{name}report"quarantined": true, and aStatusChangedSSE event with statusquarantinedis emitted.
Quarantine is lifted by any explicit deploy: touching restart.txt,
soli-proxy restart <app>, or POST /api/v1/apps/{name}/restart|deploy. The failure reason
and the path to the app's log (run/logs/<app>/<slot>.log) are logged at error level.
Install soli-proxy as a systemd service for automatic restart on failure:
# Copy the service file and adjust the paths in it
sudo cp scripts/soli-proxy.service /etc/systemd/system/
sudo mkdir -p /var/lib/soli-proxy /etc/soli-proxy
# 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. Its three load-bearing lines:
WorkingDirectory=/var/lib/soli-proxy
ExecStart=/usr/local/bin/soli-proxy \
--conf /etc/soli-proxy/proxy.conf \
--sites-dir /srv/sites| Setting | Notes |
|---|---|
--conf |
Points at proxy.conf (routing), not config.toml. Short form -c. There is no --config flag. |
config.toml |
Never passed as an argument — it is read from the same directory as --conf, i.e. /etc/soli-proxy/config.toml above. |
--sites-dir |
The only way to set the sites location. There is no equivalent key in config.toml. Defaults to ./sites. |
WorkingDirectory is mandatory: the runtime state paths are relative and cannot be relocated
by flag or environment variable. With the unit above they resolve to:
| Path | Contents |
|---|---|
/var/lib/soli-proxy/run/logs/<app>/<blue|green>.log |
stdout + stderr of each app slot (created automatically) |
/var/lib/soli-proxy/run/app_state.json |
which slot currently serves each app |
/var/lib/soli-proxy/run/ports.lock |
blue/green port assignments |
/var/lib/soli-proxy/certs/ |
TLS cache, when [tls].cache_dir is ./certs |
Without WorkingDirectory, systemd starts the process in / and the proxy tries to write
/run and /certs.
Two things to avoid:
- Do not add
-d/--daemontoExecStartunderType=simple. It forks and detaches, so systemd loses the process and restarts it in a loop. In the foreground the proxy's own log goes to the journal (journalctl -u soli-proxy -f). SOLI_LOG_DIR/SOLI_PID_DIRonly affectproxy.logandproxy.pid, andproxy.logis only written on the-dpath. They have no effect on the per-apprun/logs/above.
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