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
Immutable Releases — soli cloud
soli deploy updates a working tree in place. soli cloud does the opposite: it builds an artifact, lands it in a directory that is never modified again, and moves a symlink. Rolling back is repointing that symlink — no rebuild, and the bytes it returns to are provably the bytes that were serving before.
soli cloud deploy --domain crm.example.com # build, ship, health-gate, alias
soli cloud releases # what is on the host; * marks live
soli cloud rollback # back one release
soli cloud rollback --to 20260801T200000Z-a3f21c9
soli cloud deploy --dry-run # print the plan, change nothing
Servers come from the same deploy.toml the other commands read — a second file describing the same hosts is a second thing to keep in sync. The proxy admin key comes from SOLI_PROXY_API_KEY.
The layout
releases/<app>/20260801T200000Z-a3f21c9/ a build, never modified after it lands
releases/<app>/20260801T211909Z-b7e0d31/
sites/<app> -> releases/<app>/20260801T211909Z-b7e0d31
The release id is a UTC timestamp then a short commit, in that order, so lexical sort is chronological sort. That is what answers “the previous release”, and it has to stay right on the day two deploys land in the same minute — a timestamp alone collides, a SHA alone does not sort and repeats when the same commit is redeployed.
Releases live beside sites/, never inside it: inside, the proxy would discover every past release as an app and try to run all of them.
The order is the product
mkdir releases/<app>/<id>
upload .soli -> releases/<app>/<id> nothing points at it yet
repoint sites/<app> -> releases/<app>/<id> ln -sfn, atomic
deploy <app> (blue/green, health-gated)
health https://<domain>/up within 90s old slot still serving
alias <domain> -> <app> traffic moves here
prune oldest releases, never the live one
- Upload before repoint — a transfer that dies half way leaves an unused directory, not a live symlink pointing at half an app.
- Repoint before deploy — the proxy reads the app from
sites/<app>. Deploying first would start the release that is already live, and report success. - Health before alias — blue/green keeps the old slot serving until the new one answers. Moving the alias first sends real traffic at a release that may still be starting.
- Prune last, never the live one — a deploy that pruned first would have thrown away what it needs to roll back to. Pruning also skips the live release explicitly, because after a few rollbacks it can fall outside the newest five.
A failed deploy is not rolled back for you
Up to and including the upload, a failure is invisible — retry it. From the repoint onward the deployment is live, and the error names the release that is currently serving plus the exact command to go back.
It stops there on purpose. An automatic rollback in the middle of a half-applied change is a second uncontrolled change on top of the first, at the moment when least is known about what is wrong.
--dry-run prints the plan and changes nothing — and it is the same plan a real deploy executes, not a second description of it. It still reads the host, because a plan computed from an invented view is a guess rather than a dry run; if it cannot reach the host it says so before printing.
Preview Environments
soli env gives every branch its own running environment: a git worktree, its own subdomain, and its own SoliDB database created from your migrations and seeds. Soli Proxy supplies the runtime — port allocation, blue/green slots, health gating on /up, TLS — so an environment is just a site directory it discovers.
soli env up --branch feat/cart # create it
soli env list # what is running
soli env url feat/cart # print the URL
soli env down feat/cart # stop, unlink, remove worktree, drop the database
soli env up --branch feat/cart --server prod-1 # same, on a remote proxy
Configuration
Add a [preview] section to deploy.toml. Every key is optional.
[preview]
domain_base = "dev.example.com" # for --server environments
local_domain_base = "dev.example.test" # for local ones
sites_dir = "~/workspace/proxy/sites"
worktrees_dir = "~/.soli/previews"
env_template = ".env.preview.example"
build_command = "" # optional; Soli needs no npm step
seed = true
build_command is for projects that keep their own asset toolchain. A
soli new app has no package.json
and compiles its Tailwind with the binary Soli ships, so leave it empty unless you added a build step yourself
— npm ci on a project without a lockfile fails the preview build.
The env template must not be your production .env
env_template is copied into each worktree and then overlaid with the generated APP_ENV, SOLIDB_DATABASE and APP_BASE_URL. A template carrying production database credentials would let a preview migrate and seed straight into production — the one mistake here you cannot undo. Commit a credential-free template.
The .example suffix is also load-bearing. The generated .env sets APP_ENV=preview, and Soli layers .env.preview over .env; a template named .env.preview would be checked out into the worktree and silently win. soli env up refuses to start if it finds one.
Domains are flat
A preview is reachable at <branch>--<app>.<base> — for example feat-cart--demo.dev.example.com. The double dash is deliberate: DNS wildcards and the proxy's SNI resolver both match exactly one label deep, so a flat name means a single *.dev.example.com record and a single wildcard certificate cover every app and every branch. A nested <branch>.<app>.<base> scheme would need a record and a certificate per app.
Branch names are sanitised into a DNS label: lowercased, illegal characters replaced, and anything over 30 characters truncated with a hash of the full name so long task/… branches stay distinct.
Running under the proxy
Pass --strict-port in the app's start_script. By default soli serve scans upward for a free port when the one it was given is taken, which is helpful interactively but wrong under a supervisor: the proxy health-checks the port it assigned, so an app that quietly moved looks unhealthy and is quarantined after three such failures. --strict-port fails immediately instead.
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.
Pinned versions in production
A project that pins its interpreter (soli_version = "=2.0.3", see
Modules & Packages) switches to
that version wherever it runs, including under soli-proxy: the proxy starts an app with the app directory as the
working directory, so the pin resolves the same way it does on your machine.
- Provision the toolchain ahead of time, not at start-up. The proxy gives a new instance 30 seconds to pass its health check. A first start after changing a pin would spend 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. Fetch the version during the deploy step instead, or pre-populate the cache directory.
- The cache must be readable by the user the app runs as. It lives under
$XDG_CACHE_HOME/soli/runtimesor~/.cache/soli/runtimes, soHOMEin the app's environment has to belong to that user. A shared directory pointed at byXDG_CACHE_HOMEworks well when several apps run as different users.
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.