docs /Quickstart

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.