How Zap compares #

Zap converges the inside of Linux machines you own and ships your services to them. It is a TypeScript declaration and a Rust engine over SSH, with dry run by default, no agent, and no server. This page puts it next to the tools you might otherwise reach for. It is written for someone with a few servers and a laptop, not a fleet, and it covers when not to use Zap as much as when to.

Facts about other tools name versions where they matter. If something here is out of date, please file an issue.

What Zap is: introduction.md, including the things it does differently. The verdict comes first; the side-by-side tables are at the end.

Use Zap when

  • You run a handful of Debian/Ubuntu or Alpine hosts you reach by SSH, and want their packages, users, SSH hardening, firewall, nginx, services, and an on-box PostgreSQL declared in one file.
  • You want nothing on the target beyond sshd, sudo, and a stock shell and core tools: no agent, no Python, no server, no state bucket.
  • An AI agent or a non-ops teammate will drive the tool, and you want the default command to be harmless, a plan they can show you with a hash, exit codes that separate "converged" from "drifted" from "couldn't tell", and typed events rather than log scraping.
  • You'd rather be told "I couldn't read this" than have a tool guess.
  • You deploy your own service as release directories with a health gate and rollback, and want the deploy to refuse when the host has drifted.
  • You prefer TypeScript with completion over YAML with a template language.

Don't use Zap when

  • You need many hosts in parallel or fleet orchestration. One config, one host, resources in order. Ansible, pyinfra, Salt.
  • You need cloud resources. VPCs, DNS, load balancers, managed databases. OpenTofu, Terraform, Pulumi; they pair with Zap.
  • You need removing a declaration to remove the thing. Zap only reports orphans. NixOS, or Terraform for cloud resources.
  • You need macOS, Windows, RHEL, Arch, or network devices. Ansible, chezmoi, mise.
  • You want whole-system rollback or reproducibility. NixOS, image-based systems.
  • You're container-native. Kamal, Compose, Dokku, Coolify; Zap can still converge the host underneath.
  • You need branching on mid-run results. Ansible, or a script.
  • Your machine can't run fgro. It needs x86_64 Linux with Landlock ABI 6 (kernel 6.12 or later).
  • You need a module ecosystem and community support. Zap is pre-alpha with one maintainer. Ansible.

Head to head

Ordered by how often a reader is choosing between the two.

Versus Ansible

The closest in operational shape: agentless, SSH, no server. The furthest in control model. An Ansible playbook is an ordered list of tasks; most modules converge within one task (state: present), but the playbook is a procedure, and decisions are made at run time against gathered facts, including facts an earlier task in the same run changed. Zap has no task order you author: you declare resources and dependencies, and the engine decides create, update, or skip from detection.

Ansible's --check mode is opt-in and its fidelity depends on each module: command and shell are simulated only when given creates or removes. Handlers notified by a task are lost if a later task fails, unless you set --force-handlers; Zap remembers an owed reload on the host. Ansible needs Python on the managed node for nearly every module; Zap needs a shell. Ansible's validate: on copy and template matches Zap's validate on a file.

Reach for Ansible for its enormous module ecosystem, for fleets and inventories, for Windows and network devices, or for run-time procedural flexibility. Zap is narrower and younger by orders of magnitude.

Versus pyinfra

The closest living tool to Zap's shape: agentless, SSH, generates shell so the target needs no Python, written in a real language, two-phase (facts first, then operations). The differences are where the two draw their lines. pyinfra lets deploy code read facts while deciding what to do, so the declaration can depend on the host; Zap seals the declaration before contact and doesn't read the host while deciding. pyinfra shows its changes and prompts before acting, and warns the list may be incomplete; Zap's dry run is the default and executes nothing. pyinfra has no lock file pinning fetched content, no held verdict, and no re-read after acting. pyinfra runs many hosts in parallel; Zap runs one.

Reach for pyinfra for Python, for parallel multi-host runs, or when you want the host's facts to shape the deploy.

