---
title: "CLI reference"
description: "Every ply command with its important flags."
canonical: https://plybox.sh/docs/cli/
---

# CLI reference

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

## Build & validate

```sh
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`.

```sh
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.

```sh
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.

```sh
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.

```sh
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

```sh
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:

```sh
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](/docs/running/):

```sh
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:

```sh
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](/docs/security/#egress-the-contract).

**`--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.

```sh
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](/docs/stacks/).

```sh
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](/docs/security/#egress-the-contract).

## Lifecycle

```sh
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

```sh
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

```sh
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)
```

```sh
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

```sh
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](/docs/packages/).

## Host integration

```sh
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

```sh
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.

```sh
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
```

```yaml
- 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](https://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.

```sh
# 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

```sh
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

```sh
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.
