docs /CLI reference

CLI reference

ply <command> --help is always current; this page is the map.

#Build & validate

ply init [DIR] [-y] [--force]

Write a starter ply.toml. Detects Node/Python projects for defaults and asks a few questions (Enter accepts the default; -y accepts all). Never touches anything but ply.toml.

ply search QUERY [--versions] [--limit N] [--source SPEC] [--json]

Search a source's catalog. One line per package, paste-ready: ffmpeg = "6.1" # Multimedia framework x64 arm64. --versions lists every published version and arch. The source is --source, else the [sources] default of ./ply.toml, else the official registry.

ply add NAME[@RANGE] [--source NAME]

Add a dependency to ./ply.toml. Without a range, takes the latest major.minor from the catalog. Comments and formatting are preserved. Then ply build to resolve and lock.

ply build [DIR] [-o FILE] [--arch x64|arm64] [--insecure-source] [--allow-secrets]

Resolve dependencies (writing ply.lock), produce a deterministic image named <name>-<version>-<os>-<arch>.img. --arch cross-builds: packing is arch-independent and dependencies resolve for the target, so an x64 laptop builds arm64 droplet images.

What ships. [package] include is a whitelist — name a path and only that ships, and a typo is a hard error. With no include, everything in the directory ships, and the build says how much:

ply: packing 412 files (18.4 MiB) — no `include` in ply.toml, so everything … ships

That line matters because squashfs compresses junk away: 200 MB of node_modules can report a few KiB, so image size is no signal.

Credential-shaped files swept in that way (.env, .env.*, *.key, .npmrc, .netrc, .pgpass, .ssh*, id_rsa…) refuse the build, because an image is distributable and ply push puts it on a public registry. Naming one in include is an explicit choice and is allowed; --allow-secrets overrides wholesale. .git, __pycache__ and other build detritus never ship, at any depth.

ply check IMAGE [--against policy.toml]

Validate an image; with --against, check it against a host runtime policy. Pure function — wire it into CI.

#Run & observe

ply run WHAT [--scale N] [-e K=V]… [--env-file F] [--link HOST:CONTAINER]
            [--publish [ADDR:]PORT[:INSTANCE_PORT]]  # parent binds it, L4-balances the pool
            [--after APP]… [--after-timeout 60s]     # wait for APP, and learn its address
            [--source SPEC]                          # registry for name references
            [--egress MODE] [--egress-allow ENTRY]…  # outbound policy — see below
            [--privileged]                           # keep everything; debugging only

Foreground, signals work, exit code propagates. WHAT is any of four forms:

ply run app-1.0.0-linux-x64.img    # an image file
ply run .                          # an app dir: build (skipped when unchanged), run; no ply.toml → inferred, shown, not written;
                                   #   applies ply.dev.toml if present
ply run postgres@17                # a registry name — newest matching version,
                                   #   fetched and cached (see Databases & services)
ply run docker://mongo:7           # OCI import, converted once and cached

--publish — ADDR is internal, public (the default) or an IPv4 address. Rootful, new connections are DNATed to instances by the kernel ((kernel dnat) on the publishing line); rootless, on macOS, and for 127.0.0.1 the parent relays them itself — see Running:

ply run api.img --scale 4 --publish 8080          # 0.0.0.0:8080
ply run db.img  --publish internal:5432           # only other ply apps on this host
ply run api.img --publish 127.0.0.1:8080:3000     # exactly that address
ply run edge.img --publish 80:80 --publish 443:443  # repeatable

Repeating it gives each spec its own listener and pool. The first is the app's canonical address (what --after hands to dependants).

Reach for internal for databases and internal APIs — a bare --publish 5432 puts postgres on every interface.

--after — waits for APP's [health] gate, then injects where to reach it:

ply run web.img --after api --after db
#   API_ADDR=10.77.0.1:8080   API_HOST=…   API_PORT=…
#   DB_ADDR=…                 DB_HOST=…    DB_PORT=…
# a convenience: an app reading other names sees nothing and fails quietly,
# so prefer naming the address yourself — `-e DATABASE_URL=…@db.ply:5432/…`

ply computes the address, so it is right rootless (loopback) and rootful (bridge gateway) without the author guessing. An explicit [env] or -e wins; an unpublished dependency injects nothing.

--egress/--egress-allow — the operator's word on outbound policy, over whatever the image's [network] egress claims: --egress sets the mode (off, audit, enforce; defaults to audit when the manifest declares [network] egress, else off), --egress-allow (repeatable) replaces the manifest's list — pass --egress-allow "" for an empty one. ply run prints the effective policy at start, e.g. ply: egress enforce, 1 entry (override); see Security & rootless.

