docs /Deployments & CD

Deployments & CD

A deployment is a TOML file in /var/lib/ply/deployments/. Drop one in and the app runs; edit it and the app converges; delete it and the app stops — and its build checkout, generated files and stored token go with it (data volumes are kept; ply gc reclaims the image later) — once the watcher is installed (sudo ply setup --edge puts in a timer and a path unit). ply reconcile run by hand is one pass, and says so. systemd's inotify watches the directory, a oneshot ply reconcile does the converging, and a timer re-runs it once a minute so follow-latest deployments update themselves. There is no daemon, no agent, no webhook endpoint — a timer is a clock, not a process.

Keep two things apart: the recipe and the order. The recipe is the ply.toml in your repo — how the thing is built and run (a single app, or a composition); the developer owns it. The order is this deployment file — which source to run here plus host overrides (publish/env/scale/domain); the operator owns it. Topology lives in the recipe, never inline in the order: an order points at a source and the recipe there decides whether that is one app or a whole set.

One-time host setup:

sudo ply setup --edge        # Caddy + HTTPS, the deployments watcher, the timer
sudo ply setup --swap 2G     # small hosts that will build JS on-droplet

#A deployment names where truth lives

Two choices, not five: fetch a built thing, or build one here.

# fetch — `from` says where, and its shape says which kind
from = "redis@8.0"                                  # a registry ref
from = "ply/plybox-web"                             # …namespaced
from = "/srv/deploy/myapp-1.2.0-linux-x64.img"      # a file on this host
from = "https://cdn.example.com/myapp-1.2.0-linux-x64.img"   # a URL
from = "github:org/myapp"                           # release assets
# build here — no CI at all: the host clones and builds the repo itself
repo = "https://github.com/org/myapp"
build = "npm install && npm run build"
runtime = "node@24"
entrypoint = ["node", "dist/index.js"]
include = ["dist/", "node_modules/", "package.json"]
port = 3000
publish = ["internal:3000"]
domain = ["app.example.com"]

That block spells everything out, but usually you don't have to — see what a repo= order needs below.

A whole deployment, then, is that one line plus how to run it:

from = "redis@8.0"
publish = ["internal:6379"]

[env]
REDIS_PASSWORD = "change-me"

#A repo tells the host how to build itself

Most of the time a repo= order is far shorter than the block above, because the host learns how to build from the repo — those manifest fields are the fallback, not the norm. The host looks in two places, in order:

  • the repo's own ply.toml — the recipe rules, so the order is just repo = plus the host overrides (publish/domain/env); no build or entrypoint needed. Add a build command only if artifacts must be compiled first.

  • auto-detection, when there is no ply.toml — the host recognises the framework (Next.js standalone today) and fills the build command, entrypoint, shipped files and port itself. Paste the URL and it deploys as-is:

    # the whole order — the host detects Next.js and builds it
    repo = "https://github.com/org/next-app"
    publish = ["internal:3000"]
    domain = ["app.example.com"]

    The dashboard and ply ui prefill that detected build command (and internal:3000) into the deploy form, so you see and can tweak it before it runs — nothing is hidden. Only a repo that is neither — no ply.toml, no recognised framework — needs the fields spelled out, as in the block above.

The older spellings — app, image, url, github — still work and mean exactly what they meant; from normalizes into them. They were four names for "a built artifact, somewhere", which is one idea.

#Continuous deployment is a pull

