projectnixenv
rev0.5.0
licenceGPL-3.0+
Per-project dev containers

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

Plan: three example projects, each in its own container. A projects gateway brings HTTPS in to every project, an exit proxy is every project's only way out to the internet, an optional recorder (mitmproxy) next to it captures the traffic of projects you choose, and all projects mount one shared Nix store read-only. YOUR MACHINE · DOCKER OR PODMAN your browser internet allowed hosts Projects gateway <project>-<port>.nixenv.localhost Exit proxy allowlist first Recorder mitmproxy YOUR PROJECTS · ONE CONTAINER EACH · 3 EXAMPLES PROJECT myapp dev server :3000 ● your uid · no root PROJECT api + node · postgres ● own tools & data PROJECT blog + php · mariadb ● own tools & data read-only /nix SHARED STORE · READ-ONLY your terminal · ssh myapp in (https) out (allowlisted) recorded (capture)
Fig. 1 — three example projects on one machinehover any part
Hover (or tap) any part of the plan to read what it does.
§ 01

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.

1

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.

2

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.

3

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.

4

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.

§ 02

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.

~/work — zsh

      
  1. nixenv buildDownloads the shared toolchain into the store volume. Slow once, then never again.
  2. 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.
  3. nixenv start myappStarts the container as your user, with sshd under runit and the shared HTTPS proxy.
  4. nixenv allow myapp registry.npmjs.orgOpens one more host. Everything else stays denied — watch it with nixenv egress myapp.
  5. ssh myappA persistent terminal session. Close the laptop, reconnect, and it's still there.
§ 03

Room schedule

What every project gets, without configuring anything.

RefItemSpecification
R-01One pinned toolchainBuilt once from a flake, mounted read-only into every project. Update it in one place; every container sees it on the next start.
R-02Per-project flakesNode, PHP, Python, Go, databases — whatever the repo declares, in a profile of its own, ahead of the base on PATH.
R-03Docker or PodmanAuto-detected. Everything runs as your uid; nothing in a project container is root.
R-04SSH + persistent sessionsssh myapp and ssh myapp.tests attach named zmx sessions. VS Code Remote-SSH works from the same config.
R-05HTTPS for every porthttps://<project>-<port>.nixenv.localhost, routed by the projects gateway, trusted via mkcert — reachable from inside containers too.
R-06Egress allowlist by defaultProjects reach only the hosts you allow. nixenv egress shows what was denied, so the list writes itself.
R-07Services under runitDeclare sv/<name>/run in the repo or flake; databases and workers start with the container and restart if they die.
R-08Export / importMove a project between machines as one archive. Secrets stay out unless you ask, and imported settings are checked, not trusted.
R-09Claude CLI built inOne login for all projects; settings, hooks and MCP servers stay per project.
R-10Ready-made stacksinit --template=wordpress — or cloudflare, symfony, directus-astro, windmill. One file becomes the project's flake; the app installs itself on first start.
R-11Traffic capturenixenv capture records a project's HTTP(S), in and out, with mitmproxy — in a web UI or the terminal — without loosening the allowlist.
R-12A single fileThe whole tool is one bash script (Bash 3.2 compatible), with its flake, entrypoint and dotfiles embedded.
§ 04

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.nix or an https URL — copy a shipped one and edit it.
§ 05

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.

Outbound: the project's request passes the exit proxy's allowlist first, then the recorder, then reaches the internet; a refused request gets a 403 and is never recorded. Inbound: the gateway sends a request through the recorder before it reaches the project. OUT project exit proxy allowlist first recorder mitmproxy web refused → 403 · never recorded IN browser gateway recorder same one project
Fig. 2 — where the recorder sits
~/work — zsh
❯ 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 on offers. An app that pins its certificates shows up as TLS-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 delete removes 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
§ 06

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.

§ 07

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.