--privileged — skips rights stripping entirely: capabilities kept, no_new_privs off, seccomp off. For debugging and triaging imports. Use [package] capabilities for anything you intend to keep running.

ply up [MEMBER…] [-C DIR] [--refresh] [--source SPEC] [--after-timeout 60s]

Start a composition — several apps from one ply.toml, dependency-ordered, one Ctrl-C teardown. Named members start with their after dependencies; no members = everything. run = members pin version + digest in the stack ply.lock (offline-capable); --refresh re-resolves. See Stacks & local dev.

ply ps [--json]                   # ADDRESS is what callers can dial: the published
                                  # address, or `-` for a rootless instance nobody can reach
ply stats [APP|APP.N] [--json] [--sample-ms MS]
ply exec APP[.N] CMD…
ply logs [APP[.N]] [-f] [-n LINES]
ply egress APP [--follow] [--blocked] [--json]
ply why APP [--json]

ply exec runs a command inside a running instance and gives back its output and exit code. On Linux it enters the instance's namespaces; on macOS there is no namespace to enter, so the request crosses the microVM's control channel and the guest runs the command beside the app. Either way it runs as the app's user, with the app's environment and workdir, and stdout and stderr stay apart. An interactive shell needs a pseudo-terminal, which the microVM guest kernel does not have — sh -c '…' is the form that works everywhere.

ply logs reads the bounded per-instance ring the run parent tees (512 KiB ×2 per instance, in the run dir) — identical foreground, under systemd, rootless. journald remains the unbounded archive on systemd hosts. No APP lists what has logs; -f follows.

ply why is the app explaining itself: its instances and image, every recent exit with the code or signal, whether it was OOM-killed, how long it had been up, what the restart policy did next and the last lines of that slot's log; the names and addresses it was refused or blocked on, with the fix; and the deploys, scale steps and resizes that preceded all of it, newest first. Every line is evidence from the journal, the state files, the egress log and the log ring — no inference. --json is the same report for scripts and agents. The journal is a ring, and the report says how far back it reaches.

