Deploy #
zap deploy ships a versioned release of your application to a remote host over
SSH and swaps it in atomically, with a health gate and automatic rollback. The
host is one your config already converges with zap apply: the same tool that
built the machine ships the app, and the deploy refuses to ship onto a host that
has drifted from its config.
Deploy is the one imperative command. Each run produces a new release, so there is nothing to converge to. It still plans before it acts: a dry run prints every step with the command, script, or URL behind it, and a wetrun executes only the plan a dry run showed.
Configuration
export default (zap) => {
zap.target({
host: zap.env('DEPLOY_HOST'),
user: zap.env('DEPLOY_USER'),
package_manager: 'apt',
service_manager: 'systemd',
home: '/home/app',
});
zap.deploy({
build: 'gro build',
artifacts: ['build/', 'dist_artifacts/'],
path: '/home/app/app',
releases: 5,
service: 'app',
shared: ['.env.production'],
prepare: ['deno cache server.js'],
health: {domain: zap.env('DEPLOY_DOMAIN'), path: '/health', attempts: 15, interval_ms: 2000},
});
};| Field | Required | Meaning |
|---|---|---|
build | no | command run locally before packaging; omit it, or pass --skip-build, and the plan has no build step |
artifacts | yes | files and directories, relative to the directory you run zap from, packaged into the release. An entry can't begin with - (tar would read it as an option; write ./-name) or with @ (which some tars read as an archive to include) |
path | yes | the remote app root: holds releases/, shared/, and the current symlink. An absolute path, written out (no reference, no ~); it must exist and be writable by the connecting user, which zap apply usually arranges |
releases | no | how many releases to keep, the live one included (default 5). At least 2: the release just deployed and the one it replaced are always kept, since a failed deploy rolls back to the second |
service | no | the service restarted after the swap; requires target.service_manager; takes no reference |
shared | no | files under <path>/shared/ symlinked into each release root. Each entry is a relative name of a file: no .. component, no control character, not ending in / or in a . component (config/.); anything else is rejected by the structural gate. Each must exist (zap apply usually writes it): the deploy fails before extracting otherwise |
prepare | no | commands run on the target in the new release, as the connecting user, before the swap, each in a shell of its own; the release is still under a hidden staging name then, so a command shouldn't record its absolute path |
health | no | the post-restart health gate, below |
health
| Field | Required | Meaning |
|---|---|---|
domain | yes | the host polled over HTTPS; usually a zap.env('DEPLOY_DOMAIN') reference |
path | yes | the request path, beginning with / (/health, or /ready to make it the gate); takes no reference |
attempts | no | polls before the deploy fails (default 15), at least 1 |
interval_ms | no | delay between polls in milliseconds (default 2000) |
Omit the block to deploy with no health gate and no readiness check; there is no flag to disarm a declared one.
Commands
zap deploy # dry run: the whole plan and its plan_hash, recorded as the preview
zap deploy --wetrun # ship the previewed plan: build, upload, swap, restart, health-check
zap deploy --wetrun --expect-plan <hash> # ship only the plan with this hash
zap deploy --wetrun --skip-build # no build step: ship the local artifacts as they are
zap deploy --wetrun --skip-preflight # ship without the drift preflight
zap deploy --wetrun --force # ship an unchanged version, or a dirty tree
zap deploy --wetrun --json # stream step events as JSON linesA wetrun binds to a preview made with the same plan-changing flags
(below). --env-file <path> loads the
operator env first, as for every command. Flags, exit codes, and what a dry run
reads: cli.md.
What it needs. On your machine: git, tar with gzip, and ssh, with zap run from
inside the application's git checkout. On the target: what a file write needs
(targets.md), plus tar, id, and flock.
First deploy
A first deploy onto a fresh host is a chicken-and-egg: the binary lands via deploy, but deploy's preflight requires a converged apply, and a service declared running: true can't be running before there is anything to run.
Resolve it in the config: declare the service enabled and leave running unset.
zap.service({
id: 'app-service',
service: 'app',
enabled: true,
// no `running`: zap manages enablement, not runtime state, so the service
// doesn't drift before a binary exists; the deploy's restart starts it
restart_on: ['app-unit'],
});Then the bring-up is two reviewed runs: zap apply, then zap apply --wetrun,
provisions the host (firewall included, so deploying first would bail), and zap deploy, then zap deploy --wetrun, ships the first release, whose
restart step starts the service. Preview the deploy after the apply has run:
the dry run connects as the user the apply sets up, and a pin the apply writes
to zap.lock is part of the deploy plan.
The trade is explicit: a config that leaves running unset tells Zap never to
assert that the app is up, so zap apply on a host whose daemon crashed and
stayed down reports converged. Declaring running: true buys that assertion
back at the cost of one deploy --wetrun --skip-preflight on the very first
release (previewed with deploy --skip-preflight, since the flag is part of
the plan), and --skip-preflight disables the whole drift gate for that run.
When SSH hardening changes the user Zap connects as, add --bootstrap to the
first apply: targets.md.
Binding a wetrun to a reviewed plan
A dry run prints the whole plan and ends with its hash:
deploy — target {env:DEPLOY_HOST} (dry-run)
version: 3f2a9c1e7b04 (previous: 8d41e0a6c2f9)
host: app.example.com
target: {"local":false,"host":{"$env":"DEPLOY_HOST"},"user":{"$env":"DEPLOY_USER"},"home":"/home/app","package_manager":"apt","service_manager":"systemd"}
references:
DEPLOY_DOMAIN = app.example.com
tree: clean
preflight: no apply-managed resources declared — nothing to check
- Build application (build)
local$ gro build
- Create tarball (tarball)
local$ tar -czf "$ZAP_DEPLOY_ARCHIVE" -- 'build/' 'dist_artifacts/'
writes $ZAP_DEPLOY_ARCHIVE: a file in a private directory of this run, removed when it ends
- Upload tarball (upload)
the archive → target:/home/app/app/.zap-deploy-app-3f2a9c1e7b04.tar.gz (as the connecting user, mode 600, checked against its SHA-512)
- Extract and swap release (extract_swap)
target script (its stdin: the archive's SHA-512):
set -e
zap_archive='/home/app/app/.zap-deploy-app-3f2a9c1e7b04.tar.gz'
…
- Restart app (restart)
target$ sudo systemctl daemon-reload && sudo systemctl restart app
- Health check (health)
GET https://{env:DEPLOY_DOMAIN}/health — healthy on 2xx; up to 15 attempt(s), 2000 ms apart
polls https://app.example.com/health
- Readiness check (readiness)
GET https://{env:DEPLOY_DOMAIN}/ready — ready on 2xx, unserved on 404; up to 15 attempt(s), 2000 ms apart
polls https://app.example.com/ready
(dry-run — re-run with --wetrun to deploy)
plan_hash: 1f98612fcbfacb69b9d31236457d549e00714e4ad0fb0a36e225c3fe6a5f4312A wetrun executes only a plan you were shown. It computes its plan again, and that plan's hash must equal the reviewed one:
--expect-plan <hash>names the reviewed plan explicitly, and binds alone;- with no flag, the reviewed plan is the deploy preview recorded for this
config and host: the last
zap deploydry run, or a wetrun that refused.
Otherwise the wetrun refuses before the drift preflight and any step,
prints the plan it would have run under a REFUSED banner that says why (no
preview yet, the plan changed since the preview, or the hash doesn't match),
records that render as the new preview, and exits 2. Nothing is built or
uploaded. The everyday flow is zap deploy, read it, zap deploy --wetrun with the same flags. A wetrun fired cold becomes the preview, and the next one
executes if nothing moved. There is no flag that skips the review, and a dry
run rejects --expect-plan.
On an interactive terminal (standard input and standard error both terminals),
a wetrun with no review and no --expect-plan shows the plan instead, under an UNREVIEWED banner with its plan_hash, and ends with the question:
UNREVIEWED — this plan is not the one that was reviewed.
no deploy plan has been previewed for this host.
plan_hash: 1f98612fcbfacb69b9d31236457d549e00714e4ad0fb0a36e225c3fe6a5f4312
Deploy this plan? Type 1f98612f, the start of its plan_hash, to confirm:Typing those eight characters back is the review: the wetrun goes on with
exactly the plan shown, to the drift preflight, the withdrawal of any recorded
preview, and the steps, as a bound wetrun does. Any other answer is the refusal
above, under a DECLINED banner. The prompt follows zap apply's rules: no
flag answers it, --json never asks, and a script stays non-interactive by
previewing first, passing --expect-plan, or running with standard input that
isn't a terminal (cli.md).
One recorded preview covers one execution: a wetrun that passes the review and
the preflight withdraws it before its first step, so a deploy that fails needs a
new preview (or --expect-plan) before it runs again, and of two wetruns
started on one preview only one goes on. An --expect-plan hash is not
single-use. A preflight bail or an already-deployed exit leaves the preview in
place. A preview doesn't expire.
The preview is a small file under ~/.zap (lock.md), separate from the last-run report that zap apply binds to: a zap plan doesn't disturb a deploy preview, and a
deploy dry run doesn't disturb a convergence one. It holds the hash, not the
plan. A deploy wetrun's drift preflight does replace the convergence record: it
records its pass as the last-run report, which is no preview, so a zap apply previewed before it needs a new preview before its wetrun. With no state
directory nothing is recorded, the run says so, and only --expect-plan binds.
Everything the hash covers is printed but two of Zap's own scripts: the one the
upload installs the archive with, and the swap the auto-rollback runs. So a plan
reviewed under one zap build doesn't bind one whose scripts differ. It covers:
- the version, the tree state, and the previous release on the target;
- the declared target, the host it resolves to, and the connecting user (in the uploaded archive's name);
- who is deploying (
user@hostof the operator machine, recorded indeploy_info.json); - the preflight's state: skipped, nothing declared, or which apply-managed
resources it checks, as a count and a digest of their declarations and the
current pins
zap.lockholds for them; - every step: the build and tarball commands, the upload paths and the script
that installs the archive, the remote script with its
sharedlinks andpreparecommands, the restart command, the health and readiness URLs as they resolve, and the swap the auto-rollback would run; - the value of each reference the
deployblock uses (its health domain), printed in itsreferences:block (a secret's value excepted: the plan records only that it is one).
So a plan reviewed for one host, tree, flag set, or operator does not bind a
wetrun for another: a new commit, a deploy or rollback by someone else in
between, an edit to any declared resource, a changed pin in zap.lock, --force on a dirty tree, --skip-build, --skip-preflight, or --bootstrap each make a different plan. The hash is printed, and the preview recorded, only
when the steps are shown: an already-deployed dry run does neither, so a
redeploy binds to the plan a --force dry run shows.
It does not cover:
- the artifact bytes. The version names the commit, and the plan says whether the tree was dirty, but what the build produces is not hashed. The review covers the script that verifies the archive it was sent, not the archive.
- the values a resource's references resolve to. The digest takes
each declaration as written, so it is the same in every environment; the
environment reaches the hash only through the host, the connecting user, and
the health domain. The same holds for the bytes of a local
sourcefile.
A masked host, connecting user, or health domain can't be deployed to. No
review can bind such a plan: a wetrun is an error that names the reference
before anything runs, and a dry run shows the plan under an UNBINDABLE note,
with no plan_hash and no recorded preview. The ways out: env.md.
Text from the config, the environment, and the target is printed with control
characters written out as <U+XXXX>, so a value can't hide or forge a line of
the preview (security.md).
Preflight
Before deploy touches the network, the machine-free gates run (secrets,
structure, and, for a deploy config, a present health.domain), and a wetrun
rejects any unset reference and a host, connecting user, or health domain behind
a masked reference. zap.lock is read here too, when the drift preflight will
check. --skip-preflight doesn't skip the gates.
Then a wetrun checks its review and runs
the drift preflight: the convergence pass against the config, before
building or uploading. If apply-managed state (an nginx site, the
env file, the service unit) was edited in the config but never applied, shipping
a new artifact against it would be a silent mismatch; the preflight bails with
exit 2, and a change held behind a resource it couldn't read counts as drift. A pipeline, which detects absent and runs on every apply, is not drift, and
neither is a service that changes only because its trigger fires, planned or
held: it reads converged, no pending restart or reload is recorded for it, and
every source that fires it is itself not drift (a pipeline, or another such
service). A service that also drifted, one owed an action an earlier apply
never completed, or one fired by any other change is drift.
It also bails, with the same code, when any resource is undetermined: a gate
that couldn't check everything doesn't pass by default.
After a drift bail, review the changes with zap apply, converge with zap apply --wetrun, and re-run the deploy: its review still binds unless zap.lock changed.
--skip-preflight skips the drift preflight, and a config that declares no
apply-managed resources has nothing to check. Either way the plan says so on its preflight: line.
The deploy also refuses when the live release already has this version, on a
dry run too (server already at this version, exit 0). --force ships anyway.
A current that names this version but whose release isn't on the host serves
nothing, so it isn't refused: the run plans a redeploy and warns that current names a release that is not on the host.
Dirty trees
A tree with uncommitted changes or untracked files git doesn't ignore deploys
only under --force, as <hash>-dirty-<id>. Without it, a wetrun is refused
before the target is contacted (exit 2), since the release would ship the tree
under the commit's version; a dry run shows the plan
(tree: uncommitted changes or untracked files) with no hash, records nothing,
and says the wetrun is refused, even when the commit is the live release.
The id is 8 lowercase hex of a blake3 over what the tree adds to the commit, so the same tree gets the same id (a dry run and its wetrun agree) and a second dirty tree on one commit is a release of its own:
| In the tree | Counts by |
|---|---|
| tracked and staged changes | git diff HEAD --binary |
| an untracked file git doesn't ignore | its name and contents |
| an untracked symlink | the path it points to, as git records one, whether that is a file, a directory, or nothing |
| an untracked nested repository | its name alone |
| a submodule | its commit and whether its own index shows changes, not what they are |
| a sparse checkout | the files outside its cone, as deletions: the tree reads dirty |
Zap reads the tree against a copy of the index's entries that holds no
timestamps or flags, so every file is compared by its contents: a file the
index marks unchanged (assume-unchanged, skip-worktree) counts like any
other, and so does one whose timestamps the index's cache would take for
unchanged. Each read sets core.fsmonitor=false, core.untrackedCache=false, core.ignoreStat=false, core.fileMode=true (a mode change counts), core.autocrlf=false (line endings are not converted), and core.splitIndex=false, and passes --untracked-files=normal and --ignore-submodules=none, so status.showUntrackedFiles, a submodule's ignore setting, and diff.ignoreSubmodules don't narrow it either. So a
checkout on a filesystem without executable bits, or one checked out with core.autocrlf=true, reads dirty, and deploys only under --force.
Two things still decide what counts as a change. The checkout's attributes
(.gitattributes, .git/info/attributes) are applied: a clean filter, LFS
among them, or an eol or text attribute can make a file that differs from
the commit compare equal to it, and no flag turns them off. A submodule's own
index is read as it is, so an assume-unchanged edit or stale timestamps
inside one can read clean.
A sparse checkout reads dirty and deploys only under --force: the files
outside its cone aren't there, so the tree the build sees is not the commit's.
A tree Zap can't read is an error that says what git said, not a clean tree.
The release mechanism
Once the preflight passes, a wetrun runs these steps in order:
build (local) → tarball → upload → extract + swap → restart → health → readiness- build: run
deploy.buildlocally (gro build,cargo build --release, whatever produces your artifacts). With--skip-build, or when no build command is declared, the plan has no build step. - tarball: package
deploy.artifactsinto a release archive, a file in a private directory of this run on your machine, removed when the run ends. - upload: install the archive into
<path>as the connecting user, mode600, under.zap-deploy-<user>-<version>.tar.gz. It is staged beside that name and checked against its SHA-512 before it is renamed in, so a transfer cut short installs nothing. The archive is held in memory while it uploads. - extract + swap: one remote script, generated deterministically and
printed whole by the dry run, does the release-directory work. It reads the
archive's SHA-512 on its stdin, opens the archive once, requires it to be the
connecting user's own file, writable by no one else, with that digest, and
extracts through the open file, so another account on the host can't swap
the archive between the upload and the extraction. Extracted files belong to
the connecting user, under that user's umask, without set-id bits on files.
The script removes the archive on every exit of its own. Then:
- lock: take the app's deploy lock, a kernel
flockon<path>/.zap-deploy.lock, held until the script exits. The file is never removed and holds no data; when Zap creates it, other users get no permission on it. It is opened read-only, so the connecting user and its group can hold it, the group even under a umask that gives it read alone (022). A deploy that finds the lock held stops at once, before it touchesreleases/, withanother deploy is running for this app(exit 1); it doesn't wait. The kernel releases the lock when the script's process ends, however it ends; a lock held by a hung deploy is cleared by ending that process (fuser <path>/.zap-deploy.locknames it). Thepreparecommands run with the lock's descriptor closed, so a process one of them leaves running doesn't keep the lock once the script ends; - recover: under the lock, repair what an unfinished deploy (killed, or
its connection cut) left in
releases/: a staging directory is removed, and a release it had moved aside is put back under its version when that name is empty, or removed when it is filled. So acurrentthat a killed--forceleft naming nothing resolves on the next deploy (a redeploy of that version needs no--force: Preflight). Each repair is a warning in the run's text report (not in a--jsonstream), and happens even when that deploy then fails; - check: still under the lock, confirm
currentnames the release the plan read as live (by the link's last segment, trailing slashes dropped; nothing at all atcurrent, no link and no file or directory, on a first deploy). A deploy planned before another one swapped stops here, sayingcurrentchanged since this deploy was planned (exit 1), having made nothing of its own; runzap deployagain. So the release a rollback returns to, and the one the prune keeps, is the one live just before this deploy's swap. A plan that couldn't readcurrent(it warns) stops here too when the host has one; - extract into a fresh hidden directory,
<path>/releases/.zap-stage; - link shared: symlink each
deploy.sharedentry from<path>/shared/into the new release (an apply-managed env file, for example); a release that ships a directory at a shared name fails the deploy here; - prepare: run each
deploy.preparecommand in the new release, before the swap, in a shell of its own (sh -ec): anexitorexecin one ends that command, not the script, and a command that fails stops the deploy before the swap. Its working directory is the hidden one, not the release's final path, and its output goes to the script's error stream: a step that fails shows the script's own reason when it stopped itself, or else the stream's last lines, and what a command prints becomes no run warning. A command that reaches the script's own output anyway (through/proc, or a descriptor it was handed) can print a line the script marks, a warning or its reason for stopping, which is then read as the script's: text only, since what the deploy does turns on the exit status and the swap; - place: rename the complete release to
<path>/releases/<version>, where the version is the commit's hash abbreviated to 12 hex digits (git rev-parse --short=12, longer only where 12 would be ambiguous), with-dirty-<id>for a forced dirty tree, above. When that name already exists (an earlier deploy of the same version, or the live release under--force), it is renamed aside first, to.zap-old-<version>, and removed after the swap, so a release never mixes files from two archives. A run that fails before this point leaves no hidden directory and changes no release beyond what it recovered; under--forceof the live version,currentnames nothing for the instant between the two renames, and a run that fails after this point leaves the new contents live under that name, the old ones not kept. A run killed between the two renames leaves the old release aside for the next deploy's recovery to put back; - swap: re-point
<path>/currentat the new release atomically. From here the release is live, and the run goes on to restart and check it whatever the rest of the script does: what follows is housekeeping, and a failure there is a warning in the run's output, not a failed step. A script that fails after saying it swapped is taken as swapped only whencurrent, read again, names the new release; - record: write
<path>/deploy_info.json(version, who, when, previous release); - prune: keep
deploy.releasesreleases: the one just deployed, the one it replaced, then the newest others. Only a directory named the way Zap names a release (a commit hash of 4 to 64 hex digits, alone or with-dirty-<id>) is ever removed; anything else underreleases/is left alone. As a guard, the prune removes nothing unlesscurrentnames a release, and on a target with nosortit removes nothing and warns.
- lock: take the app's deploy lock, a kernel
- restart: restart
deploy.servicethrough the target's service manager (with adaemon-reloadfirst on systemd). - health: poll
https://<domain><path>from the operator machine (the one you runzapon) until it answers 2xx, up toattemptstimes,interval_msapart. Neither poll follows a redirect: a 3xx is not healthy or ready, so a release that redirects to a login page doesn't pass on that page's 200. - readiness: when
health.pathisn't already/ready, poll/readytoo. 2xx proceeds. When the attempts run out, the last HTTP status decides; a connection failure after it doesn't replace it. A 404 means the app doesn't serve/ready: the step is recorded asskipped, warns loudly, and the deploy proceeds on liveness alone. Any other answer fails the deploy and rolls back: a 503 (the convention for a database schema that doesn't match the running code, or an unreachable database), any other status, or no answer at all. Pointhealth.pathat/readyto make that one check the gate; then any non-2xx, 404 included, rolls back.
Auto-rollback
If a step after the swap fails (the restart errors, health never goes green, /ready answers 503), Zap re-points current at the previous release,
restarts, and re-checks health (the health URL, not /ready), recording the
rollback as warnings and step events. The deploy still exits 1: rollback
restores service, it doesn't paper over the failure.
The re-point is a compare-and-swap under the app's deploy lock, taken without
waiting. It moves current back only while current still names this deploy's
release and the previous release is still on the host. Otherwise (another
deploy holds the lock or has replaced the release, someone re-pointed current by hand, current names nothing, or the previous release is gone) the rollback
is skipped with a warning that says why: nothing is re-pointed or restarted, and
the deploy still exits 1.
There is nothing to roll back to on a first deploy, or on a --force redeploy
of the live version: current stays at the failed release, and the run warns
that a manual rollback is needed.
When the restored release's health check fails too, the rollback step is recorded as failed, and the warning says manual intervention is needed.
The previous release is read from the live current symlink at deploy time,
not from a recorded file, so a manual rollback done by hand is seen correctly
on the next deploy. A current whose last segment, trailing slashes dropped,
is no release's name names no previous release, so the deploy refuses before
it builds anything: point current at the live release by hand, or remove it
to deploy as onto a fresh host.
Concurrent deploys
A deploy planned before another deploy's swap stops at its check (step 4), so a
rollback never undoes a newer deploy, with one exception: a --force redeploy
of the same version, planned after a deploy's swap while that deploy is in its
health check, writes the same current, which the first deploy's rollback
can't tell from its own and can undo. A rollback skipped because another deploy
held the lock leaves current at the failed release if that other deploy then
fails before its own swap; the warning says to check it.
The lock covers the release-directory work only: the restart and the health checks run after the script releases it, so a deploy can report on a host that another deploy has since moved on. Two simultaneous deploys of one version by one connecting user upload to the same name, so either can replace or remove the other's archive; the other then stops at its digest check or finds no archive (exit 1) rather than ship bytes it didn't build.