Versus Docker Compose, Kamal, and self-hosted PaaS

For a solo developer with a VPS, this is the realistic alternative. Compose, and platforms like Dokku, Coolify, and CapRover, assume a prepared Docker host and own the application layer: images, containers, routing, often TLS. They don't converge the host: SSH hardening, the firewall, users, an on-box PostgreSQL, unattended upgrades. CapRover and a self-hosted Coolify run a control plane on your server that you have to keep; Coolify's hosted cloud keeps it for you, over servers that are still yours to converge. Dokku is git push to one box.

Kamal 2 ships Docker images and does rolling restarts behind kamal-proxy, which health-checks the new container before switching traffic, a gate that is stronger than Zap's post-swap health check plus rollback. Kamal has no dry run. kamal setup installs Docker but doesn't otherwise manage the host.

Zap deploys release directories, not containers, and its isolation comes from converging the host (a hardened systemd unit, a dedicated user) rather than from an image. The two are complementary: Zap can converge the box these tools run on.

Reach for Kamal or Compose when you're container-native or want multi-host deploys; for Coolify or Dokku when a UI or git push is the experience you want and you're happy to run their control plane.

Versus OpenTofu, Terraform, and Pulumi

The closest in discipline: declarative, converge, review a plan before acting. Terraform's plan is computed by refreshing a state file that is the authoritative record of what Terraform owns, and data sources read live infrastructure during planning, so the desired state is a function of config, state, and the world. Zap's declaration is a function of the config alone; detection decides only which declared changes run. There is no state file: the machine is read every run, and zap.lock pins content, not resources.

Credit where due: Terraform's saved plan (plan -out, then apply <planfile>) is the most operationally binding plan artifact in the field, and it refuses a plan whose state has moved since. Zap's wetrun binding does the same job for a machine, by hash. Terraform also destroys a resource you stop declaring; Zap only reports it as orphaned.

Pulumi takes "a real language" the other way across the boundary: the program runs against the live engine, resources register over RPC, and apply() callbacks run mid-deploy. Zap's TypeScript emits data and stops.

Scope differs more than model: both are cloud-resource tools with provider ecosystems. Zap converges the inside of a machine and has no providers. Pair them: OpenTofu for the droplet and DNS, Zap for what's on the droplet.

Reach for OpenTofu, Terraform, or Pulumi for cloud infrastructure, for a removed-means-destroyed model, or for mature team workflows around state.

Versus NixOS

The nearest philosophical kin: both make the declaration deterministic. Nix makes purity a precondition: builds are sandboxed, the whole system is a function of a pinned expression, and you adopt the store and the language wholesale to get reproducibility and atomic rollback. The system closure is a more complete inert artifact than Zap's declaration, and nixos-rebuild build, dry-activate, and nix store diff-closures let you inspect it before switching. deploy-rs adds a magic rollback over SSH; nixos-anywhere installs a machine from nothing.

The difference is prevention versus measurement. Nix prevents drift in what it owns and is silent about the rest (/var, database contents, mutable users); activation reasserts without reporting. Zap reads and reports drift on every run, including the security controls, and repairs it. You keep your distro and your package manager; you give up atomic generations and whole-system rollback. Guix System is the same model in Scheme; image-based systems (bootc, Fedora CoreOS, systemd-sysext) are the other prevention answer: don't converge, replace.

Reach for NixOS for whole-system reproducibility and atomic rollback, if you're willing to adopt the model end to end.

Versus Puppet, Chef, and Salt

The same convergence model, a different operational one: agents that pull and converge continuously, usually against a server, with mature resource models and fleet tooling. All three run masterless too.

Puppet deserves specific credit: it compiles a catalog, an inert JSON resource graph, before applying, and the catalog can be compiled without applying and diffed. The contrast with Zap is that the catalog's inputs include facts gathered from the node, which is exactly the host-reactivity Zap declines to model (below). Chef's why-run is best-effort by its own documentation. Salt's test=True and state.show_highstate give an inspectable compiled state; salt-ssh is agentless but needs Python on the target unless it ships Salt's bundled runtime (--relenv).