Every following lane re-resolves on each reconcile run:

  • a registry ref (and a stack member's run =) resolves the newest matching version, including a namespaced one like ply/plybox-web — CI publishes, the host converges, nothing on the host names a version.
  • github:org/app resolves the latest release (a version = "1.2" prefix follows patch releases; an exact "1.2.3" pins).
  • repo = fetches the branch tip (ref = "main", default: remote HEAD) and rebuilds only when the commit or the spec actually changed.

With the timer installed, that is CD: push code or tag a release, and the host converges within a minute. Nothing on the host listens; nothing in CI holds your server's keys. GitHub is a shelf, not an actor.

Two controls:

auto = false     # manual only: background runs leave this deployment alone

Intent is the file's mtime. Touching or editing the spec — which is what the dashboard's deploy now button does — always converges it, auto notwithstanding. And a deployment whose last attempt failed backs off for ten minutes instead of re-running an expensive build every beat.

The third pattern — CI pushing over ssh with ply deploy — still works and still has its place: when CI must be the decider (test-gated deploys, centrally orchestrated fleets). See Deploys, health & restarts.

#Private repos: one credential

A fine-grained personal access token with Contents: read on that one repo covers everything: https clones for the build lane, release downloads for github:, and the dashboard's update checks.

repo = "https://github.com/org/private-app"
token_file = ".keys/private-app.token"      # root-owned file, 0600

Relative token_file and deploy_key paths resolve against the deployments dir. The token is injected into git per-invocation — it never lands in .git/config. An SSH deploy_key remains supported for the build lane.

#Building on the host

Builds run in a throwaway, memory-fenced container (a real ply app named <name>-builder — it shows up in ply ps and the dashboard, and its log ring is the build log). The checkout persists between builds, so node_modules and framework caches are the cache.

Measured on the smallest DigitalOcean droplet (1 vCPU / 512 MB / $4), with 2G swap, while serving other apps: a Next.js cold build in 209s, incremental rebuilds in 80s, and the running apps never stuttered — the fence kept the builder at low CPU weight and ~60% of RAM.

Rules of thumb: Go, Python and static sites build fine on 512 MB; JavaScript wants ply setup --swap 2G (ply refuses a JS build on a small host without swap, and tells you the fix); Rust belongs in CI — build it there and deploy with from.

#A repo that is a composition

repo = deploys whatever its ply.toml is. If that ply.toml is a single app ([package]), the host builds one image. If it is a composition ([[service]]), the host deploys the whole set from one line — building each member on the box, wiring them with after, one systemd unit per member:

# todos.toml — the order is one line; the topology lives in the repo's ply.toml
repo = "https://github.com/you/todos"

That repo's ply.toml is the recipe — say, postgres@17 plus two git+ services it builds on the host:

[package]
name = "todos"
version = "0.1.0"

[[service]]
run = "postgres@17"
name = "db"
publish = ["internal:5432"]

[[service]]
run = "git+https://github.com/you/api"
name = "server"
build = "npm install && npm run build"
after = ["db"]
publish = ["internal:3001"]

[[service]]
run = "git+https://github.com/you/web"
name = "web"
build = "npm install && npm run build"
after = ["server"]
publish = ["8080:3000"]

Because the host builds git+ members itself, publishing to the registry is optional — no "publish first" tax to stand a multi-service product up on one box. (In a host composition, a build-from-source member names its own git+ repo; a ./dir member is a ply up dev thing, not a host source.)

One subtlety: repo = reads the repo's ply.toml, not a stack.toml. A repo that is an app but merely ships a stack.toml (a ply up convenience — see Stacks) still deploys as its app. To deploy a product as a composition from one repo= order, the composition must be the repo's ply.toml.

#What the host reports back

  • deployments/.status/<name>.status — one JSON line per deployment: the last reconcile verdict (deployed …, building @ <commit>…, unchanged (…), or the failure).
  • <apps>/events.log — an append-only journal of deploys, scales, restarts, every instance exit (instance-exit: code or signal, uptime, OOM count, what the restart policy did), autoscale steps (scale-up, scale-down, resize, each with its reason) and egress violations (egress-blocked, egress-undeclared). tail -f it, read it in the dashboard, or let ply why <app> join it with the rest of the evidence.

#Spec reference

key meaning
from / repo the source — exactly one. from takes a registry ref, a path, a URL, or github:org/repo; app/image/url/github remain as older spellings
version registry/github lanes: exact pin, prefix follow, or blank = latest
asset github lane: app name in <asset>-<ver>-linux-<arch>.img; default = deployment name
ref repo lane: branch or committish; default = remote HEAD
build, runtime repo lane: build command + toolchain keg (node@24)
entrypoint, include, port repo lane, only when the repo has no ply.toml and is no framework the host auto-detects (Next.js is detected)
token_file / deploy_key private-repo credential (PAT file / SSH key)
publish, domain, env, env_file, scale, after passed through to ply run
a relative env_file (.env/site.env) resolves against the deployments dir; omit it and .env/<name>.env is used when present
$VAR in env, publish and domain is filled from env_file + the environment; an undefined one is a hard error naming the key
grant_links mount the [requests] links the image asks for (dashboard-style apps)
auto false = converge only when the file is touched
source registry override for the app lane

#Shared env files

Secrets never belong in a spec in the clear — a fleet repo is at its best public. Sealed ones do: DATABASE_URL = "enc:v1:…" in a spec's [env] opens only on the host it was sealed for (Sealed secrets). Env files live in deployments/.env/<name>.env (0600, host-local, never synced), and a deployment picks up the file named after it automatically:

# site.toml — no env_file key needed; .env/site.env is found by name
app = "myapp"

Reconcile says so when it happens (site: using .env/site.env (by convention)), so an implicit binding is still visible in the log.

Name a file explicitly when it is not the one named after this deployment — an absolute path, or one file shared by several apps:

env_file = ".env/shared-db.env"

An env file that nothing reads — no deployment of that name, and no spec naming it — is reported on every beat. That is what catches a renamed deployment quietly leaving its secrets behind.

Relative paths resolve against the deployments dir, exactly like token_file and deploy_key. Several apps sharing one file is the point — a stack's database password is written once. The dashboard has an editor for these (with a save & apply that touches every referencing spec so the apps restart onto the new values); over ssh, vi and touch do the same job.

#GitOps fleet

One host is a deployments dir; a fleet is that dir synced from git:

sudo ply setup --fleet git@github.com:you/infra.git          # this host = $(hostname)
sudo ply setup --fleet … --fleet-host web-2 --fleet-key /root/.ssh/fleet_ro

The repo's layout is the fleet's desired state:

infra/
  shared/            # every host gets these
    notify.toml
  hosts/
    web-1/
      plybox-web.toml
    web-2/
      api.toml
      postgres.toml

Every reconcile beat pulls the repo and applies shared/ + hosts/<host>/ into the deployments dir — content-compared, so an unchanged file is never rewritten and the mtime-is-intent rules (auto = false, deploy-now) keep working. Git manages exactly the files it introduced: deployments created locally or from the dashboard coexist untouched. Remove a file from the repo and the app retires on the next beat; sync state lands in deployments/.status/fleet.json and on the dashboard's deploy page.

What falls out:

  • a fleet-wide change is a pull request — the diff you review is the diff production executes
  • rollback is git revert; history is git log
  • a new server is three commands: create it, install.sh, setup --fleet — it converges to its folder within a minute
  • there is no control plane: each host holds one read-only deploy key; no machine anywhere can command the fleet
  • a git outage changes nothing: hosts keep converging on what they have

Secrets never enter the repo: specs carry references (token_file = ".keys/app.token"), the keys live on the host.

#Stacks

An app plus its database plus its cache is not a special object — it is several deployment files that reference each other:

# shop-db.toml                      # shop.toml
app = "postgres"                    repo = "https://github.com/you/shop"
stack = "shop"                      stack = "shop"
publish = ["internal:5432"]         after = ["shop-db"]
                                    publish = ["internal:3000"]
[env]                               [env]
POSTGRES_PASSWORD = "…"             POSTGRES_PASSWORD = "…"

after waits for the service to be healthy. Wiring is a line you write: DATABASE_URL=postgres://postgres:$PW@shop-db.ply:5432/shop says what talks to what, in the file, without depending on ply and the app having picked the same variable name (after injects SHOP_DB_HOST / SHOP_DB_PORT / SHOP_DB_ADDR here — named after the member, and only because shop-db is published; an app reading POSTGRES_HOST would find nothing and quietly run without a database). stack is a label: members render grouped in the dashboard. Because a stack is just files, it inherits everything files already have: per-member rollback and freshness, fleet sync, git review.

The dashboard's wizard does the wiring for you: a "needs a database?" step creates <name>-db / <name>-cache alongside your app, generates the shared password, and writes the after line. One pass, one running stack. (One instance of each service per host — two stacks wanting their own postgres is a planned refinement.)

The other way to run a stack is to let the topology live in one repo's ply.toml and point a single repo= order at it — see A repo that is a composition. Files-referencing-files keeps per-file rollback and review; a repo composition keeps the wiring in one version-controlled recipe.