One Nix store.
A room for every project.
nixenv downloads your whole toolchain once, into a single Nix store, and gives each project a small, disposable container that mounts it read-only. Containers start in seconds, every project runs the same pinned tools, and none of them can see the others.
$ brew install rande/nixenv/nixenv
How it works
No images to rebuild, no host Nix install. Everything happens in containers, driven by one bash script that carries its own flake, entrypoint and dotfiles.
Build once
A short-lived nixos/nix container realises the pinned flake into a named volume: shell, editor, git, Claude CLI, the projects gateway, the exit proxy and friends.
Mount everywhere
Each project is a debian:stable-slim container with /nix mounted read-only. Nix binaries carry their own libraries, so the base image barely matters.
Add per project
A flake.nix in your repo adds that project's runtimes and services into its own profile, layered ahead of the base — and shared paths aren't rebuilt.
Work inside
ssh myapp drops you into a persistent zmx session; your dev server is at https://myapp-3000.nixenv.localhost with a trusted cert.
Five commands, start to finish
From nothing to a shell inside a project, with HTTPS routing and an egress allowlist already in place. Click a step to jump the replay.
- nixenv buildDownloads the shared toolchain into the store volume. Slow once, then never again.
- nixenv init myapp <git-url>Clones into the project's own volume, asks for a git identity, and allows the forge in the egress list.
- nixenv start myappStarts the container as your user, with sshd under runit and the shared HTTPS proxy.
- nixenv allow myapp registry.npmjs.orgOpens one more host. Everything else stays denied — watch it with
nixenv egress myapp. - ssh myappA persistent terminal session. Close the laptop, reconnect, and it's still there.
Room schedule
What every project gets, without configuring anything.
| Ref | Item | Specification |
|---|---|---|
| R-01 | One pinned toolchain | Built once from a flake, mounted read-only into every project. Update it in one place; every container sees it on the next start. |
| R-02 | Per-project flakes | Node, PHP, Python, Go, databases — whatever the repo declares, in a profile of its own, ahead of the base on PATH. |
| R-03 | Docker or Podman | Auto-detected. Everything runs as your uid; nothing in a project container is root. |
| R-04 | SSH + persistent sessions | ssh myapp and ssh myapp.tests attach named zmx sessions. VS Code Remote-SSH works from the same config. |
| R-05 | HTTPS for every port | https://<project>-<port>.nixenv.localhost, routed by the projects gateway, trusted via mkcert — reachable from inside containers too. |
| R-06 | Egress allowlist by default | Projects reach only the hosts you allow. nixenv egress shows what was denied, so the list writes itself. |
| R-07 | Services under runit | Declare sv/<name>/run in the repo or flake; databases and workers start with the container and restart if they die. |
| R-08 | Export / import | Move a project between machines as one archive. Secrets stay out unless you ask, and imported settings are checked, not trusted. |
| R-09 | Claude CLI built in | One login for all projects; settings, hooks and MCP servers stay per project. |
| R-10 | Ready-made stacks | init --template=wordpress — or cloudflare, symfony, directus-astro, windmill. One file becomes the project's flake; the app installs itself on first start. |
| R-11 | Traffic capture | nixenv capture records a project's HTTP(S), in and out, with mitmproxy — in a web UI or the terminal — without loosening the allowlist. |
| R-12 | A single file | The whole tool is one bash script (Bash 3.2 compatible), with its flake, entrypoint and dotfiles embedded. |
A running stack in one command
A template is one file that becomes the project's flake.nix: the toolchain, the services, and a startup hook that installs the app on first start. What you get is an ordinary project — real files in your app volume, yours to edit and commit. Pick a sheet.
On first start wp-cli downloads WordPress core, creates the MariaDB database, installs the site and its plugins. nginx and php-fpm run as services; outbound requests go through the exit proxy, which the template has already allowed WordPress's hosts on.
nixenv init myblog --template=wordpress # asks before building nixenv start myblog # → https://myblog-8080.nixenv.localhost/
- Allowlist included. A template declares the hosts its setup needs, so a restricted project can install itself on day one.
- Pinned to your version. Short names resolve to the templates that shipped with your nixenv, never a moving branch.
- Bring your own.
--template=./mystack.nixor an https URL — copy a shipped one and edit it.
See every request
What is that dependency calling home to? Why does the webhook fail? nixenv capture records a project's HTTP and HTTPS traffic — headers and bodies, both directions — with mitmproxy, and shows it in a web UI or your terminal.
❯ nixenv capture myapp on ! Captures record EVERYTHING that crosses the wire — tokens, cookies, git ! credentials included. They stay on this machine (owner-only) ✓ capture ON for 'myapp' (egress ingress) Restart 'myapp' now? [y/N] y ❯ nixenv capture myapp log -f 09:14:02 egress GET https://registry.npmjs.org/vite 200 09:14:03 egress POST https://api.stripe.com/v1/payment_intents 200 09:14:05 ingress GET http://myapp-3000.nixenv.localhost/ 200 09:14:09 egress TLS-REFUSED telemetry.example-sdk.io ❯ nixenv capture myapp web ==> mitmweb UI (pre-filtered to 'myapp'; clear the filter to see every captured project): https://myapp-mitm.nixenv.localhost/?token=3f9c…
- Behind the allowlist, never instead of it. The recorder only sees what the exit proxy already let through. A refused host still gets its 403, and is still never looked up.
- Both directions. Egress: everything the project sends out. Ingress: requests to its
https://<project>-<port>addresses, on their way in — the app still sees its public host name. - HTTPS in the clear. While capture is on, the container trusts the recorder's certificate — one restart, which
capture onoffers. An app that pins its certificates shows up asTLS-REFUSED. - Out of the projects' reach. No project can talk to the recorder directly. The web UI is served by the gateway at
myapp-mitm.nixenv.localhost, behind a password, and no restricted project can open it — not even the one being recorded. - Treated as secrets. Recordings hold tokens and cookies, so they're owner-only on your machine and
deleteremoves them.
nixenv capture myapp on # both ways nixenv capture myapp web # web UI address nixenv capture myapp log -f # one line per request nixenv capture myapp tui # console UI nixenv capture myapp har out.har # for browser devtools nixenv capture myapp off
Built for code you only half trust
A project container runs cloned repos, their dependencies and their setup scripts. So each one is treated as a sandbox: it shouldn't reach your machine, another project, or the internet beyond what you allowed.
Default-deny egress
Restricted projects sit on an internal network with no route out. The only exit is an allowlist proxy, and a refused name is never resolved — so DNS can't be used to leak data.
Projects can't reach each other
A restricted project gets a 403 for another project's URLs unless that project opts in. The proxy's port relays aren't reachable from project networks.
Key-only SSH, pinned host keys
Each project has its own key, generated on your machine and mounted read-only. The container's host key is pinned, so a squatter on the port is refused.
Hardened containers
Non-root, all capabilities dropped, no privilege gain through setuid binaries, and a process limit so one runaway project can't take the rest down.
Pinned downloads
The base build is pure: every fetch is hash-pinned. A swapped upstream file fails the build instead of reaching your containers.
Templates are pinned
A template is code, so short names resolve to the ones that shipped with your version, plain-http templates are refused, fetched ones are hashed, and init asks before building one.
Recording stays behind the allowlist
The recorder only sees traffic the allowlist already let through, listens where no project can reach it, and re-checks every address so a name can't be re-pointed at your network.
Imports are checked
An archive from someone else can't turn on privileged flags, publish ports to your LAN, lift the egress restriction or smuggle symlinks — those need your say-so.
Stated limitsBuilding a project's flake runs its code with write access to the shared store, every project can read the shared Claude login, an allowlist can't police the hosts you allowed, and a capture holds whatever crossed the wire — tokens included. These are documented in the README rather than glossed over.
Install
Three ways, one script. Pick whichever suits the machine.
brew install rande/nixenv/nixenv brew install --cask docker # or: brew install podman brew install mkcert # optional: trusted HTTPS nixenv build
curl -fsSLO https://github.com/rande/nixenv/releases/latest/download/nixenv.sh chmod +x nixenv.sh ./nixenv.sh --version ./nixenv.sh build
git clone https://github.com/rande/nixenv && cd nixenv
./nixenv.sh install # → /usr/local/bin/nixenv
nixenv build- Docker or Podman — either works, auto-detected.
- Bash — the one macOS ships is fine.
- No Nix on the host — all Nix work happens in containers.
- mkcert (optional) — for browser-trusted HTTPS.
Full documentation — commands, networking, export/import — lives in the README.