Puppet and Chef have both moved their official builds behind commercial terms, with community forks (OpenVox, Cinc) carrying the free builds. Salt is actively released under Broadcom, with many modules moving to community extensions.

Reach for one of these for a large fleet that wants continuous, pull-based convergence and a deep existing ecosystem.

Versus Capistrano, Deployer, and Mina

On the deploy axis the comparison shifts: this is release push, not convergence. Capistrano and its kin ship a versioned release into releases/ and swap an atomic current symlink, the same model Zap's deploy uses, with keep_releases (default 5) and a manual deploy:rollback. None has a built-in health gate or automatic rollback; Capistrano usually clones git on the server, where Zap builds locally and uploads a tarball. Mina generates one bash script per deploy and prints it with --simulate, as Zap's dry run prints its remote script. Mina is dormant: no release since 1.2.5.

Zap's additions are the drift preflight (the deploy refuses to ship onto a host whose convergence config has drifted), the automatic rollback on a failed health gate, and that deploy rides the same engine as convergence.

Reach for Capistrano or Deployer for a mature multi-host release tool with a plugin ecosystem in the Ruby or PHP world.

Versus plain shell and cloud-init

The honest baseline: a setup.sh, a Makefile, a cloud-init file. These are imperative bootstrap: top to bottom, no idempotency guarantee, no notion of current state, no dry run, and cloud-init runs most modules once per instance rather than as a loop. Zap's value over this baseline is exactly what convergence buys: skip what's done, re-run safely, preview by default, report after.

Reach for plain shell when the task is one-shot, tiny, or so bespoke that a resource model is more ceremony than it saves.

Personal machines: mise, chezmoi, home-manager, Brewfile

Zap also sets up a developer's own machine: build tools, a terminal, dotfiles, packages. The dedicated tools are better at their one job: mise pins tool versions with a lockfile, chezmoi diffs and templates dotfiles across machines and supports macOS and Windows, home-manager brings the Nix model to a user's environment, brew bundle declares macOS packages. Zap's case is one model for the laptop and the servers, with the same file, pkg, build, and script resources, and the same dry run. It doesn't run on macOS.

How each tool reacts to the host

