Stacks & local dev
A typical project is a database, a server, and maybe a web app. One file wires them; one command runs them:
# ply.toml at the project root — pure wiring, no [package]
[stack]
db = { run = "postgres@17", env = { POSTGRES_PASSWORD = "dev", POSTGRES_DB = "todos", PGPORT = 5442 } }
server = { path = "./server", after = "db", env = { PGPORT = 5442, PGPASSWORD = "dev" } }
web = { path = "./web", after = "server" }
ply up # everything, dependency-ordered; Ctrl-C stops it all
ply up db # just the database (dependencies of named members come along)
#Members
Each member is one of the two things you already know how to run:
| member | equivalent | what ply up does |
|---|---|---|
run = "postgres@17" |
ply run postgres@17 |
fetch the prebuilt service from the registry, cached |
path = "./server" |
ply run ./server |
build that directory's own ply.toml — skipped entirely when nothing changed — and run the image |
path members keep their own manifest — the same one ply build and
ply deploy use for production. The stack file adds only wiring: there is
no second place where an app is defined, so dev and prod cannot drift.
env sets per-member variables. after orders startup on the readiness
gate ([health] port answering, or just running when there is none) — the
same --after machinery production wiring uses, address
injection included. after takes a member name or an array of them; cycles
are a build error, not a hang.
#Every member is a normal app
ply up spawns one ply run parent per member — the same supervision
process systemd runs in production — and stays in the foreground. ply ps
shows the instances; ply exec server sh works; a member dying takes the
stack down in reverse order so nothing keeps serving against a dead
dependency.
#The stack lock
run = members resolve once and pin — reference, version, and the image
digest per arch — in the stack dir's ply.lock:
[stack.db]
ref = "postgres@17"
version = "17.10.0"
digest.x64 = "sha256:…"
A locked member starts straight from the local store: no index fetch, no download, and the whole stack starts with the network down. Upgrades are deliberate, as everywhere in ply:
ply up --refresh # re-resolve run= members, re-pin
#ply.dev.toml — the dev overlay
Your production manifest says node dist/index.js. Your dev loop wants
tsx watch on live source. Neither belongs in the other's config — so dev
overrides live in a separate, gitignorable file next to the app's
ply.toml:
# server/ply.dev.toml (add it to .gitignore)
entrypoint = ["npm", "run", "dev"]
links = ["./src:src"]
[env]
NODE_ENV = "development"
entrypointreplaces the image's argv at spawn — the image itself is untouched.linksare extra bind mounts. Relative host paths resolve against the app dir; a relative container path lands under the app's prefix (src→/opt/server/src). Absolute paths pass through.[env]merges over the manifest's; explicit-eflags still win.
The overlay is runtime-only, structurally: ply build never reads it,
so a shipped image cannot contain dev configuration, and editing the overlay
never triggers a rebuild. It applies on the ply run DIR form (and ply up
path members, which use it) — never on plain image runs or deploys. When
it applies, ply says so:
ply: applying ply.dev.toml (entrypoint, env(1), links(1))
With the overlay in place, the full dev loop is:
ply up # db from the registry, server under tsx watch on live code
# edit server/src/*.ts — hot reload inside the container
# Ctrl-C — everything stops
Delete the file (or clone fresh) and the identical tree runs the production entrypoint. Nothing to remember, nothing to ship.
#ply run DIR
The building block ply up uses is useful alone — cargo run for
containers:
ply run . # build this directory (skipped if unchanged) and run it
ply run ./server # same, from anywhere
#What stacks are not
A stack is a dev-loop convenience, not an orchestrator. In production
each member is its own systemd unit with its own deploy cadence — see
Deploys. And two stack members (or a member and a manual
ply run) that resolve to the same app name share one instance pool — app
names are per-host.