Stacks & local dev
A typical project is a database, a server, and maybe a web app. One file
wires them; one command runs them. A composition is just several ply runs
written down — each [[service]] block maps one-to-one to a run:
# ply.toml at the project root — the same [package] header an app uses,
# plus [[service]] blocks. That array is what makes it a composition.
[package]
name = "todos"
version = "0.1.0"
[[service]]
run = "postgres@17" # → ply run postgres@17
name = "db" # → --name db
publish = ["internal:5432"]
params = { database = "todos" } # override; password stays minted
[[service]]
run = "./server" # → ply run ./server
e = ["DATABASE_URL={db.url}"] # reference IS the edge — see below
publish = ["internal:3001"]
[[service]]
run = "./web"
e = ["SERVER_URL={server.base_url}"]
publish = ["3000"]
A composition is not a separate file or concept — it is a ply.toml whose
[[service]] blocks make it several runs instead of one. There is nothing
else to learn.
Legacy files still read. Older compositions use a
[stack]header instead of[package],[[app]]blocks instead of[[service]], and astack.tomlfilename — all three are still accepted, so nothing needs rewriting. New files use[package]+[[service]]inply.toml.
Note what wires the members: a line you wrote, and only one of them.
{db.url} in server's env is simultaneously the connection string and
the start order — ply derives after: db from the reference itself (no
separate after = ["db"] to keep in sync), and resolves db.url from
postgres's own [params] (postgres://{user}:{password}@{host}:{port}/{database})
using the real address and the minted password, neither of which this file
ever names. web's {server.base_url} does the same for the next hop.
ply up # everything, dependency-ordered; Ctrl-C stops it all
ply up db # just the database (dependencies of named members come along)
Every field is a ply run flag: run→the image, name→--name, env→-e
(spelled e in older files; both work, but not both on one member),
params→per-param overrides for the member's own declared [params],
after→--after, publish→--publish, volume→--volume,
domain→--domain, scale→--scale, egress→--egress/
--egress-allow. There is no stack concept beyond
"these runs, in dependency order." See the full model.
#Members
The run value is exactly what you'd hand ply run — its form decides the
source:
run = |
equivalent | what ply up does |
|---|---|---|
"postgres@17" |
ply run postgres@17 |
fetch the prebuilt service from the registry, cached and lock-pinned |
"./server" |
ply run ./server |
build that directory's own ply.toml — skipped when nothing changed — and run it |
"https://…/app.img" |
ply run <url> |
fetch the image at that URL |
"docker://redis:7" |
ply run docker://redis:7 |
import the OCI image once (cached, pinned to the first pull — --refresh pulls again) and run it as a fat image; the member is named after the image (redis) |
"git+https://github.com/org/repo" |
— | a git repo the host clones and builds (see below); the member is named after the repo (repo) |
A directory member keeps its own manifest — the same one ply build and
ply deploy use for production. The composition adds only wiring: there is no
second place where an app is defined, so dev and prod cannot drift.
#`git+` — a member the host builds from source
A member can name a git repo the host clones and builds into an image, so a composition can carry build-from-source services without publishing them to the registry first:
[[service]]
run = "git+https://github.com/org/api" # also: a .git URL, or git@github.com:org/api.git
name = "api"
build = "npm install && npm run build" # run in a memory-fenced container before packing
runtime = "node@24" # builder toolchain (default: node@24)
ref = "main" # branch/committish (default: remote HEAD)
after = ["db"]
publish = ["internal:3001"]
build, runtime, and ref apply only to a git+ member — putting
them on any other member is an error. This is a host source: ply up
rejects a git+ member (it has nothing to build locally) and tells you to
override that member's run to a local ./dir in a ply.dev.toml. The
committed recipe keeps the git+ source; the dev overlay swaps in your
checkout. See Deploying a composition.
Each member runs under its own --name (the member name, defaulting to
the image name). That identity is what after, the <member>.ply bridge
name, ply ps, and ply exec all key on — so two members may even run the
same image under different names. after names other members; cycles are a
build error, not a hang.
#`egress` — the operator's word on outbound policy
A member's run = image may declare a [network] egress claim (see
Security & rootless); egress on
the member is the stack's word over it:
[[service]]
run = "postgres@17"
name = "db"
egress = { mode = "enforce" } # enforce the keg's declared list
egress = { mode = "enforce", allow = [] } # override: nothing at all
egress = { mode = "audit", allow = ["*.stripe.com"] }
egress = "off" # shorthand for { mode = "off" }
allow, when present, replaces the manifest's list for that member alone;
mode is off, audit, or enforce. Omitting egress on a member
leaves the effective policy to fall out of the claim alone — audit when
the manifest declares [network] egress, off otherwise. ply up --plan
prints each member's effective policy on its own egress: line.
#`{app.param}` — reading a neighbor's params
A member's env/e values (and its params = {…} overrides) can hold
{app.param} holes: interpolation into the named member's resolved
namespace — the
declared [params] from its manifest, built-in facts (host, port,
addr, base_url, name, version, scale, arch, image), and
computed values. {self.x} reaches the member's own namespace
(e = ["APP_VERSION={self.version}"]).
e = ["DATABASE_URL={db.url}"] # common case
e = ["DATABASE_URL={db.url}?sslmode=disable"] # composed — templates can't do this
e = ["PGHOST={db.host}", "PGPASSWORD={db.password}"] # discrete vars
- A
{app.param}reference IS the ordering edge. Noafter = ["db"]needed alongside it — ply derivesdb.state == "healthy"from the reference and waits on it before starting the member that wrote it. Writing an explicitaftertoo is legal and redundant, never conflicting; it's how you order without a reference, or state a custom condition (next section). publishanddomaintake no{}holes in v1. They are handed to the runtime verbatim, so a hole there would reach it as the literal text{db.port}; a stack that writes one is rejected at parse, naming the limitation. Write the port literally, or use a$VARfor a deploy-time value.$VARand{}coexist and point in different directions:$reaches the ambient system (shell, orenv_file),{}reaches the stack graph. Both may appear in one value.- Escapes:
{{→ literal{,}}→ literal}. - No expression language. No functions, conditionals, or defaults
syntax inside
{}— logic belongs in your own code, not the reference. - A live param (
state,instances,started_at,restarts) in anenv/evalue is a build error, not a stale value: those change after launch, so nothing bakes them into env. Wait on one withafter(next section), or read it at runtime from/run/ply/<app>/<param>— see Running & scaling.
params = { key = "value" } on a member overrides one of that member's
own declared params (db's database above) — it is not a cross-member
reference. Values may hold $VAR; setting a param the member's manifest
doesn't declare is an error naming the declared set.
#Waits — `after`
after = ["db"] # sugar for db.state == "healthy" — today's gate, unchanged
after = ["server.finish_boot"] # wait until the param exists
after = ["server.finish_boot == 'ok'"] # wait until equality holds
Exactly those three forms — APP, APP.PARAM, APP.PARAM == 'value' (or
"value") — and nothing else: no !=, no ordering comparisons, no boolean
operators. An app that needs more computes it itself and publishes a param
from inside its own code (echo ok > /run/ply/self/finish_boot, or
fs.writeFileSync("/run/ply/self/finish_boot", "ok")) — that's where the
logic belongs, never in the wait grammar.
A condition unmet within the timeout fails loud, never hangs — the launch aborts naming the condition, the current value, and the elapsed time:
waiting for server.finish_boot == 'ok' (currently unset, 30s elapsed)
See Running & scaling for the /run/ply
tree these conditions read, and how an app self-publishes into it.
#Secrets: minted files, sealed values, or `$VAR` holes
A member's env may also carry a value sealed for the host
(DATABASE_URL = "enc:v1:…", from ply secret seal); it opens at launch
and nowhere else. See Sealed secrets.
The db member above declares no password anywhere — postgres's own
password = { secret = true } mints a strong value the first time the
stack starts and stores it as a 0600 file (<stack dir>/.ply/secrets/ db.password locally; <deployments dir>/.secrets/<stack>/db.password on a
host). {db.url} already carries it; the stack file and the published
template never hold the plaintext.
Override precedence: a stack params = value beats an existing secret
file, which beats minting:
[[service]]
name = "db"
params = { password = "$PROD_PW" } # ambient beats minting
PROD_PW=s3cret ply up
$VAR in an env/e value still works exactly as before — filled from
the environment at launch, from your shell or an --env-file:
PW=s3cret ply up
ply up --env-file ./secrets.env
An undefined $VAR is a hard error naming the member and key — never a
silent empty value. $$ is a literal $. Operator surface for minted or
external secrets is files first, ply secret as a convenience:
ply secret ls -C . # or --deployments STACK on a host
ply secret set db.password s3cret # or omit VALUE to read one line from stdin
A composition's env_file (a file of KEY=VALUE lines filling every $VAR
hole) is still supported — it fills the shape a published composition
ships with holes in — but is superseded for secrets by [params]: a minted
or external secret needs no env_file entry, no $VAR hole, and never
appears in the published template at all.
#Secrets on a host
ply reconcile runs the same resolver as ply up, but a host expands a
stack into N independently-managed systemd units instead of one foreground
process group, so delivery goes through two different files under the
deployments dir's .secrets/:
- The secret itself — `
/.secrets/ / .` (0600), one file per param: the minted or operator-set value, same layout as the local `.ply/secrets/` store. `ply secret ls|set --deployments ` manage exactly these files. - The delivery file — every reconcile beat, the reconciler writes a
member's secret-tainted resolved env (which may combine several
params into one composed value, like a
DATABASE_URL) to<deployments dir>/.secrets/<stack>/env/<member>.env(0600, itsenv/directory 0700), and that member's unit gets--env-filepointed at it. Plain, non-secret entries stay ordinary-e KEY=VALUEflags baked into the unit text — only secret-tainted ones go through the env file, so the unit itself (world-readable,systemctl cat-able) never carries a secret.
Changing a secret does not, by itself, restart the member.
ply reconcile decides whether to restart by comparing the unit text it
would generate against what's on disk, and the unit only names the env
file by path — never its contents. So ply secret set --deployments <stack> db.password … (or hand-editing the file) rewrites the delivery
file on the very next beat, but the running process keeps its old value
until something restarts it. Run ply restart <member> afterwards to pick
up the change — v1 behavior, by design; there is no content-hash restart
trigger.
#`ply up --plan` — the composed result, inspectable
ply up --plan
Resolves every member's params and env, and prints the composed result —
no minting, no spawn, no lock write. Exits non-zero on a resolution error
(an undeclared param, a live param in env, a missing external secret) —
--plan is the validator, not just a preview:
db (postgres@17) internal:5432
POSTGRES_DB = todos params (stack override)
POSTGRES_PASSWORD = ******** minted secrets/db.password
server (./server) internal:3001 after: db (via {db.url})
DATABASE_URL = postgres://postgres:********@db.ply:5432/todos {db.url}
web (./web) 3000 after: server (via {server.base_url})
SERVER_URL = http://server.ply:3001 {server.base_url}
What --plan lists in v1 is the stack's own e = [...] entries and the
params-driven self-config a provider resolves from its own [params] —
each with its resolved value and where it came from (stack e,
manifest [env], {db.url}-style references, params (stack override),
minted secrets/db.password) — plus the derived wait DAG, annotated with
which reference created each edge. It is not the child's whole environment:
plain manifest [env] values (the ones with no holes), -e, --env-file
and ply's own injected variables are not listed. Secret values are masked, including
inside a composed value like DATABASE_URL — only a secret the resolver
itself knows about is masked, so a secret typed literally into a stack e
value (rather than referenced) still prints verbatim.
#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 db sh works; a member dying takes the stack
down in reverse order so nothing keeps serving against a dead dependency.
#The stack lock
run = registry 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:
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
directory members, which use it) — never on plain image runs or deploys.
With the overlay in place, the full dev loop is:
PW=dev 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.dev.toml — the composition overlay
The committed ply.toml describes production: members reach each other
by their <name>.ply bridge names, and secrets are minted files or $VAR
holes — never plaintext. A laptop differs in fewer ways than it used to: a
rootless composition gets its own network too, so <name>.ply and the
members' real ports mean the same thing here as there. What is still local
is a published port that has to dodge whatever the machine already runs,
and building the checkout next door instead of pulling a release.
Put those local truths in ply.dev.toml beside the ply.toml (gitignored
by the repo's own .gitignore). (A legacy stack.dev.toml beside a
stack.toml works the same way.)
# ply.dev.toml (beside the composition's ply.toml)
[[service]]
name = "db" # WHICH member — matched by name
publish = ["internal:5433:5432"] # the container still serves 5432; only
# the HOST side moves, because this box
# runs its own postgres there
[[service]]
name = "server"
run = "../server" # the checkout next door — {db.url} still
# resolves correctly, dev password and all
- Members are matched by
name; overriding a name that is not in the stack is an error, not a silent no-op. envandparamsmerge by key — the override adds or replaces one entry and leaves the member's others alone.publish,domain,volume,scaleandrunreplace outright;env_filereplaces too.- Overlays override members, they never add them.
Same structural rule as ply.dev.toml: ply up applies it, a host never
does. ply reconcile reads the stack file alone, so the file you commit
(and ply push) is the production one, and the deployment cannot inherit a
laptop's loopback address.
#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
#Publishing and running a stack from the registry
A composition with a [package] name, version, and optional owner is
publishable — ply push records its ply.toml template verbatim (the
$VAR holes stay in). There is no image, so nothing builds and nothing
uploads:
ply push . # a directory whose ply.toml is a composition
owner picks the namespace the same way [package] owner does for an
app: set it in [package], or pass --as NAMESPACE when the file
names none. Members must be registry refs (postgres@17), URLs, or git+
repos — a ./dir member is refused, since it names nothing on someone
else's machine; publish that app first, or point it at a git+ repo the
host can build. The registry writes the template to
{owner}/{name}/{name}-{version}.toml.
Anyone can then run it by name — the toml is fetched and brought up, holes filled from the environment at launch:
PW=s3cret ply up iluxav/umami
No secret ever ships in the published stack; only the shape does.
#Dev versus production — same file, two behaviors
ply up is the developer experience: a foreground, supervised group that
starts and stops together, holding nothing on the host.
On a host, the same stack file lives in the deployments directory and
reconcile expands it into N independently-managed apps — each its own
--name'd unit, shown in ply ps, rolled and reverted on its own. Dev wants
atomic all-up/all-down; production wants each app independently reconcilable.
One declaration serves both. See Deploys and the
model.