ply egress reads the audit log every instance of APP has been writing since it started (off writes none): a table of destination, name, port, protocol, connection count, first/last seen, and verdict. --blocked narrows to what was not allowed — blocked, undeclared (audit's word for what enforce would block), refused; --follow tails new records; --json prints the raw log lines. See Security & rootless.

#Lifecycle

ply deploy IMAGE [--timeout S]     # rolling deploy, health-gated (see Deploys)
ply scale APP N|auto|0             # grow/shrink the pool; `auto` resumes [scale] after a pin; 0 puts an app
                                   # with `[scale] min = 0` to sleep now (a command is a file in the app's
                                   # control dir; parent acts in ~2s)
ply restart APP                    # rolling restart, health-gated
ply reconcile                      # converge systemd units to
                                   # /var/lib/ply/deployments/*.toml — fired
                                   # automatically by systemd's dir watch;
                                   # a deployment is a file (root)
ply rm APP [--volumes]             # volumes kept unless --volumes
ply gc                             # drop store entries nothing references
ply snapshot take APP[.N]          # every declared volume, as one dated image in the
                                   # store; the app is held still for the copy (see Backups)
ply snapshot ls|rm APP [NAME]
ply restore APP [NAME|latest]      # a roll: the slot stops, its volumes are moved aside
                                   # (kept) and filled from the snapshot, it starts
ply notify [--test] [--to DEST]    # flush event notifications now (the reconcile beat does it
                                   # each minute); --test proves a destination. See Notifications
ply backup now|ls APP              # a service's own dump contract (postgres), through
                                   # `ply exec`: dump to BACKUP_DEST now, or list dumps
ply backup restore APP [NAME|latest] --to DB | --replace
                                   # a dump beside the live database, or over it

ply reconcile run by hand is one pass, and says so; the watcher that keeps converging (a touched file deploys, a deleted one retires its app within a minute) is a timer and path unit that sudo ply setup --edge installs.

#Secrets

ply secret ls [-C DIR | --deployments STACK]      # names only, never values
ply secret set MEMBER.PARAM [-C DIR | --deployments STACK]
                                   # an external secret's value (stdin), before `ply up`
ply secret hostkey                 # this host's sealing key (public half); made on first use;
                                   # `sudo` for the key root's apps use
ply secret seal KEY=VALUE… [--for HOSTKEY] [--env]
                                   # KEY = "enc:v1:…" lines for [env] (or KEY=… with --env);
                                   # a VALUE of `-` reads stdin. Opens only on that host,
                                   # at launch, in the run parent (see Sealed secrets)

#Images

ply rebase IMAGE --runtime name@x.y.z [-o FILE]   # swap a runtime, no rebuild
ply bundle IMAGE -o FILE                          # flatten to fat mode
ply import docker://image:tag -o FILE             # OCI bridge (fat mode)
ply inspect postgres@17 | owner/name@1.2 | ./the.img | ./dir | ply.toml
                         [--json] [--manifest]

Show what a package declares, read straight off its manifest — a registry ref resolves and fetches into the store exactly like ply run; a .img, a .toml, or a directory's ply.toml reads directly, no build. Default output:

$ ply inspect postgres@17
postgres 17.10.7  app  owner: ply
volumes:      /var/lib/postgresql/data
links:        —
dependencies: postgresql17 17, rclone 1.60
params:       reference as {postgres.<name>} from a stack; set with params = { <name> = "…" }
  database  default   postgres
  password  secret, minted
  url       computed  postgres://{user}:{password}@{host}:{port}/{database}
  user      default   postgres
facts:        name version host port addr base_url scale arch image   (built-in, read-only)
live:         state instances started_at restarts   (after conditions only)

owner: ply shows once the image was built from a manifest declaring [package] owner — an image built before that field existed prints owner: — instead, same as any manifest that omits it.

--json prints the record — the same shape ply push sends, and what ply push --dry-run prints; --manifest prints the embedded manifest_toml verbatim.

#Package authoring

ply craft new|shell|edit|changes|commit|ls|rm

Interactive package authoring — shell in, install, commit the diff as an inert package. See Making packages.

#Host integration

ply systemd IMAGE [--scale N] [--publish [ADDR:]P[:IP]] [-e K=V] [--env-file F]
                  [--after APP]… [--user]
                                  # emit a unit file (supervision = systemd);
                                  # --user = ~/.config/systemd/user, for rootless.
                                  # The unit runs a current.img link beside IMAGE,
                                  # which `ply deploy` re-points — so a restart or
                                  # reboot comes back on the deployed version
ply proxy [APP]... [--format caddy|nginx|haproxy] [--watch] [--out FILE]
                                  # emit reverse-proxy config; no APP = every
                                  # running app. Backends are the published
                                  # address, so scale/rolls need no re-emit.
                                  # --watch keeps FILE current and reloads
                                  # Caddy — installed as a unit by --edge
ply setup [--unprivileged-ports [PORT]] [--edge]
                                  # one-time host prep (idempotent, sudo);
                                  # also reports subuid/newuidmap readiness.
                                  # --edge installs Caddy + the proxy watcher:
                                  # after it, --domain is all an app needs
                                  # for HTTPS
ply sync                          # pre-fetch the host policy's packages

#Registry account

ply login                         # GitHub device flow; first sign-in chooses a username
ply whoami                        # your namespace, and any others you may publish to
ply push .                        # app/keg dir, or a composition dir
ply push myapp-1.0.0-linux-x64.img   # a built image (append-only)
ply push myapp-1.0.0-linux-x64.img --src https://…/myapp-{version}-linux-{arch}.img

The manifest embedded in what you push (or the stack file's own text) IS the record ply push sends — never the working-copy ply.toml for an app, which may differ from what got built.

ply push TARGET … build upload publish
TARGET = app/keg dir yes yes manifest read off the built image; artifact verified: true
TARGET = .img no yes same
TARGET (dir) --src URL yes, for sha256/bytes no verified: false; URL may template {version}/{arch}
TARGET = .img --src URL no no same
TARGET = a composition dir no no type = "stack" (registry classification), no artifacts; members must be registry refs, URLs, or git+ repos
… --arch arm64 cross-builds a DIR, as ply build --arch yes/no appends the arm64 artifact to the version
… --dry-run as above no prints the record instead of sending it
… --as NAMESPACE sets owner when the manifest has none; conflicts with a different [package] owner

An existing .img already knows its arch — its name says so — so --arch there may only confirm it: ply push ./a-1.0.0-linux-arm64.img --arch x64 is refused rather than publishing arm64 bytes as x64. --dry-run is the plan and nothing else: it needs no key, no credentials file and no network, so CI can print exactly what a push would send before anything is configured (the owner the server derives from your key is left unset unless the manifest or --as names one).

A bare https:// target carries no manifest and is refused: ply push ./the.img --src https://… instead. Owner resolution: [package] owner (or [package] owner) wins; --as fills a manifest that names none; the two disagreeing stops the push (manifest says owner = "ply" but --as other was given — drop one of them). Neither, and you haven't chosen a namespace yet: ply push points you at plybox.sh/account/.

$ ply push .
published ply/postgres@17.10.7
  https://registry.plybox.sh/ply/postgres/postgres-17.10.7.toml
use:
  ply run ply/postgres@17.10.7

#Publishing from CI

A runner has no browser, so it cannot do the device flow. It publishes with a key instead: mint one where you are logged in, store it as a repository secret, and set PLY_TOKEN in the workflow. PLY_TOKEN wins over ~/.config/ply/credentials, and the registry derives the owner from the key itself — a key can only ever publish to its own namespace.

ply key new --note "ci: myapp"    # printed once; only its hash is stored
ply key ls                        # ids, notes, last use — never the keys
ply key rm 3                      # revoke; anything using it stops now
- run: ply push myapp-${{ github.ref_name }}-linux-x64.img
  env:
    PLY_TOKEN: ${{ secrets.PLY_TOKEN }}

Keys are also minted (and revoked) on plybox.sh/account — the lane to use when no machine is logged in yet.

#Publishing without ply installed

ply push is two HTTP calls: upload the bytes, then publish the record — the manifest, verbatim and as JSON, plus where the bytes landed. A pipeline that never installs ply does both with curl; ply push . --dry-run (run once, anywhere ply is installed) prints exactly the JSON the second call needs.

# 1. bytes — the registry stores them and hands back the src to cite below
curl -fsS -X POST \
  -H "Authorization: Bearer $PLY_TOKEN" \
  -H "X-Ply-Filename: myapp-1.2.0-linux-x64.img" \
  -H "X-Ply-Sha256: $(sha256sum myapp-1.2.0-linux-x64.img | cut -d' ' -f1)" \
  --data-binary @myapp-1.2.0-linux-x64.img \
  https://plybox.sh/api/upload/

# 2. the record — the manifest is the publish
curl -fsS -X POST \
  -H "Authorization: Bearer $PLY_TOKEN" -H "Content-Type: application/json" \
  -d @record.json \
  https://plybox.sh/api/publish/

record.json is {owner, name, version, type, manifest_toml, manifest, artifacts: [{arch, src, sha256, bytes, verified}]} — ply push --dry-run prints it. verified is always present in what you send, but the server decides the real value from what it can check, not from what you claim: an artifact's src under registry.plybox.sh/<owner>/<name>/ must name an object step 1 uploaded; any other host records as external with verified: false (see --src below). Add X-Ply-Namespace: <owner> to step 1 to upload under a namespace other than your login; a stack publish has no artifacts, so it skips step 1 entirely.

#Namespaces

Your namespace is a username you choose once on first sign-in — not your GitHub handle. The account itself is keyed on your verified email, so renaming on GitHub never moves your namespace, a freed handle never hands it to someone else, and signing in later through another provider with the same verified address reaches the same account. Until you have chosen one, ply push says so and publishes nothing.

Anything beyond your own username is an explicit grant: the official ply and apps shelves are reserved (never claimable by registering a matching name), and a shared org namespace is a row in namespace_grants. Your account page lists everything you may publish to; ply whoami and GET /api/cli/whoami report the same list.

Operators grant the official shelves declaratively — PLY_ADMIN_LOGINS on the site deployment (comma-separated GitHub logins) grants them on that user's next sign-in.

--src registers an artifact where it already lives instead of uploading it: ply push image.img --src https://… hashes the local bytes itself and sends only the record — the registry never fetches the URL itself, so the artifact records verified: false (a later verification pass may confirm it without a protocol change). Your CI keeps publishing to GitHub Releases; the registry stays a catalog. The image still has to exist where ply push runs, even though nothing uploads — build it, or have it already on disk — because ply computes the hash locally.

Installing needs no account — reads are public files. Signing out is deleting ~/.config/ply/credentials; revoke keys with ply key rm or at plybox.sh/account/.

#Keeping ply current

ply self-update                   # fetch + verify + atomically replace this binary
ply self-update --check           # just report what's newer

Hosts prepared by ply setup --edge run this daily on a jittered timer; ply ps marks instances whose supervisor predates the installed binary with up*. An app asleep under [scale] min = 0 shows as one row with no instance — asleep, wakes on :PORT (idle 10m) — and a parent blocked on --after as waiting on ….

#Fleet hygiene

ply audit                         # shared volumes, deprecated runtimes, risk surface
ply outdated                      # dependencies with newer versions available
ply volume ls                     # every data volume: size, in use / idle / orphaned
ply volume rm myapp/data.1        # delete one (refused while its instance runs)
ply volume rm --orphans           # sweep volumes no installed app claims

Volumes survive ply rm and deleted deployments on purpose — volume ls is where you see what survived, and volume rm is the deliberate act. Wiping a database volume before a BACKUP_RESTORE redeploy lives here too.

#Conventions

  • --json everywhere it matters — ps, stats are stable interfaces for scripts.
  • Foreground by default — backgrounding is systemd's job, emitted for you.
  • Destructive actions are explicit — data deletion never rides along (rm keeps volumes; --volumes is the separate act).
  • Exit codes propagate — ply run in CI behaves like running the binary.