ESC
Type to search...
S
Soli Docs

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 logging
  • username — SSH username
  • ip — Server IP address
  • folder — Deployment path on server
  • api_key — soli-proxy API key
  • proxy_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 soli binary 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.

FlagWhat it does
--encryptWraps the whole bundle in AES-256-GCM. The .soli file on disk is ciphertext; the key is needed to boot.
--protectImplies --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:

  1. SOLI_BUNDLE_KEY — the key material itself (handy for local testing).
  2. SOLI_BUNDLE_AUTH_URL — a URL your server exposes. Soli issues a GET, sending SOLI_BUNDLE_API_KEY (if set) as an x-api-key header; 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; set SOLI_BUNDLE_ALLOW_DISK=1 to override (temp dir, still 0700).
  • A --protect bundle is locked to the exact Soli version that built it — the binary AST has no cross-version format guarantee. Serving it with a different soli fails with a "rebuild the bundle" error.
  • --protect does not yet support apps with an engines/ 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/runtimes or ~/.cache/soli/runtimes, so HOME in the app's environment has to belong to that user. A shared directory pointed at by XDG_CACHE_HOME works 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/shm or SOLI_BUNDLE_ALLOW_DISK=1.
  • Do not post-process the artifact — strip, objcopy, upx or 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-arm64 artifact cross-built on Linux must be re-signed before Apple Silicon will run it: codesign --force -s - my_app-darwin-arm64 on a Mac. Use codesign specifically — the payload rides inside __LINKEDIT, and a signer that regenerates that segment (such as rcodesign) discards it. No Windows target is published.
  • soli update does not apply to standalone apps — a runtime fix means rebuilding and redeploying the artifact.

Next Steps