Quickstart
#Install
curl -fsSL https://plybox.sh/install.sh | sh
As root, or as a user who can sudo, it installs one system-wide
/usr/local/bin/ply and prepares the host itself (AppArmor profile,
subuid range, the user-mode router rootless instances need for outbound
network). Only a user with no sudo at all gets ~/.local/bin/ply and a
note about the one-time sudo ply setup.
#Write a manifest
A ply app is a directory with a ply.toml. Without one, ply run . infers a manifest from what is in the directory (a package.json, a go.mod, a requirements.txt) and shows it before running; ply init -y writes that same manifest down. ply init writes it for you
(it detects Python/Node projects and asks a few questions); ply add node
adds a dependency at its latest version. By hand, it is:
[package]
name = "hello"
version = "0.1.0"
[build]
base = "debian@13"
dependencies = { node = "22" }
sources = { default = "https://registry.plybox.sh/ply/{package}" }
[run]
entrypoint = ["node", "-e", "console.log('hello from ply')"]
base and node come from the official registry
— prebuilt, content-addressed packages served from a CDN.
#Build
ply build .
This resolves the version ranges (writing ply.lock), fetches the
dependencies by hash, and produces a deterministic image:
locked node 22.23.2
locked debian 13.6.0
built ./hello-0.1.0-linux-x64.img (4.0 KiB)
The image is tiny because dependencies are references, not copies — ten Node apps on one host share one node in the store. Rebuilding the same directory produces a byte-identical file, always.
#Run
ply run hello-0.1.0-linux-x64.img
ply run . # same thing: build if changed, then run
The app runs in the foreground as a normal process: stdout is your terminal,
Ctrl-C stops it, the exit code propagates. Under the hood it got a mount /
PID / network / user namespace, all capabilities dropped, seccomp, and a
read-only rootfs — secure by default, no flags needed.
Useful variations:
ply run --scale 3 app.img # three identical instances (rootful: each with its own IP;
# rootless they share one network, each is handed its own PORT)
ply run -e KEY=value app.img # environment overrides
ply run --env-file .env app.img # secrets stay out of the image
ply run --link ./src:/opt/app app.img # dev mode: bind-mount live code
Need a database next to it? Prebuilt services run by name — no manifest, no build (Databases & services):
ply run postgres@17 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=todos --publish internal:5432
--publish internal:5432 is what makes it reachable from your app on this
host ([ports] in a manifest are labels, not host bindings; rootless, an
instance has no address anyone can dial until something is published).
And when the project is db + server + web, one ply.toml (a composition) starts them
all in order: ply up.
#Inspect
ply ps # instances, IPs, health, restarts
ply stats # live CPU / memory / net per instance, no agent
ply exec hello sh # shell into a running instance
#Deploy to a host
An image is a file. That's the whole story:
scp hello-0.1.0-linux-x64.img server:
ssh server ply run hello-0.1.0-linux-x64.img
Dependencies fetch by hash from your sources on first run. For zero-downtime upgrades of a running app, see Deploys & health gates.
Working with a coding agent? Give it the ply skill first — agents reach for Docker habits by default, and the skill is what stops them writing a Dockerfile you never asked for.