runo

CLI and environment reference

Detailed configuration for Runo. Start with the quickstart for your first environment. Boot time and infrastructure cost depend on your recipe, region and instance configuration.

Environment variables

Var Default Effect
RUNO_HOME ~/.runo state root (registry, worktrees, SSH keys). Parallel installs MUST use distinct RUNO_HOMEs
RUNO_AWS_REGION sa-east-1 EC2 region (compare current AWS prices for your region)
RUNO_DEBUG : 1 prints stack traces
RUNO_RECIPE .kodus/workspace.yaml which recipe to read (same as --recipe) : one repo can describe a dev box AND a PR preview
RUNO_PROFILE : env profile (same as --profile): one env PER PROFILE of a branch : see "Profiles"
RUNO_SSH_KEY : private key as a value instead of a file, for machines with no $RUNO_HOME (CI)
RUNO_MAX_INSTANCES 3 ceiling on simultaneous RUNNING instances
CLOUDFLARE_API_TOKEN : only for expose.domain (named tunnels)

Commands

Command What it does
runo setup guided doctor: checks tooling, AWS/control-plane access, agent credentials (validated against the API), recipe; fixes what it can
runo init [--force] inspects the repo and proposes .kodus/workspace.yaml
runo new <name> branch task/<name> + worktree + runo up
runo up [--branch B] [--here] materializes the remote env (idempotent: existing → resume/reconcile); --here uses the CURRENT working tree as sync anchor (Orca/worktree tools)
runo agent <claude|codex> [args…] agent session ON the VM in tmux (Ctrl+B D detaches without killing it; running again reattaches), cwd in the repo, auth injected
runo validate [step] runs validate: on the VM; downloads JSON+MD evidence; exit ≠ 0 on failure
runo pull rsync VM → local worktree (commit/push happen locally)
runo ship ["msg"] [--validate] [--destroy] the finish flow in one command: pull (tolerant of suspended envs) → commit → push → open the PR via gh. --validate only ships green and embeds the evidence in the PR body; --destroy tears the env down after
runo push [--restart] rsync local worktree → VM; --restart restarts run: services (compose dev with watch hot-reloads by itself)
runo url [--open] public URL of the public service (current IP + port)
runo tunnel [port…] forwards localhost:<port> → VM (frontends behave exactly like local dev); defaults to the recipe's public ports
runo logs [service] [-f] remote logs per service
runo exec -- <cmd> arbitrary command on the VM, cwd in the repo
runo ls envs: branch, state, URL, uptime, instance
runo suspend / runo resume stop/start the EC2 instance (stopped costs no compute; the IP changes and runo redetects it)
runo bake [--rm] bakes an AMI with provisioning done (once): subsequent runo up boot in ~1-2min instead of ~6. --rm removes image/snapshot
runo pool [n] warm pool: n provisioned, stopped instances (EBS only, ~US$3/mo each); runo new claims one. adjusts type/disk while stopped and starts it . No arg shows status; 0 drains
runo destroy [--all] terminates the instance (EBS included), removes worktree and registry; --all also removes keypair + SG. --branch B without --profile destroys every profile of the branch

Every command accepts --branch <B> (which env) and --profile <name> (which profile of that branch. see below).

Profiles: several envs of one branch

A branch normally has one env. Some changes need the same code materialized more than once. Kodus runs a cloud shape (billing, analytics) and a self-hosted shape, and a pull request may need one, the other or both. --profile (or RUNO_PROFILE) makes the profile part of the env's identity: the env name, the VM name and tags, the worktree, the tunnel or ingress hostname and the registry all split by profile, and an env created without one keeps every identifier exactly as before.

runo up --here --branch "$HEAD_REF" --profile cloud        # env <slug>-cloud
runo up --here --branch "$HEAD_REF" --profile self-hosted  # env <slug>-self-hosted, same VM size rules
runo logs --branch "$HEAD_REF" --profile cloud             # commands need the profile once a branch has two
runo destroy --branch "$HEAD_REF"                          # takes every profile of the branch

Recipe (.kodus/workspace.yaml, schema v1)

version: 1
setup:
  - bun install
files:
  copy: [.env]              # untracked files copied from the local working tree to the VM
services:
  db:
    image: postgres:16      # image: → docker compose generated by runo
    port: 5432
    env: { POSTGRES_PASSWORD: runo }
  api:
    run: bun run dev        # run: → process on the VM (tmux), logs in ~/.runo/logs/
    port: 3000
    public: true            # opens the port on the SG → http://<ip>:3000
    health: /health         # HTTP health check performed FROM OUTSIDE (validates SG + service)
data:
  migrate: bun run db:migrate
  seed: bun run db:seed
validate:
  - name: lint
    run: bun run lint
  - name: test
    run: bun test
expose:
  mode: https               # how the env is reachable. see "Public access" below
limits:
  instance: t3.medium
  disk: 30gb
  idle_suspend: 10m         # default 10m; "2h" or "off". idle auto-suspend
  idle_activity: [ssh, net, agent]  # default all; what counts as use. A public
                            # preview drops net: the clock then runs from the last up
  spot: true                # ~70% cheaper; AWS interruption = stop (EBS survives,
                            # runo resume restarts). Automatic on-demand fallback.
                            # Spot skips the warm pool and hibernation.

Passthrough mode (repos with their own compose). Multi-file overlays, interpolation env (with ${RUNO_PUBLIC_IP} / ${RUNO_PUBLIC_IP_DASHED} substituted at up time. dashed form for nip.io-style wildcard DNS) and an explicit public_port are supported:

services:
  compose:
    files: [docker/compose.yml, docker/compose.preview.yml]
    profiles: [back, front]
    env: { PREVIEW_DOMAIN: "${RUNO_PUBLIC_IP_DASHED}.nip.io" }
    public: gateway
    public_port: 80

