Guide
I kept meeting the same wall: a system that tries to describe every application by configuration grows a large exception list. Most of the time I needed a script with steps run one after another on a machine I can SSH to. If a step fails, I fix it in the script or run it by hand.
Orbit has no remote daemon. It runs hooks on the remote over SSH and keeps slot env, data, and run between deploys.
Docker, systemd, a plain process — any of them work. Obey the slot layout and the variables Orbit exports into hooks. See Deployment hooks.
Project, host, slot
Project — the repository and its ops/ scripts.
Host — the remote machine Orbit reaches over SSH.
Slot — uploaded source plus persistent configuration, secrets, data, and runtime files.
One project can run in several slots — production, staging, a preview that expires. The slot supplies env and paths; hooks decide how to build, start, and stop.
On the host, each slot is /srv/orbit/slots/<name>/ with src (uploaded project; ops/deploy lives here), data, env (mode 0600), and run. Name slots like public hostnames — app.example.com, not production. Then SLOT_NAME is the hostname hooks publish to Caddy.
src changes on every deploy. data, env, and run stay until you purge the slot. Layout details: Deployment hooks.
First deploy
From a clean machine to a running slot — in order.
Install Orbit
On your machine: Python 3.14 or newer, Git, OpenSSH, and rsync.
Run from inside a Git repository. Orbit finds the repo root from your current directory and uploads the whole work tree — not just the folder you are in.
The Python distribution is named stario-orbit. The command is orbit. The unrelated orbit package on PyPI is not this project.
uv tool install stario-orbit# or: uv add --dev stario-orbit# or: pip install stario-orbitorbit --helpFrom a checkout:
uv syncuv run orbit --helpSSH target
<host> is whatever you would pass to ssh or rsync — a Host alias, user@hostname, or a bare hostname. Orbit does not parse it further.
An OpenSSH alias keeps commands short:
Host ship HostName host.example.com User deployThis guide uses ship. You can also pass deploy@host.example.com or host.example.com directly.
ssh shipPrepare the host
Run orbit setup once per host before you create slots.
Host requirements (Ubuntu): bash, rsync, Caddy (system service), systemd-run, systemctl, loginctl, and sudo. Podman and other runtimes are hook-specific — see Podman.
orbit setup creates /srv/orbit, configures slot permissions, points system Caddy at /srv/orbit/Caddyfile, and enables user systemd timers for slot expiry. Existing Caddy sites on that host stop unless you move them into Orbit slots — use a dedicated or disposable host.
orbit setup ship --email you@example.comSetup uses interactive SSH (sudo prompts). Open a new SSH session after setup so the shell picks up the orbit group. Slot expiry uses a user systemd timer.
Add the deployment hook
From the application repository:
orbit initgit add ops && git commit -m 'Add Orbit hooks'orbit init walks bundled recipes in order: Stario, a Python HTTP app, then static files. The first recipe that recognizes the project writes ops/. Pass --recipe to choose (stario-podman, python-podman, static-caddy). --list shows matches without writing.
You can still write ops/deploy by hand. Optional ops/status and ops/remove. Orbit runs them with bash — see Hooks.
Orbit checks that ops/deploy exists before upload. Hook rules and variables are on Deployment hooks. See Podman for the Podman recipe.
Create, env, deploy, status
This example uses app.example.com as the slot name and public hostname.
orbit create ship app.example.comSet slot env before the first deploy:
orbit env ship app.example.com --from .env.productionorbit deploy ship app.example.comorbit status ship app.example.comDeploy requires a clean Git work tree so the upload matches a revision. Pass --force to include uncommitted files.
Success: deploy exits 0. With ops/status, status should report healthy (exit 0). Without ops/status, orbit status prints unknown and still exits 0 — add a status hook when you need a real check.
Do not keep secrets in the synchronized repository. A tracked .env is uploaded with the source. Use orbit env for slot values. Your hook must pass $SLOT_ENV to the app (--env-file, EnvironmentFile=, or equivalent) — Orbit does not source it into the hook.
Point DNS at the host and publish TLS through Caddy in your hook. See Podman for a full example.
Day to day
Once a slot is running, you mostly ship changes with orbit deploy — it uploads your tree and runs ops/deploy on the host again. What gets synced, which variables the hook sees, and how cutover works are in Deployment hooks.
Local target config
After a successful create or deploy, Orbit writes a branch mapping on your machine (~/.config/orbit/config.json). When origin is configured, Orbit uses that remote URL. Detached HEAD has no mapping — pass the target explicitly. Shape: CLI reference.
From a branch that has a mapping:
orbit deployorbit statusTemporary previews
Same project and hooks as production. On a feature branch:
orbit create ship {}.example.com --ttl 6h# created ship calm-otter.example.comorbit deploy ship calm-otter.example.comThat branch maps to the preview slot. expires_at is stored locally; Orbit drops the mapping after the TTL (max 30 days). A user systemd timer purges the remote slot; it does not survive host reboot.