Making packages
A ply package is an inert image: files under its own prefix plus a tiny
manifest describing its PATH/LD_LIBRARY_PATH contributions. No install
scripts — ever. That makes packages easy to mint.
#deb2pkg — convert Debian packages
The deb2pkg tool converts a Debian package (plus its runtime library
closure) into a single self-contained ply package:
deb2pkg redis-server --name redis -o ./out
# → out/redis-8.0.2-linux-x64.img
Debian's Depends graph is too coarse to vendor (postgres would drag in
perl), so deb2pkg reads the binaries instead: it unpacks the package,
walks every ELF's DT_NEEDED, maps each soname to its owning package via
the Contents index, and recurses — vendoring exactly the libraries the code
loads into one keg (/opt/redis-8.0.2/). Maintainer scripts are never
read: no install hooks, ever, by construction.
Three flags cover what scripts would have done:
deb2pkg postgresql-17 --name postgresql17 --skip-so llvmjit.so # drop a dlopen plugin (–150 MiB of LLVM)
deb2pkg nodejs --with node-cjs-module-lexer # vendor non-ELF runtime data the walk can't see
deb2pkg python3.13 --name python3 --symlink python3=python3.13 # symlinks update-alternatives would have made
--arch arm64 converts for the other arch from any host — conversion only
unpacks files. Names and versions normalize to ply's grammar
(postgresql-17 → postgresql17, epochs stripped: 5:8.0.2-2 → 8.0.2).
This is the machinery behind the official registry
— mainstream packages pre-converted from Debian trixie (glibc, so npm
prebuilts, pip wheels, and JNI libraries work untouched), served from a
CDN. Most of the time you don't run deb2pkg at all. The earlier Alpine/musl
catalog (apk2pkg) is frozen: still served, no longer grown.
#ply craft — author interactively
For anything that isn't a Debian package, craft turns a shell session
into a package. The overlay upper layer is the layer:
sudo ply craft new --from debian@13 mytools # opens a shell on the base
# …inside: apt-get install things, copy files, configure…
sudo ply craft changes mytools # what did the session add?
sudo ply craft commit mytools --version 0.1.0 # → mytools-0.1.0-linux-x64.img
It needs root (it mounts an overlay) and the base comes from the official
registry unless --source says otherwise.
commit leaves two kinds of thing out, and says so. What a package
manager regenerates — apt's or apk's package lists and download caches —
is reported as one total, because it is usually most of the weight: an
apt-get install jq session packs to about half a megabyte instead of
sixteen. The session's own records — anything under /tmp, the package
manager's logs and lock files, the shell history — are reported by
name, because a tool you unpacked into /tmp and meant to keep would
otherwise vanish quietly; move it somewhere else and commit again. The
dpkg database itself ships, so a session resumed from the image with
craft edit still knows what is installed; it just needs apt-get update
before installing something new, exactly as a Dockerfile does.
#`[layer]` — what a keg adds to the apps that depend on it
A keg can contribute to the environment of every app that depends on it:
[layer]
path = ["/opt/ruby-3.3.8/usr/bin"]
ld_library_path = ["/opt/ruby-3.3.8/usr/lib/aarch64-linux-gnu"]
env = { RUBYLIB = "/opt/ruby-3.3.8/usr/lib/ruby/3.3.0" }
path and ld_library_path are joined across the dependency closure;
env sets plain variables, base first so a dependent's value wins, and
the app's own [env] wins over all of them. This is how the registry's
ruby finds its standard library from a relocated prefix without every
app spelling out RUBYLIB.
env arrived in ply 0.1.81, and the check is strict on both sides: a
host running an older ply refuses a keg that carries it when the app
starts — not when the image is built, because building never reads a
dependency's layer. If you build on a current laptop against such a keg
and ship the image to a server, update the server first.
Sessions persist between shells (ply craft shell), can be listed
(ply craft ls), discarded (ply craft rm), and — because a committed
package is just an image — resumed anywhere from the artifact itself
(ply craft edit). The result is a normal, inert, content-addressed
package.
#Plain directories
The lowest-tech path: an app-layer dependency can be a static binary you
vendor straight into your app directory before ply build. What npm's
ffmpeg-static does today needs zero ply support.
#Publishing
However a package is made, publishing is copying a file — see
Registries & publishing. Upload to GitHub Releases,
any bucket, or a directory, add an index.json if you want range
resolution, done.