Git submodules are shipped automatically (recursively): each submodule's content is archived from an already-populated checkout at the exact commit the superproject records. fully offline, still tracked-files-only.

Simple passthrough (single compose file):

version: 1
setup:
  - pnpm install
files:
  copy: [.env]
services:
  compose:
    file: docker-compose.dev.yml
    profiles: []             # optional; the repo's compose runs AS-IS on the VM
    public: kodus-api        # service whose published port becomes the public URL
    health: /health/simple   # optional; without it the check is TCP
    health_timeout: 1200     # heavy apps need more than the 120s default on first boot
data:
  migrate: pnpm run migration:run
  seed: pnpm run seed
validate:
  - name: lint
    run: pnpm run lint
limits:
  instance: m7i-flex.xlarge  # non-burstable: heavy builds never throttle on CPU credits
  disk: 100gb

services.compose is mutually exclusive with image:/run:. Since the VM is dedicated to the env (exclusive Docker daemon), fixed container_names and published ports from the repo cannot collide by construction. no DinD, no compose overrides.

Public access (expose)

How the env's services are reached from outside. Everything below is one ExposeProvider (src/expose/). the engine only asks for "service → URL".

expose:
  mode: https               # https | tunnel | ip
  domain: preview.acme.com  # tunnel only: named tunnel on a domain you own
  hostname: pr-${RUNO_SLUG} # tunnel + domain only (default: the env slug)
mode URL Setup Inbound ports Stable across suspend/resume
https (proposed by runo init) https://<ip-dashed>.sslip.io none 80, 443 no (the IP is in the name)
tunnel https://<random>.trycloudflare.com none none no (a new name per restart)
tunnel + domain https://<slug>.<domain> Cloudflare token none yes
ip http://<ip>:<port> none every service port no

Services listed in services.compose.public each get their own URL. the first is the primary, the others are reached at <service>--<primary host>. Their addresses are substituted into the compose environment BEFORE the stack boots (${RUNO_PUBLIC_URL}, ${RUNO_PUBLIC_HOST}, ${RUNO_URL_<SERVICE>}, ${RUNO_HOST_<SERVICE>}), because an app that emits absolute links. auth callbacks, a frontend calling its own API. has to know its public address at startup.

Worktree tools (Orca & friends): runo up --here

If another tool already owns your worktrees (Orca, git worktree by hand), skip runo new entirely: run runo up --here inside the worktree and that directory becomes the env's sync anchor. runo push/pull sync it, and runo destroy terminates the instance but never touches the directory.

Orca post-create hook (one line. every new worktree gets a remote env):

# copy untracked requirements first if your recipe needs them, e.g.:
# cp ~/dev/my-repo/.env .
runo up --here

Two ways to use an agent

  1. Agent ON the VM (runo agent claude|codex). the brain runs there, in a tmux session: watch it live in your terminal, detach (Ctrl+B D or close the laptop) and the agent keeps working; runo agent reattaches with scrollback. Subscription auth via claude setup-token (CLAUDE_CODE_OAUTH_TOKEN) or an API key.
  2. Local agent driving the VM. your local agent (with your subscription) treats the env's worktree as a regular local repo: edit files → runo push [--restart] → change live on the public URL → runo validate/logs/exec to verify. Zero credentials in the cloud.

How it works

Teams: control plane. no AWS credentials on laptops

For CI-created previews shared with QA agents, see shared preview setup and migration. runo environments discovers accessible environments, runo attach <env-name> connects a checkout, and runo share [user...] sets collaborators (owner only).

For solo use, the CLI talks to AWS directly with your local credentials. For teams, run runo-server: it holds the AWS credentials and the SSH keys; developers need zero cloud credentials. they sign in with GitHub and use a personal token.

dev laptop (CLI, no AWS creds)          runo-server (control plane)
  RemoteProvider ─── HTTP/WS ───►       bearer token → EC2 provider + SSH keys
  RUNO_SERVER + RUNO_TOKEN              env registry + history (server.db)
browser ─── GitHub OAuth ───────►       web panel: envs, history, cost, settings

Operator (platform team), on a machine that has AWS credentials:

RUNO_HOME=~/.runo-server \
RUNO_PUBLIC_URL=https://runo.example.com \
RUNO_GITHUB_CLIENT_ID=... RUNO_GITHUB_CLIENT_SECRET=... \
RUNO_GITHUB_ALLOWED_ORGS=your-org \
RUNO_SERVER_ADMINS=your-github-login \
RUNO_SERVER_TOKENS="preview-ci:<long-random>" \
bun server/main.ts --port 7777
Who Signs in with Gets
People GitHub (active member of an allowed org, or a listed user) the panel; mint their own CLI token there
CI / agents a token: RUNO_SERVER_TOKENS, or a service account created in the panel the full API
Teams without GitHub tokens only. leave the RUNO_GITHUB_* variables out the panel via "use an access token"

Setup (OAuth App, callback URL, GitHub Enterprise, what a browser session may and may not do, offboarding) is in docs/control-plane-panel.md.

Developers. open the server's URL, sign in with GitHub, then settings → CLI tokens → create token (shown once):

export RUNO_SERVER=https://runo.example.com
export RUNO_TOKEN=runo_...
runo new my-task        # same CLI, same flow. AWS stays server-side

Security. accepted v1 limitations (documented on purpose)

Validation evidence (runo validate)

Downloaded to <worktree>/.kodus/evidence/<sha>.json + <sha>.md + logs/: a schema with repo/branch/sha/env/startedAt/finishedAt/status/steps[]/urls : the future contract with Kody (review-time runs the SAME validation).

View this page on GitHubRuno documentation