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.

bash
uv tool install stario-orbit
# or: uv add --dev stario-orbit
# or: pip install stario-orbit
orbit --help

From a checkout:

bash
uv sync
uv run orbit --help

SSH 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:

text
Host ship
    HostName host.example.com
    User deploy

This guide uses ship. You can also pass deploy@host.example.com or host.example.com directly.

bash
ssh ship

Prepare 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.

bash
orbit setup ship --email you@example.com

Setup 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:

bash
orbit init
git 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.

bash
orbit create ship app.example.com

Set slot env before the first deploy:

bash
orbit env ship app.example.com --from .env.production
bash
orbit deploy ship app.example.com
orbit status ship app.example.com

Deploy 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:

bash
orbit deploy
orbit status

Temporary previews

Same project and hooks as production. On a feature branch:

bash
orbit create ship {}.example.com --ttl 6h
# created ship calm-otter.example.com
orbit deploy ship calm-otter.example.com

That 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.