Zap's declaration is sealed before the machine is touched: the config runs with no network and no environment, and detection then decides only which declared changes run. That is why there's no state file, why the plan has a stable hash a wetrun can be bound to, and why secrets stay inert until use. It splits "reacting to what's on the host" into three cases:

  1. Detection: gating a declared action on observed state ("install only if missing", "restart only if the config changed"). This is the convergence loop itself, plus a script's check, verify assertions, and triggers. Under convergence you don't write "if X, do Y" when Y is idempotent; you declare Y and detection skips it.
  2. Derivation: desired state computed from host facts ("swap is twice the detected RAM", "apt on Debian, apk on Alpine"). Terraform does this with data sources, Puppet and pyinfra with facts, cdist with explorers. Zap doesn't read the target during eval; a script that computes on the target covers these cases.
  3. Mid-run reactivity: decisions from results a prior step produced. That is orchestration (Ansible's when: over run-updated facts, a shell script), and Zap doesn't do it. Put that logic inside one script, or compose Zap with a tool built for it.

A script's run is arbitrary shell, so the cost is legibility, never capability. Two caveats: on a local target the config runs on the machine it describes, so it could read host state at eval time (outside the model, not prevented); and determinism is by construction for the common sources (the clock and randomness throw, the time zone and locale are fixed) rather than proven, where Nix's sandboxed evaluation is the stronger claim.

Also in the space

cdist: agentless SSH push, type-based, generates shell from explorers run on the target; the closest historical kin, now low-activity. mgmt: a declarative, event-driven engine with a real-time graph that converges continuously, Zap's polar opposite on timing. Fabric, Rex, Puppet Bolt / OpenBolt: imperative remote task runners. Kubernetes: the purest convergence model there is, continuous reconcilers over etcd, and for a handful of VPSes the cost is the cluster itself. Dagger: a CI/CD pipeline engine, not a host tool; Zap isn't a CI engine.

At a glance

One table per axis. Zap's cells are exact; the others are our best understanding and are kept general on purpose.

Model: how each tool decides what to do

ToolControl modelDesired state fixed whenCan read the target while deciding?Plan artifactPlan binds the apply?
Zapconverge, one-shotat eval, before contactno (SSH); possible but outside the model (local)changeset with a blake3 hashyes: a wetrun must match the reviewed hash
Ansibleordered idempotent tasksat run time, per taskyes (facts, register)none; --check simulatesn/a
pyinfraordered operations over factsafter fact gathering, before executionyes (facts)the change summary before the promptno
Terraform / OpenTofuconverge, graphplan timeyes (data sources)saved plan file; JSON via show -jsonyes: apply <planfile> refuses a stale plan
Pulumiconverge, live programduring the updateyespreview; saved update plans (experimental)experimental
Puppetconverge, graphcatalog compileyes (node facts)catalog (JSON), compilable without applyingthe agent applies the catalog
Chefconverge, ordered resourcescompile phase on the nodeyes (Ohai, arbitrary Ruby)none; why-run is best-effortn/a
Saltconverge, requisitesrender timeyes (grains, pillar)highstate data (show_highstate)no
NixOSbuild then activateeval time, pure with flakesnothe system closure, a hashed store pathyes: you activate the closure you built
cdistconverge, types to shellafter explorers runyes (explorers)generated shell (-n)no
Kubernetescontinuous reconcileon API writen/akubectl diff, server-side dry runno
Capistrano / Deployer / Minarelease push, procedurerun timeyesnone (Deployer --plan lists tasks; Mina --simulate prints the script)n/a
Kamalcontainer release pushrun timen/anonen/a
Compose / Dokku / Coolifycontainer appson deployn/anonen/a

Operations: what you run and where

ToolOperator machine needsTarget needsServer or state backendState of recordReaches the target byMany hosts
Zapone binary plus fgro (Gro's runtime), sshsshd, POSIX shell, sudononethe live machine, plus zap.lock for pinsSSH, or localone host per config, resources in order
AnsiblePythonSSH plus Python (except raw)none (AWX optional)the machineSSH; ansible-pullyes, inventory, parallel forks
pyinfraPythonSSH plus shellnonethe machineSSH, local, Dockeryes, parallel
Terraform / OpenTofuone binary plus providersn/a (APIs)local state file by default; remote backend with locking for teamsthe state fileprovider APIsn/a: resources, not hosts
Pulumibinary plus a language runtimen/aPulumi Cloud by default, or self-managedthe state backendprovider APIsn/a
Puppet / OpenVoxserver, or puppet applyagent with bundled Rubyserver plus PuppetDB, or masterlessthe machine, plus catalogs and reportsagent pull every 30 minyes, fleet scale
Chef / Cincserver, or local modeagent with bundled RubyChef Infra Server (the open-source server is end-of-life; Chef 360 or the Cinc Server fork)the machine plus node objectsagent pullyes
Saltmasterminion, or salt-ssh with Python on the targetmaster, or masterlessthe machine plus job cacheZeroMQ or SSHyes
NixOSNixNixOSnonethe Nix store and generationslocal, --target-host, deploy-rs, colmenavia deploy-rs or colmena
cdistPythonSSH plus shellnonethe machineSSHyes, parallel
Kuberneteskubectla clusteretcdetcdAPI serveryes
Capistrano / Deployer / MinaRuby or PHPSSH, git for Capistranononereleases/ on the hostSSHyes
KamalRuby, or its Docker imageSSH plus Docker (installed by kamal setup)a container registrycontainers on the hostSSH plus registryyes, rolling
Coolify / CapRovera browserDocker plus their control plane on a serveron-server control planeits control planeits UI or agentyes
DokkugitDocker plus Dokkuon the serverDokku's configgit pushone server (schedulers for more)

Safety: what protects you from a bad run

ToolDry-run postureDry-run fidelityA read that failsAfter actingSecrets at restRollback
Zapdefault; --wetrun to act; on a terminal with nothing reviewed, asks for the start of the plan_hash (no flag answers it)full detection, nothing executesheld: out of the plan, exit 1re-detects; anything not converged is RESIDUAL, exit 1none stored; references resolved at apply and maskeddeploy: previous release, health gate; convergence: re-apply the old config
Ansibleopt-in --check (+ --diff)per module; command/shell need creates/removestask error or unreachablenone built in (assert tasks)Ansible Vault, encrypted in the reponone built in
pyinfrashows changes, then prompts (-y skips); --dry exits afterfact-based, may not include every changeerrornoneuser-managednone
Terraform / OpenTofuplan, then prompt (-auto-approve skips)high; "known after apply" unknownsrefresh fails the plannext plan shows driftplaintext in state (OpenTofu 1.7+ can encrypt; Terraform ephemeral values from 1.10)re-apply the old config
Pulumipreview, then prompt (--yes skips)higherrornoneencrypted per stacknone built in
Puppetopt-in --noophigh for native types; exec guards run, commands don'tresource fails, run continuesnext runHiera eyaml and the likere-apply the old catalog
Chefopt-in --why-runbest-effort, with assumptions; Chef's own blog calls it harmfulerrornext runencrypted data bags, Vaultnone
Saltopt-in test=Trueper moduleerrornonepillar, GPG renderernone
NixOSbuild or dry-activate before switchthe build is exact; activation partlythe build fails before touching the systemn/aworld-readable store unless agenix or sops-nixatomic generations, boot menu
Kubernetes--dry-run=server, kubectl diffhighreconciler retriescontinuousbase64 by default; encryption at rest opt-inrollout undo
Capistrano / MinaMina --simulate; Capistrano noneprinted scripterrornonelinked_filesdeploy:rollback, manual
Kamalnonen/aerrorkamal-proxy health-checks the new container before switching.kamal/secrets with password-manager adapterskamal rollback <version>

Scope and maturity

ToolPrimary scopeAuthoringConfig typed?OS coverageMaturity
Zapinside a Linux box, app deploy, dev toolsTypeScript pure function to JSONyes: TS types plus a strict schema at planDebian/Ubuntu, Alpinepre-alpha, one maintainer, small fixed resource set, no plugins
Ansiblemachine config and ad-hoc opsYAML plus Jinja2lint onlyvery broad, Windows and network devices includedhuge; Galaxy collections
pyinframachine configPythonPython typingbroad POSIXsmall to medium; active (3.x)
Terraform / OpenTofucloud resourcesHCLschema-validatedn/ahuge; provider registry. Terraform is BUSL-licensed from 1.6; OpenTofu is the MPL fork
Pulumicloud resourcesTS, Python, Go, C#, Java, YAMLyesn/alarge
Puppet / Cheffleet machine configPuppet DSL, Rubypartialbroad, Windows includedlarge and mature; both moved their official builds behind commercial terms, with community forks (OpenVox, Cinc)
Saltconfig, remote execution, eventsYAML plus Jinja and other renderersnobroadlarge; actively released under Broadcom, many modules moving to extensions
NixOSthe whole OSNixmodule system checks at evalNixOS (nix-darwin for macOS)large; steep curve
Kamalcontainer app deployYAMLnoany Docker hostmedium; 37signals-backed
Compose / Dokku / Coolifycontainer appsYAML or a UInoany Docker hostlarge
mise / chezmoi / home-managerdev tools, dotfilesTOML, templates, NixnoLinux and macOS (chezmoi: Windows too)medium to large