What works¶
Snapshot: Punix 0.3.0, July 2026. The suite is green on Python 3.13 + 3.14, x86_64 + aarch64 Linux.
This page answers one question in plain language: if you install Punix today, what can you actually do with it?
The short answer is three things, and they are the same machinery each time:
| You want to… | Command | And then you can undo it |
|---|---|---|
put software on your $PATH |
punix install ripgrep |
punix profile switch 4 |
| configure a machine | punix service deploy WebStack |
punix service rollback WebStack |
| configure twenty machines | punix fleet apply MyFleet |
punix fleet rollback MyFleet |
Everything below is an elaboration of that table. The undo column is the interesting one.
1. Install software without a package manager owning your machine¶
punix install builds a package from source into a content-addressed store (a directory whose name is a hash of everything that went into it) and links its binaries into your profile.
$ punix install just # → gen-001 (just on PATH)
$ punix install atuin # → gen-002 (just + atuin)
$ punix uninstall just # → gen-003 (atuin only)
$ punix profile list
gen-001 2026-05-27T… just
gen-002 2026-05-27T… atuin, just
* gen-003 2026-05-27T… atuin
active: gen-003
$ punix profile switch 2 # changed your mind
switched to gen-002
$ just --version
just 1.51.0
Setup is one line in your shell rc: export PATH="$HOME/.punix/current/bin:$PATH".
Why the hash matters, without the theory. Two packages that need different versions of the same library get different store paths, so they simply coexist: no conflict, no "this formula was upgraded and broke your other tool". And because the name is the content, a package your machine already has is a package it never downloads again.
Switching profiles is one rename syscall. Not a re-install, not a re-link, not a transaction log. That's why profile switch is instant regardless of how much is installed, and why a power cut in the middle leaves you on the old profile rather than on half of the new one.
What's in the catalogue: 385 recipes build green from source today: no Homebrew underneath, no prebuilt toolchain. That covers 67% of Homebrew's 500 most-installed formulae. The corpus pages list every one by topic, and blockers names the ones that don't build yet and what each needs.
Finding things:
punix search 'json' # regex over names + descriptions, ~150ms, no index
punix info ripgrep # version, deps, and where it will land in the store
punix list # what's in the active profile
punix upgrade --check-all # which packages have a newer upstream release
→ install · profile · search · info
2. Configure a machine, and be able to put it back¶
You describe the machine in a typed config file (services, config files, secrets) and deploy it:
Punix builds whatever needs building, writes the config files, renders the service units, and then flips the machine to the new state in one atomic step. Then:
That puts every config file back byte for byte and re-points the machine at the previous state. It takes about a second whether the change was one line or a hundred files, because nothing is rebuilt; the previous state is still on disk, pinned so it cannot be garbage-collected out from under you.
It works even if you delete the source file. The machine records what it is, not just what you asked for. Rollback reads that record.
What a generation records: every config file and its sha256, every store path the stack pins, the backend, and which recipe produced each file. It does not record secret values; see below.
→ service · Generations and rollback
3. Ask whether the machine is still what you said it was¶
Someone SSH'd in and edited nginx.conf at 2am. Six weeks later, does anyone know?
$ punix verify WebStack
ok /etc/systemd/system/api.service
ok /etc/systemd/system/worker.service
MODIFIED /etc/nginx/conf.d/app.conf (recorded 3f2a91cc4b07… now 8e14bd0a72f1…)
missing /etc/app/app.conf (declared, not on the target)
WebStack gen-003: 4 checked, 2 clean, 1 MODIFIED, 1 missing
This is the question Chef and Puppet run a permanent agent to answer, and the one Ansible cannot answer at all because it keeps no record of what it declared. Punix already wrote the hashes down at deploy time, so drift detection is a read-only command over data that was already there: no agent, no daemon, nothing extra installed on the target.
It never repairs. An audit command that edits the thing it audits is not an audit command; putting the file back is service rollback.
→ verify
4. Ask who wrote this file, and why¶
punix why config.pcl WebStack.port # the value, and every line that contributed to it
punix service why WebStack /etc/nginx/conf.d/app.conf
The second one is for deployed files: which recipe wrote it, a hash of that recipe's code, and (if the file consumed something from another host, like a TLS cert or a WireGuard public key) which host produced it and what digest arrived.
→ why
5. Do all of it to a fleet, from one file¶
A Fleet module lists hosts and the stack each one runs. One command deploys them all:
The point isn't the loop; you can write a for loop. The point is that the hosts can depend on each other and Punix works out the order. A reverse proxy that needs the backend's TLS certificate gets it because the certificate is a declared artifact produced by one host and consumed by another; Punix deploys the producer first, carries the value across, and records the dependency in both generations.
Rollback runs the same graph in reverse, so a consumer never spends a moment pointing at a producer that no longer exists.
A failing host doesn't abort the others (unless you ask for --fail-fast), and you get a per-host report.
→ fleet · Deploying a fleet · Cross-host artifacts
What the target machine has to have installed¶
For a config-only deploy: sshd and rsync. That's the whole list. No Punix on the target, no Python, no agent, no daemon. You still get generations, byte-exact rollback, provenance and drift detection.
You only need Punix installed on the target if you're building software from source for a platform your control node isn't (a Mac deploying to Linux, say). That's physics, not packaging.
→ What the target needs, including a precise Ansible comparison
The awkward parts, handled¶
Secrets. Write {from_env = "DB_PASSWORD"} or {from_file = "/run/secrets/db"} in your config. The value is read at deploy time, lands in the running service's environment, and goes nowhere else: not into the build hash, not into the store, not into the generation record. A test proves that two deploys differing only in a secret value produce byte-identical records. If three secrets are missing you're told about all three at once, not one per run.
TLS certificates. Declared, issued or supplied, and distributed to the hosts that consume them, with renewal on one host not forcing a redeploy on another.
Databases and stateful services. Data directories, ownership, and the bits that must exist before the daemon starts; provision handles the ordering so a service doesn't come up pointing at an empty directory.
Structured config as data. Instead of templating a YAML file and hoping, you write dataFiles with a format and a data value, and Punix serializes it. A value of yours cannot escape into syntax; every string stays a string. And since data is an ordinary config value, one fleet model can generate per-host variants instead of you copy-pasting them.
Your own machine, no root. scope = "user" writes config under your home directory and services into ~/.config/systemd/user/. Same deploy command, same generations, same rollback. Manage your dotfiles.
A daemon Punix has never heard of. Vendor a config recipe, pin its sha256 in a manifest, pass --recipe-pins. Punix reads the file exactly once: the same bytes it verifies are the bytes it runs. No fork required. Vendored recipes.
The guardrails you get for free¶
These are on by default and a config file cannot switch them off:
- Punix will not write outside the roots you allowed. Not via a symlink, not via
.., not via a path that resolves somewhere else on the target than it did locally. Some paths are refused outright (~/.sshand~/.punixin user scope, the privilege-escalation surfaces in system scope) on the rule that rollback can put a file back but cannot un-steal a key. - Punix decides file permissions, not the recipe. A config recipe cannot ask for a world-readable secret file.
- A generated config is escaped by shape, not by hand. Values going into nginx, systemd, or a shell command are quoted by the emitter that knows that format's rules.
- Every deployed file's provenance is assigned by Punix, not claimed by the thing that wrote it.
Each of these exists because a red-team exercise or a real failure found the gap. Config-file path confinement has the details.
Proof: real systems, reproduced¶
The first four examples are ours. The last three are reproductions of infrastructure other people actually run. We didn't get to choose the shape, so they are the stricter check.
examples/… |
What it proves |
|---|---|
hello/ |
The whole model in miniature: build → config → unit → generation. One Docker command. |
webserver/ |
A live HTTP service, curl'd, asserting the served bytes are the deployed ones. |
tangled-deploy/ |
Deploy once, then docker restart: the stack self-starts from the persisted generation. Git node + web UI + redis + nginx, all built from source, HTTPS, secrets. |
tangled-fleet/ |
One fleet apply on a control container deploys two stacks to two systemd hosts over real SSH, and one reverse-proxies to the other across the network. |
tangled/ |
All 9 hosts of the real tangled.org fleet, rendered. |
nlnet/ |
An NLnet-class fleet: a WireGuard mesh derived from one model, plus self-hosted mail. |
mailserver/ |
A port of Simple NixOS Mailserver: four daemons agreeing on sockets, lookup tables and a 0600 password file, from one typed record. |
Each has a run-docker.sh or a README.md in the repo. The Docker ones are their own test: the container's exit code is the assertion. The rest are pinned by tests in make test, so they can't quietly rot.
Under the hood, briefly¶
For readers who want to know what's holding all this up:
- A typed configuration language with a type checker that provably terminates, and errors that always carry
file:line:coland a code. - Order-independent evaluation. Shuffle the source files and you get byte-identical results, verified by a conformance test.
- Sandboxed, reproducible builds with recipe classes for cargo, go, cmake, autotools, meson, npm, pip, swift, ruby and plain shell.
- A hard line between "working out what should happen" and "making it happen." Nothing above that line touches the disk; nothing below it participates in the language. It's why the config layer stays decidable and why rollback can be one syscall.
- One dependency-light wheel:
pip install punixpullscycloptsandtabulate, and nothing else.
→ Three pillars · Architecture · Conformance
How it got here¶
| Stage 0–2 ✅ | The language, the type checker, order-independent evaluation, provenance |
| Stage 3 ✅ | Real sandboxed builds; the content-addressed store |
| Stage 4 ✅ | Atomic deploys, generations, garbage collection that respects pins |
| Stage 5 ✅ | SSH transport and closure push: the same guarantees on a remote host |
| Stage 6 ✅ | Secrets that stay out of the hash; multi-arch CI |
| Stage 7 ✅ | Corpus scale: 385 recipes, the brew→PCL translator |
| Since ✅ | Fleets, cross-host artifacts, TLS, user scope, the safety kernel, drift detection |
| Stage 8 🚧 | Lifecycle polish and a second service backend; see the roadmap |
What doesn't work¶
There's a published list that labels each item as deliberate, deferred, or a known bug: Known limitations.