Orbit
Orbit is a small remote deployment runner. It adds a repeatable structure to
the familiar rsync plus remote shell script approach.
Guides and reference: stario.dev/orbit
When you run orbit deploy, Orbit:
- Selects the tracked and non-ignored files in your local Git work tree.
- Mirrors those files over SSH into a named slot on a host you control.
- Runs
ops/deployfrom your repository on that host.
That is the deployment contract. Orbit does not decide how to build, run, or reload your application. Your script contains those steps.
The Orbit CLI is written in Python, but the application does not have to be. The remote contract uses Bash. You can deploy a Python service, a Go binary, a static site, or another application. You can run it with Podman, Docker, systemd, or your own process manager.
Orbit provides the convention around those steps:
- named deployment slots on one or more SSH hosts
- an exact synchronized source tree for each slot
- separate paths for source, durable data, environment values, and runtime files
- repository-owned deploy, stop, and status hooks
- environment management, status checks, removal, and optional preview expiry
You keep the deployment decisions in short scripts beside the application. There is no remote Orbit daemon, hidden build pipeline, telemetry service, or MCP service to understand.
A slot is one named deployment target under /srv/orbit/slots/. It keeps the
synchronized source, environment values, durable data, and runtime files in
separate paths.
orbit init
orbit setup ship --email you@example.com
orbit create ship app.example.com
orbit env ship app.example.com --from .env.production
orbit deploy ship app.example.com
orbit status ship
ship is the SSH destination. In these examples it is a Host alias
from ~/.ssh/config. Orbit passes that value to ssh and rsync.
You can also pass user@hostname or a hostname. See Commands
for the shapes.
After a successful create or deploy, Orbit stores the git remote + branch →
host + slot mapping in ~/.config/orbit/config.json (or $XDG_CONFIG_HOME).
Later you can run orbit deploy with no host/slot and Orbit uses that mapping.
Requirements
Local:
- Python 3.14 or newer
- Git
- OpenSSH
- rsync
Ubuntu host:
- bash
- OpenSSH
- rsync
- a user systemd manager (for optional TTL expiry)
- Caddy with a system service
sudoaccess during setup
Install
The PyPI name is stario-orbit. The command and the Python import stay
orbit. pip install orbit installs a different, unrelated package.
uv tool install stario-orbit
# or: uv add --dev stario-orbit
# or: pip install stario-orbit
orbit --help
From a checkout:
uv sync
uv run orbit --help
Host setup
orbit setup opens an SSH session and runs a script that uses sudo.
Use a dedicated Ubuntu host, or a disposable one. Do not run setup on a
machine that already serves sites you need to keep.
The current host setup is opinionated about the slot layout and Caddy integration. It is not opinionated about the application runtime. Deployment hooks can use the prepared Caddy paths, or ignore them and manage the application with another runtime or service configuration.
Setup does this:
- Creates
/srv/orbitand the system grouporbit - Adds the deploy user and the
caddyuser to that group - Writes
/srv/orbit/Caddyfile(ACME email + slot imports) - Installs a systemd drop-in so the system Caddy service loads only
that file — not
/etc/caddy/Caddyfile - Restarts Caddy
- Enables user lingering for TTL expiry
- Checks that the deploy user can reload Caddy
Existing Caddy sites on that host stop unless you move them into Orbit slots. Re-running setup repairs permissions from older Orbit versions and writes the drop-in again.
Pass --email, or setup prompts for it. Setup uses a TTY so sudo can
prompt. Open a new SSH session after setup so your shell sees group
orbit. Later SSH and rsync calls use BatchMode=yes.
Anyone who can SSH as the deploy user can read slot environment files and run hooks. See SECURITY.md.
Commands
orbit setup <host> [--email you@example.com]
orbit init [--recipe NAME] [--force] [--list]
orbit create [<host> <slot>] [--ttl 6h]
orbit create <host> {}.example.com [--ttl 6h]
orbit deploy [<host> <slot>] [--dry-run] [--force]
orbit status [<host> [<slot>]]
orbit env [<host> <slot>] [--set KEY=VALUE | --from FILE | --edit]
orbit remove [<host> <slot>] [--purge] [--yes]
Provide both host and slot, or neither. Neither means: read the mapping
for this repository and current branch from your user config. When origin
is configured, Orbit keys config by that remote URL; otherwise by the repo
path on disk.
A single argument is only valid for status <host> (list every slot on that
host).
<host> is the SSH destination. Orbit does not parse it further. Typical
shapes:
ship Host alias in ~/.ssh/config (recommended)
deploy@host.example.com user and hostname
deploy@203.0.113.10 user and address
host.example.com hostname (OpenSSH default user)
A Host alias keeps the real address and user in SSH config:
Host ship
HostName host.example.com
User deploy
The value must not contain / or begin with -. The remote /srv/orbit
tree owns the slots. Host aliases are personal, so the mapping lives in
your user config — not in the repository.
create accepts one {} in the slot name. Orbit replaces it with a short
readable token such as calm-otter. Generated names treat any remnant slot
tree as taken, so a non-purge remove does not hand durable data or env to
the next preview. An explicit create of the same named slot may reuse retained
data and env when src is gone.
--ttl schedules an automatic purge on the host after a duration such as
30m, 6h, or 2d (maximum 30d). Manual remove cancels that timer.
Transient TTL timers do not survive a host reboot. Orbit exports SLOT_TTL to
hooks; preview basic auth is optional and belongs in ops/deploy (see
examples/podman-slot).
First deployment
orbit deploy uploads the Git-selected files for a clean work tree —
the same tree as HEAD. Local modifications and untracked files stop
the deploy unless you pass --force. A local .env that is not in
.gitignore is included only with --force. Put application secrets
in the slot environment with orbit env, not in the repository tree.
orbit init
git add ops && git commit -m 'Add Orbit hooks'
orbit setup ship --email you@example.com
orbit create ship app.example.com
orbit env ship app.example.com --from .env.production
orbit deploy ship app.example.com
orbit status ship
# later, on the same branch:
orbit deploy
orbit init walks bundled recipes and writes ops/ for the first match
(Stario, then a Python HTTP app, then static files). Pass --recipe to
choose. orbit deploy uploads a clean Git revision. Pass --force to
include uncommitted files.
Preview with a generated name and automatic cleanup:
orbit create --ttl 6h ship {}.example.com
# created ship calm-otter.example.com
# expires after 6h
orbit deploy ship calm-otter.example.com
# example ops/deploy may print preview basic auth on first deploy
User config
Path: ~/.config/orbit/config.json (or $XDG_CONFIG_HOME/orbit/config.json).
Orbit keys entries by a normalized git remote URL and branch name:
{
"version": 1,
"remotes": {
"github.com/acme/app": {
"branches": {
"main": {
"host": "ship",
"slot": "app.example.com"
},
"preview": {
"host": "ship",
"slot": "calm-otter.example.com",
"expires_at": 1773500000
}
}
}
}
}
Successful create and deploy create or update that entry and print a short
note. TTL creates also store expires_at; Orbit removes those entries once the
time passes (on the next config read). Detached HEAD has no branch mapping —
pass host and slot explicitly.
deploy needs a resolved host and slot. It works with a detached HEAD when you
pass them explicitly. It still requires a Git work tree because Orbit gets the
upload list from Git. Orbit always copies the complete selected Git work tree
into the slot src/ directory, including tracked modifications and non-ignored
untracked files.
On a TTY, Orbit prints local status in cyan and streams remote hook output as dim lines. When deploy finishes, it reports success or failure with the elapsed time.
Host layout
/srv/orbit/
Caddyfile
slots/ group orbit, mode 2750
slots/<slot>/
src/ synchronized repository, deploy user only
data/ durable application data, deploy user only
env application environment, mode 0600
run/ group orbit, sockets and generated Caddy configuration
Group orbit is shared by the deploy user and Caddy only for path traversal and
the run/ tree. Caddy cannot read or change synchronized source, durable data,
or the environment file. Default ACLs on run/ keep new listen sockets
reachable by Caddy.
The main Caddyfile is only:
{
email you@example.com
}
import /srv/orbit/slots/*/run/Caddyfile
Environment changes use an atomic same-directory replacement and set mode
0600. Removing a slot keeps its data and environment. remove --purge
deletes the whole slot tree.
Synchronization
Orbit finds the Git root and selects tracked files and non-ignored untracked
files with git ls-files. It builds an exact local staging tree and mirrors it
into $SLOT_SRC with rsync. Files that are absent from the local Git selection
are deleted from the remote source tree.
The rsync operation uses delayed updates, but it is not a transactional release swap. A failed or interrupted transfer can leave a mixed source tree. Do not start concurrent deploy or remove operations for one slot.
Persistent data, secrets, and runtime files are outside the synchronized tree.
Repository hooks
Hooks run from $SLOT_SRC. They are non-interactive and receive no arguments.
ops/deploy required
ops/status optional
ops/remove optional
Orbit runs each file with /bin/bash. The files do not need a .sh
suffix or the execute bit.
orbit init writes these hooks from a recipe. A ready-to-adapt Podman
blue/green example also lives in
examples/podman-slot.
Orbit does not inspect these scripts or infer deployment steps from the application language. Write the commands that your application needs. A deploy hook can, for example:
- build and replace a Podman or Docker container
- compile a Go binary and restart a systemd service
- create a Python virtual environment, install dependencies, and reload a process
- copy static files into place
- run database migrations and health checks
The script must exit with 0 when deployment succeeds and with a non-zero
status when it fails. Make it idempotent so the same source and environment can
be deployed again safely.
Orbit supplies:
ORBIT_ROOT
ORBIT_CADDY
ORBIT_PURGE
SLOT_NAME
SLOT_ROOT
SLOT_SRC
SLOT_DATA
SLOT_ENV
SLOT_RUN
SLOT_TTL
SLOT_DEPLOY_ID
SLOT_GIT_REVISION
SLOT_NAME is the concrete slot address after {} expansion. SLOT_TTL is the
configured lifetime in seconds, or 0 when the slot has no expiry.
SLOT_DEPLOY_ID uniquely identifies this deploy attempt. SLOT_GIT_REVISION
is the local HEAD commit and is informational when the work tree is dirty.
ORBIT_PURGE is set only for ops/remove. It is 1 when Orbit will
delete durable data and env (remove --purge or TTL expiry). It is
0 when Orbit keeps those paths and only deletes src and run. Use it
when the hook must decide whether to wipe application-owned durable state
before Orbit deletes the files.
Orbit streams the output from ops/deploy and returns its exit status.
For an HTTP application that uses the prepared Caddy service, the hook
can write $SLOT_RUN/Caddyfile, switch a stable socket, and reload
Caddy. These are deployment-script choices, not requirements of the
runner.
ops/remove stops the application and removes its runtime files. A
failure stops slot removal. When ops/remove is missing, Orbit still
deletes src and run (and the whole tree on purge), but prints
warnings that containers, systemd units, or Caddy routes may remain
active.
ops/status prints one short status line. Exit 0 means healthy. Exit
1 means stopped or unhealthy. Exit 2 or greater means that the check
failed. A missing script reports unknown.
Short actions such as create, status, list, environment updates, deploy, and remove run while SSH remains connected. TTL expiry uses a transient user systemd timer. Multiple slots can have independent timers.
What Orbit is not
Orbit is not a Kubernetes platform, a PaaS, a CI service, or a daemon on the host. It does not detect the application stack, generate a deployment plan, observe production, or expose MCP.
The contract is the synchronized slot, the supplied environment variables, and your shell hooks. A Unix socket and Caddy are one supported pattern, not the application contract. The Podman example uses that pattern and defaults to Stario, but Orbit itself does not require Stario or Python applications.
Disposable Ubuntu integration test
The integration test changes Caddy service configuration and creates
/srv/orbit. Use only a disposable Ubuntu host where this is acceptable. The
test creates a unique disposable slot and removes it with --purge.
The test has no default host. Both environment variables are required:
ORBIT_INTEGRATION_SSH=user@disposable-host \
ORBIT_INTEGRATION_DISPOSABLE=YES \
uv run pytest tests/test_integration_ubuntu.py -v -s
The SSH user must have interactive sudo access for setup. Normal test runs
skip this test.
Releases
Version history lives in CHANGELOG.md.
Contributing
From this directory:
uv sync
uv run ruff check .
uv run ruff format --check .
uv run pyright
uv run pytest
Before committing:
uv run ruff check . --fix
uv run ruff format .
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file stario_orbit-0.2.0.tar.gz.
File metadata
- Download URL: stario_orbit-0.2.0.tar.gz
- Upload date:
- Size: 31.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.9.24 {"installer":{"name":"uv","version":"0.9.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1503891e91c3199a562a357b599e610dc33aff36772edfe5e594f3146514874b
|
|
| MD5 |
788447db21e0d98ececc630389d189c3
|
|
| BLAKE2b-256 |
4b1c9981d5a31da13d226ed686d16979cebbfd3bbd0c6f7b10598924f48331de
|
File details
Details for the file stario_orbit-0.2.0-py3-none-any.whl.
File metadata
- Download URL: stario_orbit-0.2.0-py3-none-any.whl
- Upload date:
- Size: 42.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.9.24 {"installer":{"name":"uv","version":"0.9.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e825ae100ef660f78a6c83ae8fe82f7fda8c0fc7db75cfae264b29259bb6a8c0
|
|
| MD5 |
b92eaff695dca14d1bfa5389df0ad162
|
|
| BLAKE2b-256 |
f8fb44b6967d0940f2b2ec8197096a44248e435af017a05c715f3a17a38c4979
|