Deployment
Deploy your Soli applications to remote servers with a single command. Blue-green deployments via soli-proxy, or bundle your app into a single binary for zero-dependency deployment.
Parallel Deployment
Deploy to multiple servers simultaneously with automatic git pull and blue-green switching.
Quick Start
Create a deploy.toml file in your project root:
git_url = "https://github.com/your-org/your-project.git"
git_branch = "main"
git_folder = "www/"
[[servers]]
name = "prod-1"
username = "deploy"
ip = "192.168.1.100"
folder = "/var/www/myapp"
api_key = "your-api-key"
proxy_url = "https://proxy.example.com"
[[servers]]
name = "prod-2"
username = "deploy"
ip = "192.168.1.101"
folder = "/var/www/myapp"
api_key = "your-api-key"
proxy_url = "https://proxy.example.com"
Then run:
soli deploy
Configuration
Global Settings
git_url— Git repository URL (required)git_branch— Branch to deploy (default:main)git_folder— Subfolder to deploy, e.g.www/(default:/)
Server Settings
name— Server identifier for loggingusername— SSH usernameip— Server IP addressfolder— Deployment path on serverapi_key— soli-proxy API keyproxy_url— soli-proxy base URL
Deployment Flow
Deployment happens in two phases:
Phase 1: Sync Code (parallel)
All servers sync code simultaneously via SSH:
- SSH connect to
username@ip - Git clone (first deploy) or git pull (subsequent)
Phase 2: Migrations (first server only)
Database migrations run only on the first server to avoid conflicts:
- SSH to first server
- Run
soli db:migrate up
Phase 3: Deploy (parallel)
All servers trigger blue-green deployment simultaneously:
- POST to soli-proxy deploy API
- Automatic health checks and traffic switch
Asset cache during deploys
In production mode the server snapshots every .css and .js file under public/ into memory at boot. If new asset bytes land on disk before the binary restarts, the running process keeps serving its frozen snapshot — preventing the classic mismatch where in-flight HTML references one asset version while the disk has another. The next binary start reloads from disk. See Production Mode for details.
Requirements
- SSH key-based authentication (keys must be added to ssh-agent)
- The
solibinary must be in your PATH - soli-proxy running on each target server
- Valid API keys configured in soli-proxy
Usage
# Deploy from current directory
soli deploy
# Deploy from specific folder
soli deploy --folder /path/to/project
# Short form
soli deploy -f /path/to/project
Output Example
Deploying from /path/to/project...
Phase 1: Syncing code to all servers...
[prod-1] Connecting to deploy@192.168.1.100...
[prod-1] Folder exists, pulling latest changes...
[prod-1] Code synced ✓
[prod-2] Connecting to deploy@192.168.1.101...
[prod-2] Folder exists, pulling latest changes...
[prod-2] Code synced ✓
[prod-1] Running database migrations...
[prod-1] soli db:migrate up
[prod-1] Migrations completed ✓
Phase 2: Triggering blue-green deploy on all servers...
[prod-1] Triggering blue-green deploy...
[prod-1] Deploy started on slot green ✓
[prod-2] Triggering blue-green deploy...
[prod-2] Deploy started on slot blue ✓
✓ 2/2 servers deployed successfully
Bundle Deployment
For simpler, dependency-free deployment, bundle your application into a single .soli file. No source files needed on the server — only the soli binary and the bundle.
# Build the bundle
soli build my_app
# Deploy just the .soli file
scp my_app.soli deploy@server:/opt/my_app/
# Run from the bundle
ssh deploy@server "soli serve /opt/my_app/my_app.soli"
Bundle mode in deploy.toml
The soli deploy command supports mode = "bundle" to automatically SCP the bundle to all servers:
mode = "bundle"
bundle_source = "./my_app.soli"
[[servers]]
name = "prod-1"
username = "deploy"
ip = "192.168.1.100"
folder = "/opt/myapp"
proxy_url = "https://proxy.example.com"
How it works
soli build collects all .sl, .slv, .yml, .css, .js files into a single .soli bundle. When you run soli serve app.soli, the bundle is extracted to /tmp/soli_PID and the server boots normally. On the proxy side, set start_script = "soli serve /opt/myapp/my_app.soli --port $PORT --workers $WORKERS" in the app's app.infos. Migrations should be run locally before bundling — auto-migration is not supported in bundle mode.
Secrets stay out of the bundle. Dotfiles are never bundled, so ship your .env separately and drop it next to the .soli file — soli serve app.soli loads .env (and .env.{APP_ENV}) from the bundle's directory before boot. Variables already set in the process environment take precedence. Note that binary assets (images, fonts) in public/ are not bundled either — deploy those alongside or serve them from a CDN.
Encrypted & Protected Bundles
When you deploy to servers you don't fully control, encrypt the bundle so the source can't be copied off disk, and fetch the decryption key from a key server you own — revoking it there is a remote kill-switch.
| Flag | What it does |
|---|---|
--encrypt | Wraps the whole bundle in AES-256-GCM. The .soli file on disk is ciphertext; the key is needed to boot. |
--protect | Implies --encrypt, and additionally replaces every .sl source with its compiled binary AST — so even after decryption there is no readable source (comments and formatting are gone; identifiers and string literals remain, as in any bytecode). |
# The key is read from the environment — NOT passed as an argument.
export SOLI_BUNDLE_KEY="a-long-random-secret" # or point at your key server (below)
soli build my_app --protect
# Building bundle from my_app...
# Encrypting bundle (key from SOLI_BUNDLE_KEY)
# ✓ Bundle written to my_app.soli (1.9 KB) (protected: binary AST, encrypted)
# Then serve it — the key is resolved again the same way:
export SOLI_BUNDLE_KEY="a-long-random-secret" # (or a .env beside the bundle, below)
soli serve my_app.soli --port 8080
Command form. --encrypt and --protect are on/off flags that take no value, and --protect already implies --encrypt (you never pass both). The folder and the flags may appear in any order (soli build my_app --protect or soli build --protect my_app). There is no --key argument — a key on the command line would leak into shell history and the process list, so it is always taken from the environment. soli build --protect --encrypt abcd my_app does not work; use SOLI_BUNDLE_KEY=abcd soli build --protect my_app.
Where the key comes from
At both build and serve time the key is resolved in this order:
SOLI_BUNDLE_KEY— the key material itself (handy for local testing).SOLI_BUNDLE_AUTH_URL— a URL your server exposes. Soli issues aGET, sendingSOLI_BUNDLE_API_KEY(if set) as anx-api-keyheader; the response body is the key material (any encoding — it's hashed to a 256-bit key). This is the revocable path.
These can live in the .env next to the .soli file (loaded before decryption), so the deployed server only carries its API key, never the decryption key:
SOLI_BUNDLE_AUTH_URL=https://keys.example.com/my_app
SOLI_BUNDLE_API_KEY=srv-7f3c... # this host's identity; revoke it to lock the app out
Delete or disable that entry on your key server and the next boot fails with a clear error — a decommissioned or stolen host stops working. A wrong or rotated key fails the same way.
What this protects — and what it doesn't
Encryption protects your source against casual copying: a hosting provider's backup, a leaked .soli, or a co-tenant browsing the disk sees only ciphertext, and decrypted files live in a private RAM-backed directory (/dev/shm, mode 0700), removed on shutdown — never on persistent disk. --protect raises the cost of reconstructing the source (like shipping .pyc/.class instead of source). Because the key is fetched at boot, revoking it is a kill-switch. It does not protect against an attacker with root on the running server — they can read the key from the process environment or the decrypted files from RAM. If your threat model includes a hostile host, don't deploy the source there at all.
Operational notes
- Decrypted bundles extract to
/dev/shm. On a system without it (e.g. macOS) the boot is refused rather than silently writing plaintext to disk; setSOLI_BUNDLE_ALLOW_DISK=1to override (temp dir, still0700). - A
--protectbundle is locked to the exact Soli version that built it — the binary AST has no cross-version format guarantee. Serving it with a differentsolifails with a "rebuild the bundle" error. --protectdoes not yet support apps with anengines/directory.
Standalone Executables
--standalone goes one step further than a .soli bundle: it embeds the entire soli runtime into the artifact, producing a single native executable that boots your app directly — the target machine needs no soli install at all. It composes with --encrypt and --protect; key resolution and the key-server kill-switch work exactly as above.
# Build a self-contained executable (encrypted, no readable source)
soli build my_app --standalone --protect
# ✓ Standalone executable written to my_app (41.2 MB, 1.9 KB app bundle, protected: binary AST, encrypted)
# Platform: linux-x86_64 — run it with: ./my_app --port 8080
# Ship ONE file; run it with no soli install on the server
scp my_app deploy@server:/opt/my_app/
ssh deploy@server "/opt/my_app/my_app --port 8080 --workers 4"
Cross-platform builds
--target selects which platform's runtime to embed — build on your workstation, deploy anywhere. The matching official release runtime (same version as your soli) is downloaded, sha256-verified against the published checksum, cached under ~/.cache/soli/runtimes/, and embedded:
soli build my_app --standalone --protect --target linux-arm64
# ✓ Standalone executable written to my_app-linux-arm64 ...
# Supported targets (the published release artifacts):
# linux-amd64 linux-arm64 darwin-arm64
Air-gapped or mirrored environments can point SOLI_RELEASE_BASE_URL at their own artifact server (same layout: {base}/v{version}/soli-{target}.tar.gz + .sha256). Without --target the running soli binary itself is embedded — no network needed.
Running a standalone app
The executable accepts app-oriented flags — --port, --host, --workers, --dev, --version, --help — and reads its .env from the directory containing the executable, the same convention as a .soli bundle. For encrypted apps, provide SOLI_BUNDLE_KEY or SOLI_BUNDLE_AUTH_URL there.
Operational notes
- The runtime adds a ~40 MB baseline to the artifact regardless of app size.
- The protected-bundle version lock is satisfied by construction — runtime and bundle ship as a matched pair.
- Encrypted standalones inherit the RAM-only extraction contract:
/dev/shmorSOLI_BUNDLE_ALLOW_DISK=1. - Do not post-process the artifact —
strip,objcopy,upxor re-signing-tools that rewrite the file destroy the embedded bundle trailer. - macOS: building on a Mac ad-hoc re-signs the artifact automatically. A
darwin-arm64artifact cross-built on Linux must be re-signed before Apple Silicon will run it:codesign --force -s - my_app-darwin-arm64on a Mac. Usecodesignspecifically — the payload rides inside__LINKEDIT, and a signer that regenerates that segment (such asrcodesign) discards it. No Windows target is published. soli updatedoes not apply to standalone apps — a runtime fix means rebuilding and redeploying the artifact.