A self-contained shell tool for spinning up disposable, per-project development containers that share one common set of tools through a single Nix store.
Instead of baking every tool into a Docker image, nixenv.sh downloads all
dependencies once into a standalone Docker volume (the Nix store) and lets each
project container mount that store read-only. Containers start instantly and
every project shares the same pinned toolchain.
Contributing? DEVELOPING.md covers the local setup, the tests, and developing nixenv from inside a nixenv project.
-
Self-contained script. Everything —
flake.nix, a referenceDockerfile, the runtime entrypoint, and the home skeleton — is embedded innixenv.sh. On every run it writes these into$CONTEXT_DIR(default~/.nixenv/context) and builds from there. You can copy justnixenv.shanywhere and it recreates its own context. -
Standalone volume. A named Docker volume (
nixenv__nixos_store) holds/nix. -
Builder container. A short-lived
nixos/nixcontainer realises every dependency from the flake into the volume and installs them into a shared profile (/nix/var/nix/profiles/shared) that also lives in the volume. -
Runtime container. A lightweight
debian:stable-slimcontainer mounts the store read-only at/nixand runs entirely as your (non-root) host user —--user $(id -u):$(id -g), hostname set to the project name. The login userappis supplied via a bind-mounted/etc/passwd, code and home come from per-project named volumes (chown'd to your uid), and an unprivilegedsshd(port 2222) runs under therunitsupervisor so you can SSH in. Nothing in the container runs as root. Nix binaries reference their own loader/libs by absolute/nixpath, so the slim base's libc is irrelevant.Because the container runs as your user,
docker exec/ VS Code "Attach to Running Container" also land asapp, not root.
- Docker or Podman
- Bash
No host Nix install is needed — all Nix work happens inside containers.
nixenv.sh auto-detects docker or podman. If only one is installed it uses
it; if both are present it asks which to use the first time and remembers
the answer in ~/.nixenv/engine. Override anytime with
CONTAINER_ENGINE=docker|podman, or delete that file to be asked again. For
Podman, image names are automatically qualified with docker.io/.
Nix looks up nixpkgs through GitHub's API, which allows only 60 anonymous
requests per hour per IP address. On a shared address (an office network, a
VPN, CI) everyone behind it shares those 60, so nixenv build or update can
fail with API rate limit exceeded. A token raises the limit to 5,000/hour.
The first build or update without one explains this and asks for a token —
press Enter to skip, and it won't ask again. The link it prints opens GitHub's
token page already filled in: a fine-grained token named nixenv with no
permissions, which means read-only access to public repositories, all Nix
needs. Manage it any time:
nixenv github-token # set or replace it
nixenv github-token --status
nixenv github-token --clear
GITHUB_TOKEN=$(gh auth token) nixenv update # or pass one for a single runIt's stored in ~/.nixenv/github_token (mode 600) and only given to nixenv's
own toolchain builds — never to a project's flake. If a build does hit the limit,
or the token has expired, nixenv says so under the error.
Three ways, all of which put nixenv on your PATH. Pick one.
brew install rande/nixenv/nixenv
nixenv --versionThat taps rande/homebrew-nixenv and installs on first use; brew upgrade nixenv afterwards. The formula has no dependencies — nixenv is a single bash
script that works with the system bash — so it installs in a second. You still
need a container engine, which Homebrew won't pull in for you:
brew install --cask docker # or: brew install podman
brew install mkcert # optional: trusted HTTPS for *.nixenv.localhostHomebrew also installs this release's templates locally, and nixenv uses them automatically, so templates always match the nixenv version you have.
Don't run nixenv install on a Homebrew install; it refuses, because a second
copy would never be upgraded by brew.
The script is self-contained, so one file is the whole tool:
curl -fsSLO https://github.com/rande/nixenv/releases/latest/download/nixenv.sh
chmod +x nixenv.sh && ./nixenv.sh --versiongit clone https://github.com/rande/nixenv && cd nixenv
./nixenv.sh install # copies to /usr/local/bin/nixenvOverride the location or name with INSTALL_DIR / INSTALL_NAME, and remove it
with ./nixenv.sh uninstall. Once installed you can use nixenv <command>
instead of ./nixenv.sh <command>. Note that the installed copy is a snapshot:
if you're editing nixenv.sh, keep calling ./nixenv.sh from the clone or it
will be stale.
Every example below uses
nixenv. Working from a clone without installing? Substitute./nixenv.sh— the commands are otherwise identical.
nixenv build # download all deps into the volume (slow once)
nixenv init myapp # scaffold a project, prompts for git identity
nixenv start myapp # start the service (prints the SSH port;
# also auto-starts the shared HTTPS proxy)
nixenv ssh myapp # SSH in as 'app'With the app listening on :3000, it's already reachable at
https://myapp-3000.nixenv.localhost/ (see
Reverse proxy).
Clone a repo while initialising (cloned into the project's app volume), optionally pick the branch, and where it mounts in the container:
nixenv init myapp git@github.com:me/app.git
nixenv init myapp git@github.com:me/app.git --branch=develop # a branch or tag, not the default one
nixenv init web git@github.com:me/web.git --app-path=/var/www/htmlDon't want to set up keys? nixenv shell myapp drops you straight into an
interactive zsh via docker exec (no SSH key needed).
build— (re)write context, then download all flake deps into the volume.build <project> [--dir=<path>]— build the project's own flake into a per-project profile, layered on the base. Default readsflake.nixfrom the repo root;--dir=<path>copies a whole folder (flake + local files it references) and is remembered, so later rebuilds are justnixenv build <project>.--dir=(empty) forgets it. See Per-project tooling.init <project> [git-url] [--build] [--unrestricted] [--allow=host,…] [--app-path=/path]— scaffold the project, prompt for git name/email, assign a stable random SSH port, and optionally clonegit-urlinto the app volume.--buildalso builds the project's flake afterwards.--app-path=/pathmounts the code volume at a custom container path instead of/app(e.g./var/www/myapp, to match production); it's stored in<project>/app_mountand used bystart,shell, and the logincd. For anhttp(s)URL it also prompts for a username + Personal Access Token and stores them (see HTTPS credentials).start <project> [-v](aliasrun) — start the project as a background service (sshdunderrunit) and print its SSH port.ssh <project>— SSH into the running service (auto-starts it). For persistent zmx sessions, usessh <project>via your~/.ssh/config(see Terminal sessions).shell <project>— interactive zsh viadocker exec(no SSH key needed).ssh-config [--install]— wiressh <project>into your~/.ssh/config.expose <project> <port>…— publish extra port(s) (see Exposing ports).host <project> <name:ip>…— add custom/etc/hostsentries (see Custom /etc/hosts).restrict <project> [on|off]/allow <project> <host>…/egress <project> [-f]— egress restriction to validated hosts only, ON by default (see Egress restriction).capture <project> [on|off|untrust|web|log -f|tui|har <file>|clear]— record a restricted project's HTTP(S) traffic with mitmproxy, with a web UI and CLI views (see Capturing traffic).deploy <project> [--agent=<socket>|--no-agent] [-- <command>…]— open a shell in a throwaway container with your ssh agent forwarded, the shared code, your git credentials and an extended egress allowlist;deploy <project> allow|hosts|log|stopmanage it (see Deploying).proxy [up|reload|stop|status|logs [egress]|renew|remove-cert]— shared HTTPS reverse proxy for all projects, plus the egress container restricted projects go out through (see Reverse proxy).up <project>— build if needed, then start the service.stop [<project>]— stop and remove the project's service container; with no project, stops every nixenv container including the shared proxy (volumes and projects are untouched).logs <project>— follow the service container logs.delete <project>(aliasrm) — permanently remove a project: its container(s), the app/home/databases volumes (and the deploy state volume, if any), and its host dir. Prints the exact commands it will run and asks for confirmation first.sync-home <project>— refresh the home volume's dotfiles from the embedded templates + per-project overrides (see Updating dotfiles).projects— list projects with their SSH port and running state.ps [--json] [--watch [N]]— what runs where: the ports each project's processes actually listen on, with their proxy URLs, plus services and egress settings. Also refreshes the dashboard athttps://nixenv.localhost/(see Project dashboard).update— refreshflake.lock, then rebuild into the volume.status— show context, volume, and shared-profile state.gc [--dry-run]— garbage-collect the store (see Reclaiming disk space).clean— delete the standalone volume (removes all shared packages).install/uninstall— copy this script onto yourPATH(asnixenv) / remove it.
Each project is assigned a random host port once, stored in
~/.nixenv/projects/<name>/port and shown by init and projects. The service
container runs an unprivileged sshd (supervised by runit, as your user) and
maps that host port to port 2222 inside the container.
nixenv ssh myapp # convenience wrapper
ssh -p <port> -i ~/.nixenv/projects/myapp/ssh/id_ed25519 app@127.0.0.1 # equivalentKey-only login, with no setup. The first start generates an ed25519 key for
the project on your machine, in ~/.nixenv/projects/<name>/ssh/id_ed25519, and
wires it into both nixenv ssh and the generated ~/.ssh config — so
ssh <project>, zmx and VS Code Remote-SSH connect with no prompt, as before.
The container's sshd accepts only that key: passwords are off, and the key
list is a read-only file mounted from your machine, so nothing inside a
container can authorise a key of its own.
That matters because sshd is reachable from more than your machine. Projects on
the shared network can reach each other, and restricted projects can reach each
other through the proxy's relays — with an open login, any project could get a
shell in any other. The published port is still bound to 127.0.0.1 only,
and root login is disabled.
To use your own key as well (say, one already loaded in your agent), add it to
~/.nixenv/projects/<name>/ssh/authorized_keys.extra, one per line. It's picked
up on the next start, without a restart if the container is already up.
The key never leaves your machine: it isn't part of an export, so an imported
project gets a new one.
The container is verified, too. Its sshd host key is also generated on your
machine and pinned in ~/.nixenv/projects/<name>/ssh/known_hosts (under the
alias nixenv-<name>, not the port). ssh <project> and nixenv ssh check it
strictly, so if something else grabs the project's port while its container is
stopped, ssh refuses to connect instead of handing your session to it.
Hand-written ssh commands need -o HostKeyAlias=nixenv-<name> -o UserKnownHostsFile=~/.nixenv/projects/<name>/ssh/known_hosts, or use the
generated config.
A container started before these changes still runs the old sshd (and, for host-key pinning, the old host key, so
sshreports a changed key).nixenv start <name>warns about it;nixenv stop <name> && nixenv start <name>applies the fix.
Each project gets a generated host ssh config at
~/.nixenv/projects/<name>/ssh/config. Add one Include line to your
~/.ssh/config and you can ssh <project> directly:
nixenv ssh-config --install # adds: Include ~/.nixenv/projects/*/ssh/config
ssh myapp # persistent zmx session 'myapp'
ssh myapp.api # a second session 'myapp.api'The generated config uses zmx (bundled in
the base toolchain) for re-attachable terminal sessions over ssh, with
ControlMaster multiplexing — the same pattern zmx documents. The session name
comes from the ssh host, so ssh myapp / ssh myapp.api give you distinct,
persistent sessions you can detach from and re-attach later. Edit the per-project
file freely (it's only created when missing); swap the RemoteCommand for a
plain shell if you prefer.
nixenv ssh <project> and nixenv shell <project> connect directly (plain zsh,
no zmx) — handy as an escape hatch. The prompt shows the project name (the
container's hostname is set to it), plus the zmx session when you're in one.
nixenv init myblog --template=wordpress # WordPress + PHP + nginx + MariaDB
nixenv start myblog # → https://myblog-8080.nixenv.localhost/
nixenv init myworker --template=cloudflare # Cloudflare Workers + wrangler
nixenv start myworker # → https://myworker-8787.nixenv.localhost/
nixenv init flows --template=windmill # Windmill self-hosted + PostgreSQL
nixenv start flows # → https://flows-8000.nixenv.localhost/Shipped templates: wordpress, cloudflare, symfony,
headlesscms-directus-astro, windmill.
One file = one template, and that file becomes the project's flake.nix —
so the result is an ordinary nixenv project you own and can edit, not a black
box. The template declares only the toolchain (php/nginx/mariadb/wp-cli, or
node/wrangler) plus config files and a startup hook. The application itself is
installed once on first start by that hook — wp core download +
wp core install + plugins for WordPress, a scaffolded Worker for Cloudflare —
so your site/worker is real editable files in the app volume that you can
git-commit. (A Nix build is sandboxed to its own $out and can never write the
app volume; the hook is the supported way to do runtime setup, and it's
marker-guarded so restarts are instant.)
Templates resolve three ways:
--template=wordpress # official, from the nixenv repo
--template=https://example.com/mystack.nix # any URL
--template=./templates/wordpress.nix # local file (your own fork)Short names are pinned to the templates that shipped with your nixenv: the
templates/ folder next to the script (a clone, or Homebrew's copy), otherwise
the GitHub tag matching nixenv --version — never the moving main branch.
Override with TEMPLATE_BASE (e.g. …/rande/nixenv/main/templates to follow
main). Plain http:// templates are refused, since a template is code
(NIXENV_ALLOW_INSECURE_TEMPLATES=1 overrides). Fetched templates are cached in
~/.nixenv/templates/, and their sha256 is printed. A template can declare metadata that nixenv
reads before building — used to pre-fill the egress allowlist, the served port
and the app path:
# nixenv:description WordPress + PHP 8.3 + nginx + MariaDB
# nixenv:port 8080
# nixenv:allow wordpress.org api.wordpress.org downloads.wordpress.orgThat allow line matters because projects are egress-restricted by
default — it's what lets
WordPress fetch core and plugins on first run. Since a template is code that
gets built and whose hook runs in your container, init prints what it will do
and asks for confirmation (--yes to skip).
Writing your own: copy either file in templates/, edit the
toolchain and the hook, and point --template= at it. The placeholders
@@PROJECT@@, @@APP_MOUNT@@, @@DOMAIN@@ and @@PORT@@ are substituted when
it's installed.
Four distinct paths, each with its own knob. The diagram shows a restricted project (the default); an unrestricted one differs only in that it sits on the shared network and publishes its own ports.
flowchart LR
subgraph HOST["🖥️ your Mac"]
BROWSER["browser<br/>*.localhost → 127.0.0.1"]
CLIENT["psql / TablePlus / ssh"]
end
subgraph PROXYC["📦 nixenv__proxy"]
CADDY["Caddy :80/:443<br/><i>ingress — routes on Host</i>"]
RELAYS["socat relays<br/><i>ssh + declared ports</i>"]
end
subgraph EGRESSC["📦 nixenv__egress"]
SQUID["squid :3128<br/><i>egress allowlist</i>"]
MITM["mitmproxy<br/><i>only with 'capture'</i>"]
end
subgraph PROJ["📦 nixenv-myapp (internal network)"]
LOOP["socat 127.0.0.1:443/:80<br/><i>loopback relay</i>"]
APP["your app :8000"]
SSHD["sshd :2222"]
end
NET(["🌍 internet"])
BROWSER -- "① https://myapp-8000.nixenv.localhost" --> CADDY
CADDY -- "Host → container:port" --> APP
APP -. "② public URL from inside<br/>curl forces *.localhost → 127.0.0.1" .-> LOOP
LOOP -- "raw TCP, TLS stays end-to-end" --> CADDY
APP -- "③ HTTPS_PROXY env → CONNECT" --> SQUID
SQUID -- "allowed_hosts only<br/>else 403" --> NET
SQUID -. "captured projects" .-> MITM
MITM -.-> NET
CLIENT -- "④ 127.0.0.1:port" --> RELAYS
RELAYS --> SSHD
style NET fill:#eee,stroke:#999
| # | Path | Configure with |
|---|---|---|
| ① | Ingress — browser → app, HTTPS, no setup | automatic; proxy up|status, PROXY_DOMAIN, PROXY_HTTP_PORT/PROXY_HTTPS_PORT, mkcert for trusted certs |
| ② | Public URL from inside the container | automatic (loopback relay + CA injection); glibc clients need nixenv host <p> <name>:127.0.0.1 |
| ③ | Egress to the internet — default-deny | restrict <p> on|off, allow <p> <host>, egress <p> to see allowed vs denied, capture <p> on to record it |
| ④ | Raw TCP from your Mac (databases, ssh) | expose <p> <port>; ssh port is automatic |
Two paths need no proxy at all: service-to-service calls between projects
use http://nixenv-<project>:<port>/ over the shared network, and anything
inside one container talks to itself on localhost:<port>.
Tip — for HTTP(S) services, prefer the Reverse proxy. Any port your app listens on is already reachable at
https://<project>-<port>.nixenv.localhost/with zero configuration — noexpose, no restart, no host-port conflicts between projects, and you get HTTPS.exposeis mainly for non-HTTP traffic (a database client on your Mac, a raw TCP service) or when a tool needs a plain127.0.0.1:<port>.
Each project publishes its SSH port automatically. To expose more directly (a database, raw TCP, etc.):
nixenv expose myapp 8080 # → 127.0.0.1:8080:8080
nixenv expose myapp 3000:3000 # host:container
nixenv expose myapp 0.0.0.0:80:80 # bind all interfaces (network-reachable)Ports are stored one-per-line in ~/.nixenv/projects/<name>/ports, so they
persist and you can also edit that file by hand. expose restarts the service
to apply them; otherwise they take effect on the next start. A bare number binds
to 127.0.0.1 (local only); pass a full host:container or
address:host:container spec for anything else.
A single shared Caddy container (run from the Nix store — no extra image) routes pretty HTTPS URLs to any project by parsing the hostname:
https://<project>-<port>.nixenv.localhost/ → container nixenv-<project>, port <port>
e.g. https://myapp-3000.nixenv.localhost/ → your dev server on :3000
Every project container automatically joins a shared network (nixenv_net) on
start, and the proxy auto-starts with the first project (disable with
PROXY_AUTOSTART=0), so usually there's nothing to do. Manage it explicitly
with nixenv proxy up | reload | stop | status | logs. New projects need no proxy
configuration — the routing is dynamic. Your app must listen on 0.0.0.0 (not
127.0.0.1) inside its container so the proxy can reach it.
*.localhost resolves to 127.0.0.1 automatically in Chrome and Firefox;
Safari needs an /etc/hosts line. The proxy sends the standard forwarded
headers (X-Forwarded-Proto: https, X-Forwarded-For/-Host/-Port,
X-Real-IP), so frameworks behind a trusted proxy generate correct https://
URLs. Host ports default to 80/443 (PROXY_HTTP_PORT/PROXY_HTTPS_PORT; use
8080/8443 for rootless Podman, which can't bind below 1024).
Public URLs work from inside containers too — curl https://myapp-8000.nixenv.localhost/
just works, no configuration:
nixenv shell myapp
curl https://myapp-8000.nixenv.localhost/ # → routed to the app, cert trustedThree things make that work, all automatic:
A loopback relay. curl (and therefore libcurl, PHP's ext-curl, Guzzle,
Symfony HttpClient) implements RFC 6761 internally: it resolves localhost and
any *.localhost name to 127.0.0.1, ignoring /etc/hosts and DNS. So
rather than fight it, each project container runs a small socat relay
(supervised by runit) forwarding 127.0.0.1:443 and :80 to the proxy — making
loopback genuinely correct. It's a raw TCP relay, so TLS stays end-to-end with
Caddy: SNI and the Host header arrive intact, the wildcard cert matches, and
routing works. Non-curl clients (PHP streams, Python, Go, Java) resolve via
/etc/hosts, so for those add one line pointing at loopback:
nixenv host myapp myapp-8000.nixenv.localhost:127.0.0.1Standard ports. Caddy binds 80/443 inside the proxy container (it runs
with net.ipv4.ip_unprivileged_port_start=0), so URLs need no :8443 suffix.
Trusted TLS. The proxy's root CA (mkcert's, or Caddy's internal one) is
mounted into every container and merged into a CA bundle exported as
SSL_CERT_FILE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE
and GIT_SSL_CAINFO — so HTTPS is trusted, not merely reachable.
For plain service-to-service calls you don't need any of this:
http://nixenv-myapp:8000/ already resolves over the shared network and skips
the hairpin. Use the public URL when the app genuinely needs it — absolute link
generation, OAuth redirects, tests hitting the real hostname.
A restricted project (the default) can reach only its own public URLs
through the proxy: from myapp, https://other-8000.nixenv.localhost/ returns
403. That stops one compromised project from driving another's admin UI. Your
browser on the host is never affected. To let projects talk, list the callers in
the target's accept-from file and reload the proxy (no restart):
echo myapp >> ~/.nixenv/projects/other/accept-from # '*' = every project
nixenv proxy reloadUnrestricted projects share one flat network and can reach each other directly
(http://nixenv-other:8000/), so this guard doesn't apply to them.
nixenv only knows the ports you declare. nixenv ps asks each running
container which ports its processes actually listen on: it reads
/proc/net/tcp through docker exec/podman exec, from the host. It prints
them with their proxy URLs:
nixenv ps
# shop running restricted ssh 2201
# 8080 nginx https://shop-8080.nixenv.localhost/
# 5432 postgres (127.0.0.1 only — not reachable through the proxy)
# services: nginx:run php-fpm:run worker:down
# denied: api.stripe.com×4 (allow: nixenv allow shop <host>)
# ! service 'worker' is down — check: nixenv logs shopThe same scan is published as a page at https://nixenv.localhost/, served
by the shared proxy. Each project gets one full-width card. On the left are
its open ports as links, its services, any warnings and, while capture is on,
a link to its mitmweb UI. The UI still asks for its token: get the full URL
from nixenv capture <project> web. On the right is the
Survey, always shown: allowlist, ssh_hosts, accept-from, capture,
declared ports, the ssh port and the extra engine parameters. Values passed
with -e/--env are shown as NAME=…, never their content. Hosts squid
refused are listed by nixenv ps and nixenv egress <project>. A Help
section below the projects lists every command with its options and an
example. The page uses the same paper/blueprint design as the project site.
- When it updates:
ps,start,stop,proxy upandproxy reloadrewrite it. Services take a while to start listening afterstart, sostartalso re-checks after 10 s, 30 s and 90 s (change the delays withNIXENV_DASHBOARD_DELAYS="5 20 120", or turn this off withNIXENV_DASHBOARD_DELAYS=). A project that started recently and has no open port yet shows as starting. The page re-reads the data every 5 s, sonixenv ps --watch(every 5 s, or--watch 30) keeps it live.nixenv ps --jsonprints the same data for scripts. - Who can see it: your browser, and unrestricted projects (they share a
network anyway). Restricted projects get a
403. The page holds no secrets: no tokens, no credentials, environment values fromextra-parametersredacted, and deploy hosts only as a count. It is read-only, with no buttons that change anything. - An older proxy doesn't have the page's files mounted yet. Run
nixenv proxy uponce;psandproxy reloadremind you until you do. - Ports bound to
127.0.0.1are shown but not linked: the proxy is another container and can't reach them. Bind dev servers to0.0.0.0.
Out of the box the proxy uses Caddy's internal CA, so browsers show a warning.
If mkcert is installed, an explicit
proxy up issues a trusted wildcard cert for *.nixenv.localhost instead:
brew install mkcert nss # nss = Firefox trust
nixenv proxy up # issues the wildcard cert (one-time 'mkcert -install')The one-time mkcert -install adds mkcert's local CA to your OS/browser trust
stores and may ask for your password — the script explains exactly what it
does before running it, and only runs it when the CA isn't already installed.
Prefer manual control? Run mkcert -install yourself first, or skip trusting
entirely with PROXY_MKCERT_INSTALL=0 (HTTPS still works, with a warning). The
auto-start on start never runs mkcert -install, so it can never surprise you
with a prompt. proxy renew reissues the cert; proxy remove-cert deletes
nixenv's cert (falling back to the internal CA) without touching mkcert's CA.
The container's /etc/hosts is rebuilt by the entrypoint on every start from
base entries plus two optional sources, in order:
- Declared in the project flake (versioned, team-shared): ship an
etc/hosts.extrain the project profile viapkgs.writeTextDir "etc/hosts.extra" ''…''added tobuildEnv.paths— seetemplates/flake.nix. Apply withnixenv build <project>+ restart. - Host-side, local-only:
~/.nixenv/projects/<name>/hosts.extra, native/etc/hostsformat (ip<TAB>name). Edit it by hand, or append entries with:
nixenv host myapp db:10.0.0.5 api.local:127.0.0.1Entries apply on the next container start (host restarts a running project
for you). This is file-driven rather than --add-host so it's declarative,
idempotent, and works with the non-root container.
Every project's outbound network is locked down by default to a
validated set of hosts — deny-everything-else. The forge domain from the clone
URL is validated automatically at init, so git keeps working out of the box:
nixenv init myapp https://gitlab.example.com/team/app.git
# → gitlab.example.com auto-allowed; everything else denied
nixenv restrict myapp off # opt OUT (full internet access)
nixenv restrict myapp on # re-enable (the default)
nixenv init open-project --unrestricted # opt out at creationHow it works: the restricted project runs on its own internal network — the
kernel gives it no route to the internet at all — and its only way out is a
squid allowlist proxy (default-deny) running in the shared nixenv__egress
container. Enforcement is the missing route; squid is just policy, so nothing
in the container can bypass the list. (squid used to run inside the proxy
next to Caddy; it has its own container now, so restarting the reverse proxy
no longer cuts every project off the network. A project container created
before that still points at the old address — start tells you, and
nixenv stop <p> && nixenv start <p> fixes it.) HTTP(S)_PROXY is exported automatically
(npm, pip, composer, cargo, curl, git-https, the Claude CLI all honour it), and
ssh is routed through the proxy's CONNECT tunnel via a ProxyCommand added to
the container's ~/.ssh/config — so git@… remotes to validated forges
keep working. SSH into the project and its declared ports keep working too
(they're relayed through the proxy container).
The allowlist lives at ~/.nixenv/projects/<name>/allowed_hosts, one entry per
line. Matching is exact by default; prefix with a dot (or *.) to include
subdomains, and bare IPs are also accepted:
gitlab.example.com # exactly this host
.yarnpkg.com # yarnpkg.com AND every subdomain (classic.yarnpkg.com, …)
203.0.113.7 # requests addressed to this IP literally
An IP entry matches requests addressed to that IP, not hostnames that happen to resolve to it — honouring the latter would mean looking up every requested name, which is exactly the leak described below. Private, loopback and link-local addresses are always refused, whatever the allowlist says.
init seeds the forge domain from the clone URL automatically, and
--allow= pre-validates anything else the project needs from the start
(comma-separated, repeatable):
nixenv init myapp https://gitlab.example.com/t/a.git \
--allow=registry.npmjs.org,.yarnpkg.com --allow=pypi.orgProjects created before this feature have an empty list, so allow their forge
before pulling. Manage it from the host:
nixenv allow myapp registry.npmjs.org api.stripe.com # add + reload
nixenv egress myapp # allowed vs DENIED domains
nixenv egress myapp -f # follow liveegress reads squid's access log, so the DENIED section is your worklist:
run the project, watch what gets blocked, allow what's legitimate. Limits to
know: UDP (QUIC) isn't proxied (tools fall back to TCP); proxy-less raw-TCP
clients can't reach external services (use ssh/CONNECT-capable paths); and the
CONNECT ports are limited to 443/22/80.
Port 22 only to your git host. init writes the forge's hostname to
~/.nixenv/projects/<name>/ssh_hosts, and only the hosts listed there can be
reached on port 22 (git over ssh). Add a line per extra git host, then
nixenv proxy reload. Projects created before this have no such file and keep
the old behaviour, where every allowed host is reachable on 22.
It is a hostname allowlist, so it stops code from reaching hosts you didn't allow. It can't judge what happens with the hosts you did allow:
- An allowed forge is a way out. With
github.comallowed (the default for a GitHub clone), code can push to any repository or gist, not just yours. - Wildcards are wide.
.githubusercontent.comor.vsassets.iocover huge namespaces, parts of which other people control. - Shared CDNs can front other sites. An allowed CDN hostname can be used to reach other customers of the same CDN (domain fronting).
- Port 22 reaches any sshd on an allowed name, unless
ssh_hostsnarrows it (see above). - UDP and QUIC aren't proxied — they just fail, so this isn't a leak, but tools must fall back to TCP.
nixenv egress <project> flags wildcard and forge entries in its summary, so
you can review them.
Refused names are never looked up. A DNS lookup is itself a way out: code
that asks for <secret>.attacker.example delivers the secret to whoever runs
that domain's nameserver, even though the request is then refused. So the proxy
decides on the name first, and only resolves names that are already allowed —
it still does that, to refuse an allowed name that points at a private address.
See exactly what a project sends and receives — every HTTP(S) request, headers and bodies — with mitmproxy, in a web UI or from the terminal:
nixenv capture myapp on # egress + ingress (or: on egress | on ingress)
nixenv capture myapp web # prints the UI URL: https://myapp-mitm.nixenv.localhost/?token=…
nixenv capture myapp log -f # one line per request, live
nixenv capture myapp tui # the recorded flows in mitmproxy's console UI
nixenv capture myapp har out.har # export for browser devtools & co
nixenv capture myapp off # stop recording (files kept)
nixenv capture myapp clear # delete the recordings- Egress — the project's outbound requests. mitmproxy sits behind squid,
so the allowlist still decides first: a refused host gets its 403 and never
reaches mitmproxy (or its DNS). HTTPS is decrypted, which only works because
the container trusts mitmproxy's CA — after the FIRST
capture onthe project must restart once (capture onoffers to do it). It then keeps trusting the CA acrosscapture off, so later captures need no restart (and don't kill a running shell or Claude session);nixenv capture myapp untrustplus a restart revokes it. Apps that pin certificates will refuse the connection; that shows asTLS-REFUSEDin the log. ssh andgit://are never captured. - Ingress — requests to the project's public URLs
(
https://<project>-<port>.nixenv.localhost/), routed by Caddy through mitmproxy on the way in. The app still sees the publicHost.
Only restricted projects (the default) can be captured: an unrestricted one talks to the internet directly, with no proxy in the path.
Captures are secrets. They hold whatever crossed the wire: tokens,
cookies, the Authorization header of an HTTPS git fetch. They are stored
owner-only in ~/.nixenv/proxy/egress-data/captures/<project>.{flows,log},
delete removes them, and the UI is served by the proxy as
https://<project>-mitm.nixenv.localhost/ (no extra host port), behind a
password (the token in the URL — don't paste it around). The token is
required, but only once per browser: the first visit sets a login cookie
(400 days), after which plain https://<project>-mitm.nixenv.localhost/ opens
it. One mitmweb shows every captured project; the printed URL opens it
pre-filtered to <project> (#/flows?s=~comment <project>). With
PROXY_HTTPS_PORT other than 443, the URL includes that port. No
restricted project can open the UI, not even the one being captured, and no project can reach
mitmproxy directly: its listeners are bound to the egress container's
loopback (only squid uses them) or to the network it shares with Caddy alone.
Capture fails closed: if mitmproxy is down, the captured project's requests
fail rather than go out unrecorded (nixenv proxy logs egress shows why).
mitmproxy keeps flows in memory for the UI; capture <p> clear (or off)
restarts it.
Copy the lines for the package managers your project actually uses (replace
myapp). All of these honour the proxy env automatically:
# git over HTTPS to GitHub (your own forge is seeded by init);
# release-assets serves GitHub Releases downloads
nixenv allow myapp github.com release-assets.githubusercontent.com
# npm / npx / pnpm
nixenv allow myapp registry.npmjs.org
# yarn
nixenv allow myapp registry.yarnpkg.com
# Composer (PHP) — packagist metadata + GitHub-hosted dists
nixenv allow myapp repo.packagist.org api.github.com codeload.github.com github.com
# pip / uv (Python)
nixenv allow myapp pypi.org files.pythonhosted.org
# cargo (Rust) — sparse index + crate downloads; rustup toolchains
nixenv allow myapp index.crates.io static.crates.io crates.io static.rust-lang.org
# go modules
nixenv allow myapp proxy.golang.org sum.golang.org
# Claude CLI (platform.claude.com serves OAuth login/token refresh)
nixenv allow myapp api.anthropic.com statsig.anthropic.com platform.claude.com
# Neovim / AstroNvim first launch (lazy.nvim clones plugins from GitHub)
nixenv allow myapp github.com
# VS Code Remote-SSH — server download + extension marketplace
nixenv allow myapp update.code.visualstudio.com vscode.download.prss.microsoft.com marketplace.visualstudio.com .vsassets.io(VS Code alternative needing no allowlist: set
"remote.SSH.localServerDownload": "always" so your local VS Code uploads the
server over ssh.) For anything not listed, run the tool once and read the
DENIED section of nixenv egress myapp — it names the exact domains.
Your ssh agent should never be forwarded into the dev
container: everything running there — dependencies, scripts, an AI agent — runs
as the same user and could use it. nixenv deploy <project> gives you a
separate, throwaway container for that, which can still edit the code, commit
and push (e.g. a release.sh that bumps a version):
nixenv deploy myapp allow 5.196.77.220 51.255.65.147 # deploy-only hosts
nixenv deploy myapp --agent=~/.ssh/deploy-agent.sock # shell; exit = gone
nixenv deploy myapp -- ./release.sh 1.2.0 # or one commanddev container (start) |
deploy container (deploy) |
|
|---|---|---|
| code (app volume) | read-write | read-write (the same volume) |
| home | the home volume | tmpfs, rebuilt from the skeleton |
| persistent state | home + databases volumes | /deploy: the deploy state volume |
| git identity | home volume | the host seed's (written by init) |
| git https credentials | home volume | the dev container's (its ~/.git-credentials), seed as fallback |
| tools | base + project profile | the same (base includes age, sops) |
| egress | allowed_hosts |
allowed_hosts + deploy_hosts |
| your ssh agent | never | forwarded for the session |
| lifetime | until stop |
removed when you exit |
State. Everything in the deploy home is gone when you exit. What must
survive between sessions — terraform or ansible state, release bookkeeping —
goes in /deploy ($NIXENV_DEPLOY_STATE), the volume nixenv_<project>_deploy.
The first deploy creates it; later sessions reuse it. It is never mounted in
the dev container, so code running there can neither read it nor plant files in
it. delete removes it, and export includes it when it exists.
Allowlist. The deploy container can reach everything the dev container can,
plus ~/.nixenv/projects/<project>/deploy_hosts (deploy … allow). Put
production there and not in allowed_hosts, and the dev container can't
reach it at all. Deploy egress is switched on by that file existing (deploy allow creates it; touch it to give deploy just the dev allowlist). Without
it the deploy container has no network.
Host-side files (in ~/.nixenv/projects/<project>/, none writable from the
dev container):
home/.gitconfig.identity— your git identity, written byinit.home/.git-credentials— the tokeninitstored. Only a fallback: deploy pushes with the dev container's~/.git-credentials(read from its home volume as plain data), so a token you changed there is the one used. Nothing else from the dev home volume is read.deploy_gitconfig— included by the deploy.gitconfig, e.g. to push an https remote over ssh with the agent:[url "git@github.com:"] pushInsteadOf = https://github.com/.deploy_ssh_config— included by the deploy~/.ssh/config(server aliases, users).deploy_known_hosts— persists across sessions; first contact is trust-on-first-use, a changed key is refused.
How it connects: the container's sshd listens on loopback only, and the
host reaches it with ProxyCommand <engine> exec -i … socat, so there is no
published port or relay, and the agent rides the ssh session. That works the
same on Docker Desktop, Linux and podman. The session uses the project's key and
pinned host key.
The session connects to the host name nixenv-deploy-<project>, so your
~/.ssh/config can choose the agent for every deploy:
Host nixenv-deploy-*
IdentityAgent ~/.ssh/deploy-agent.sock
The agent ssh would use (IdentityAgent, else $SSH_AUTH_SOCK) is forwarded,
unless your config sets ForwardAgent itself. --agent=<socket> forwards a
specific one and --no-agent none (NIXENV_DEPLOY_AGENT changes the default).
Your config can't change how the session connects: the transport, key,
host-key check and the no-ControlMaster rule are fixed on the command line,
which ssh gives precedence.
What it does not protect against: the code is shared with the dev
container, which can change it at any moment. A script you run in the deploy
container runs with your agent. The deploy shell neutralises the git settings
that would run a program on ordinary commands (core.fsmonitor, hooks,
core.sshCommand), but a modified release.sh or Makefile is a different
matter. Review what you run. An agent that asks before each use
lets you notice an unexpected signature.
The deploy_* files travel in an export; on import, deploy_hosts is
re-validated and the config files are applied only after you confirm them.
The home volume is seeded from the skeleton once, so template updates (a new git default, an AstroNvim pin, …) don't propagate to existing projects on their own. Refresh them with:
nixenv sync-home myappThis layers the embedded skeleton first, then per-project overrides committed in
the repo at <repo>/.nixenv/home/ (mirroring $HOME paths — e.g.
.nixenv/home/.config/nvim/lua/plugins/extra.lua), which win over the skeleton.
Every file it overwrites is backed up inside the volume at
~/.nixenv/home-backups/<timestamp>, and it never touches installed nvim
plugins, shell history, or your git identity/credentials.
When you init with an http(s) clone URL (e.g. a GitLab repo), nixenv prompts
for a username and Personal Access Token (input hidden) and stores them with
git's credential-store helper inside the project home:
~/.nixenv/projects/<name>/home/.git-credentials https://user:token@host (mode 600)
~/.nixenv/projects/<name>/home/.gitconfig.credentials enables credential.helper = store
.gitconfig includes that file, so the token is reused for the clone and for
later pull/push inside the container. The token is stored in plaintext (as
git's store helper always does); the file is chmod 600 and lives outside the
repo. SSH URLs skip this and use your keys instead. For non-interactive use,
export GIT_HTTP_USER / GIT_HTTP_TOKEN.
Each project's code and home live in named Docker volumes:
volume nixenv_<name>_app → /app (your code; the WORKDIR — customisable
via init --app-path=/path)
volume nixenv_<name>_home → /home/<user> (.ssh, .zshrc, .gitconfig, configs)
volume nixenv_<name>_databases → /databases (persistent DB data: pgsql, redis, …)
volume nixenv_<name>_deploy → /deploy (deploy container only; created by
the first `deploy`)
/databases is an empty, writable, per-project volume for database data files.
Point your services at it — e.g. Postgres PGDATA=/databases/pgsql, Redis
dir /databases/redis — so the data survives container recreation (run the DBs
themselves as runit startup services — <repo>/.nixenv/sv/<name>/run, documented
in templates/flake.nix — or by hand).
The volumes are created and chown'd to your uid (via a one-time throwaway
root helper container) so the non-root runtime container can write them — that's
the trick that lets us use fast named volumes while staying non-root. On macOS
Docker Desktop this is much faster than host bind-mounts for heavy file I/O
(node_modules, installs, git).
Host-side, ~/.nixenv/projects/<name>/ keeps only small state: home/ (the
seed the home volume is populated from on first run — skeleton + git config),
the generated passwd/group/shadow (the container's user db), port,
ports, app_mount (custom code-volume path, if set), hosts.extra
(local /etc/hosts entries, if any), extra-parameters (see below),
capture (present while capture is on),
deploy_hosts/deploy_ssh_config/deploy_gitconfig/deploy_known_hosts (for
deploy), and
ssh/config. Recorded traffic lives outside it, in
~/.nixenv/proxy/egress-data/captures/<name>.{flows,log}. Because the code and home are in volumes, they're not directly
editable from the host — you work through the container (nixenv ssh /
Remote-SSH / VS Code). Populate the code volume by passing a git URL to init,
or by cloning/working inside the container at the app mount (/app by default,
or your --app-path).
Git identity is stored per project in home/.gitconfig.identity, which the
project's .gitconfig includes — so re-running init never duplicates the
[user] block.
init and start create an empty ~/.nixenv/projects/<project>/extra-parameters
for you. Anything you put there is appended verbatim to the container's
start — one flag per line, # comments allowed, no presets and no magic:
--memory=4g
--ulimit nofile=8192
There is no CLI flag for this on purpose; it's project state like unrestricted
or ports. Parameters apply when the container is created, so re-run
nixenv start <project> after editing. start -v echoes the active set.
Every project container (and the proxy) starts hardened: --cap-drop=ALL,
--security-opt=no-new-privileges and --pids-limit=4096 (change it with
NIXENV_PIDS_LIMIT; 0 removes it, which rootless podman without cgroup
delegation needs). Your extra parameters come after these, so a
--cap-add=… or --security-opt=no-new-privileges=false there wins — each one
loosens the sandbox, so add only what you need. There's no default memory limit;
add --memory=4g here if you want one.
That's what the commented example in the scaffolded file is for — uncomment it:
--security-opt seccomp=unconfined # user-namespace syscalls (clone/unshare)
--security-opt apparmor=unconfined # Debian/Ubuntu hosts
--security-opt label=disable # SELinux hosts
--device /dev/fuse # fuse-overlayfs storage driver
--device /dev/net/tun # slirp4netns / pasta networking
Drop any --device your engine host doesn't have — a missing device makes start
fail outright. Two more caveats: podman isn't in the base toolchain (add it to
the project flake), and the container runs as your uid with no added
capabilities and no /etc/subuid//etc/subgid, so rootless podman inside is
limited to a single UID — images that chown to other UIDs will fail unless the
project provides those mappings itself.
Beyond the shared base, a project can add its own dependencies via a flake.nix
committed in its repo. Build it with:
nixenv build myapp # or: nixenv init myapp <git-url> --buildA ready-to-copy, heavily-commented starter lives at
templates/flake.nix — drop it into a project repo as
flake.nix and edit the paths list.
The repo flake must expose packages.<system>.default (override the attribute
with PROJECT_ATTR), typically a buildEnv of the extra tools:
# flake.nix in your project repo
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; # match the base to share the store
outputs = { self, nixpkgs }:
let pkgs = import nixpkgs { system = "x86_64-linux"; config.allowUnfree = true; };
in { packages.x86_64-linux.default = pkgs.buildEnv {
name = "myapp-deps";
paths = with pkgs; [ nodejs_24 postgresql_16 awscli2 terraform ];
}; };
}build <project> extracts flake.nix (+ flake.lock) from the app volume and
installs it into a per-project profile (/nix/var/nix/profiles/proj-<name>) in
the same store, so packages already present (from the base or another
project) aren't rebuilt. At runtime that profile goes on PATH ahead of the
base, so the project sees base ∪ its extras (and can shadow a base tool with a
pinned version). Rebuild after changing the repo flake; delete removes the
profile too.
By default only flake.nix (+ flake.lock) is copied, so a flake that
references other local files won't resolve. For that case, put the flake and
its local files in a folder and point at it:
nixenv build myapp --dir=nix # copies the whole repo/nix/ folder
nixenv build myapp --dir=/abs/path # or an absolute host path--dir copies the entire folder into the build, so relative references inside it
(overlays, a vendored package, ./.-style local inputs) work.
Only build flakes you trust. Building a flake runs its build code as root,
with write access to the Nix store that every project shares. nixenv limits
what a project flake can do: its nixConfig is ignored (so it can't add its own
binary cache and signing key), GITHUB_TOKEN is not passed to it, and builds
are sandboxed when the builder can create namespaces. The builder container
usually can't, and then Nix builds without a sandbox. So a malicious flake can
still tamper with the shared store and, through it, other projects. build
reminds you of this the first time you build each project. A template's flake,
and any repo you init … --build, falls under the same rule.
The shared profile includes git, zsh + oh-my-zsh + starship, OpenSSH, runit,
Caddy (for the shared reverse proxy), age and sops (encrypted secrets), the
Claude CLI (claude), zmx (terminal session persistence), common CLI tools
(curl, wget, ping, host/dig, ripgrep,
fd, fzf, bat, jq, delta, lazygit, …), a build toolchain (gnumake, gcc, binutils,
pkg-config, cmake, autoconf, automake, libtool), and an editor — Neovim +
AstroNvim (see Editor).
The base ships no language runtimes at all — no Node, PHP, Python, Go, Rust, Ruby — and no package managers or language servers for them. That's deliberate: the base is the shell/editor/CLI toolbox every project shares, and languages belong in a per-project flake (see Per-project tooling), so each project pins its own versions and nothing pays for toolchains it never uses. Add the runtime and its language server together:
paths = with pkgs; [
nodejs_22 # or php83 + php83Packages.composer
typescript-language-server # …and its LSP, so nvim works
];The templates are complete
worked examples. Edit the embedded flake.nix block in nixenv.sh and re-run
build to change the base set.
nvim launches AstroNvim — a Neovim distribution with a
VS Code-like feel: file tree, buffer tabs, statusline, LSP, completion, git
signs, and a VS Code colorscheme. Language servers come from the toolchain, not
Mason (which is disabled) — and since the base has no language runtimes, it
ships only the two servers that need none: lua-language-server (for editing
the nvim config itself) and bash-language-server, with their astrocommunity
packs enabled.
For a real language, add the server to the project's flake next to its
runtime (pyright, intelephense, gopls, rust-analyzer, ruby-lsp,
typescript-language-server, …) and enable the matching pack through a
per-project <repo>/.nixenv/home/.config/nvim/ override applied with
sync-home.
The config lives at ~/.config/nvim/init.lua (seeded from the skeleton, editable
in the home volume). On the first nvim launch, lazy.nvim downloads the
plugins (needs network; a one-time step that persists in the home volume). For
the icons to render, use a Nerd Font in your terminal.
You log in to claude once and every project uses that login. Everything else
Claude keeps — settings, hooks, MCP servers, CLAUDE.md, slash commands — is
per project, so one project can't plant something that runs in another:
~/.nixenv/claude/.credentials.json → shared by all projects (the login)
~/.nixenv/claude/profiles/<project>/ → that project's ~/.claude + ~/.claude.json
~/.nixenv/claude/projects/nixenv-<project>/ → that project's session transcripts
What a project can still read: the login token itself. Token refresh
rewrites the credentials file, so it has to be shared read-write, and any code
running in a project (a cloned repo's startup hook included) can read it. Only
run claude in containers holding code you trust, or log in per project by
deleting the shared file and logging in inside each one.
New project profiles start empty (no settings are copied from anywhere). If you
used nixenv before this change, your old shared config is still at
~/.nixenv/claude.json and ~/.nixenv/claude/settings.json; copy what you want
into a project's profile by hand — and check it for mcpServers or hooks
entries you don't recognise first. delete <project> removes its profile but
keeps its transcripts.
Transcripts are reviewable on the host at
~/.nixenv/claude/projects/nixenv-<project>/<encoded-cwd>/<session-id>.jsonl
and are auto-pruned after ~30 days; raise "cleanupPeriodDays" in the project's
profiles/<project>/dot-claude/settings.json to keep them.
Override via environment variables:
CONTAINER_ENGINE(dockerorpodman; auto-detects, asks if both present)CONTEXT_DIR(default~/.nixenv/context)CONTAINER_PREFIX(defaultnixenv) — every engine-side name: containers<prefix>-<project>, volumes<prefix>_<project>_*, and the defaults ofNIX_VOLUMEandPROXY_NETbelow. Two prefixes on one engine share nothing.NIX_VOLUME(default<prefix>__nixos_store, i.e.nixenv__nixos_store)BUILDER_IMAGE(defaultnixos/nix:2.32.8)RUNTIME_IMAGE(defaultdebian:stable-slim)APP_USER(defaultapp)INSTALL_DIR/INSTALL_NAME(default/usr/local/bin/nixenv) — used byinstall/uninstall.GIT_USER_NAME/GIT_USER_EMAIL— skip the interactive git identity prompt.GIT_HTTP_USER/GIT_HTTP_TOKEN— skip the interactive HTTPS credentials prompt (forinitwith anhttp(s)URL).APP_MOUNT— default code-volume mount path forinit(same as--app-path).PROXY_DOMAIN(defaultnixenv.localhost),PROXY_NET(default<prefix>_net, i.e.nixenv_net),PROXY_HTTP_PORT/PROXY_HTTPS_PORT(default 80/443; use 8080/8443 for rootless Podman),PROXY_AUTOSTART(default 1; 0 = don't start the proxy onstart),PROXY_MKCERT_INSTALL(0 = never runmkcert -install).EGRESS_PORT(default 3128) — squid's port inside thenixenv__egresscontainer (not published; used by restricted projects).NIXENV_DASHBOARD_DELAYS(default10 30 90) — seconds afterstartat which the dashboard is re-checked; empty = off.
Projects always live in ~/.nixenv/projects (not configurable).
Move a whole project to another machine, or keep a backup:
nixenv stop myapp # a live database tars inconsistently
nixenv export myapp # → nixenv-myapp-20260927-101500.tar
# ...copy it across...
nixenv import nixenv-myapp-20260927-101500.tar
nixenv build myapp && nixenv start myappimport <file> <new-name> clones a project under a different name on the same
machine — handy for forking a database-heavy environment. Importing onto a name
that already exists needs --force, which replaces that project's volumes —
it warns and asks first (--yes to skip the prompt).
What travels by default: the app and databases volumes, the deploy
state volume if deploy has created one, plus everything
in ~/.nixenv/projects/<project>/ that isn't regenerated per machine — egress
and deploy settings, ports, hosts, extra engine parameters, your extra
authorized ssh keys, and the home seed with its git identity. Git credentials in
the seed travel only with --with-home. If your deploy tools keep secrets in
/deploy (terraform state often does), export warns that the archive holds
them; import restores that volume only into /deploy of the deploy container.
An archive may be someone else's, so import checks what it restores. Host
lists and ports are re-validated (ports stay on loopback), the git identity is
rebuilt from name and email only, and anything that changes how the container is
created or who can log in — extra-parameters, unrestricted, the deploy ssh/git
configs and known hosts, extra authorized keys — is shown and applied only after
you answer yes. Without a terminal, or if you say no, those files are kept next
to their target as <file>.imported for you to review and rename.
A token in the repo's .git/config is the other leak. If you ever cloned
with https://user:token@host/…, git stored that URL verbatim — and the app
volume is in every archive. export refuses when it finds one and tells you how
to fix the remote; import strips any it finds. Only http(s) URLs are touched
(ssh://git@host is a username, not a secret).
The home volume is opt-in. It holds ~/.ssh and ~/.git-credentials, so
including it by default would make every backup a credential leak. A default
archive is safe to hand to a colleague; import builds a fresh home instead —
the archived seed's dotfiles and git identity (skeleton for anything missing),
and .ssh/ at mode 700.
nixenv export myapp --with-home # keeps shell history, nvim plugins,
# ~/.local/bin — and the secrets. ⚠️Without --with-home you lose shell history, installed nvim plugins and
anything you dropped in ~/.local/bin; everything else in that volume is
reseeded. After such an import, to restore outbound git auth:
If the project clones over HTTPS, import notices and prompts for a username
and token itself. For git-over-ssh you still need a key:
nixenv ssh myapp && ssh-keygen -t ed25519What never travels, and why:
- The shared Nix store. Gigabytes, and fully reproducible — that's what
nixenv buildis for. An archive is the size of your data, not your toolchain. passwd/group/shadow. Generated from your uid.importregenerates them and chowns the restored volumes to your uid, which is what makes a cross-machine move work at all — the archive's files carry the exporting machine's ownership.- The SSH port.
importassigns a fresh free one and prints it; the exported port may already be taken here. - The project's ssh key, host key and ssh config. Re-created on import, so an archive you receive can't come with a key someone else holds. The host ssh config embeds this machine's port and paths.
- Capture state (
capture,capture-trust): whether this machine's mitmproxy CA is trusted is a local decision.
export refuses while the project is running, because copying a live Postgres or
MySQL data directory is crash-consistent at best. --force overrides it with a
warning, which is fine for a code-only project and not fine for a database.
It also refuses while a deploy session is open, for the same reason.
Both commands report progress as they go, so a multi-GB volume doesn't look like a hang:
==> Archiving nixenv_myapp_databases
archiving databases… 48312 files
databases.tar.gz 1.4G
On a terminal that's a single self-updating line. Piped or in CI it prints a line
periodically instead, so logs stay readable. NIXENV_PROGRESS=0 turns it off.
The Nix store keeps every package it has ever built. Removing something from a flake only makes those paths unreachable — it doesn't delete them, so the store grows over time (a base rebuild that drops a language runtime can leave gigabytes behind). Collect them:
nixenv gc --dry-run # report what would go
nixenv gc # delete it, prints before → after sizeIt deletes old profile generations plus every path not reachable from a live
profile — the base (shared) and each proj-<project> — then hardlinks
identical files. Everything your current toolchains reference is kept, so the
next start needs no downloads.
Stop your projects first if you want a full sweep: a running container
executes binaries from the store paths it started with, and if a rebuild has
since moved its profile forward, those older paths are collectable. gc warns
and asks before proceeding when it sees running containers, and reminds you to
restart them afterwards.
gc is the safe, incremental option; clean is the nuclear one —
it removes the whole volume, so the next build re-downloads everything.
The test suite lives in tests/ — one bash file per test, a shared
tests/lib.sh harness, and a runner. Exit codes: 0 pass, 77 skip, else fail.
./tests/run.sh # unit tests — pure logic, no docker needed
./tests/run.sh integration # end-to-end against a real engine (DEDICATED env!)
./tests/run.sh all # both
./tests/run-in-docker.sh # the whole suite inside docker-in-docker —
# touches nothing on your machine
NIXTEST_HEAVY=1 ./tests/run.sh integration # include the slow flake-build testUnit tests source nixenv.sh (functions only, nothing executes) and verify all
pure logic: URL/name/ACL parsing, Caddyfile/squid/start.sh/egress.sh
generation, the entrypoint's feature hooks, allowlist semantics, and the
capture addon (against a stub mitmproxy, so no install is needed). Integration
tests exercise the real flows — init/volumes/run/ssh/app-path/hosts/proxy
routing/egress deny+allow/capture/sync-home/expose/delete — using an isolated prefix (nxt-*
containers, volumes, networks) and isolated state dirs; they sweep everything
prefixed before and after each test, and reuse the shared nix store volume
(test 00 builds it if missing). run-in-docker.sh wraps all of that in a
disposable privileged DinD container with a named cache volume
(nixenv-dind-cache) so repeat runs skip the store build.
Which ports is my project actually serving, and at what URL?
Run nixenv ps, or open https://nixenv.localhost/. See
Project dashboard.
Can two projects talk to each other? Do I add the other one to allowed_hosts?
No: allowed_hosts is for the outside world. A restricted project has no route
to other containers, and squid refuses any name that resolves to a private
address, so nixenv-other in allowed_hosts is still denied. Instead, the
target grants access in its accept-from, and the caller uses the target's
public URL (https://other-8000.nixenv.localhost/):
echo myapp >> ~/.nixenv/projects/other/accept-from # '*' = every project
nixenv proxy reload # no restart neededThe target decides, so a compromised caller can't grant itself access. Unrestricted
projects share one network and reach each other directly (http://nixenv-other:8000/).
See Projects can't reach each other by default.
How do I reach a service on my host (host.docker.internal)? --add-host in extra-parameters does nothing.
extra-parameters is passed to the engine verbatim, but --add-host has no
effect: nixenv mounts its own /etc/hosts and the entrypoint rebuilds it on
every start (see Custom /etc/hosts). The same applies to
podman's automatic host.containers.internal.
- From a restricted project you can't reach the host:
host.docker.internalresolves to a private address, which squid always refuses. Even when the name is inallowed_hosts, the request shows up asTCP_DENIEDinnixenv egress <project>. - From an unrestricted project on Docker Desktop, it already works: Docker's DNS resolves the name.
- From an unrestricted project on Linux, map the name to the gateway of
the project network with
nixenv host. The host service must listen on that address or on0.0.0.0.
nixenv restrict myapp off
gw="$(docker network inspect nixenv_net -f '{{(index .IPAM.Config 0).Gateway}}')"
nixenv host myapp "host.docker.internal:$gw"
nixenv stop myapp && nixenv start myappUse extra-parameters for flags the engine applies itself (--memory,
--ulimit, --device, …). It is read only when the container is created,
so after an edit run stop and then start: start on a running container keeps
the old flags.
- Code, home, and databases live in named volumes and survive
stop/startand rebuilds; onlydelete <project>(with confirmation) andcleanremove data.~/.nixenv/projects/<name>/on the host holds only small state (home seed, SSH config, git credentials, port). - The Nix store volume persists across runs;
cleanis the only thing that removes it. - The
.gitconfigseeded into each home ships sensible modern defaults (histogram diff,push.autoSetupRemote,rerere,rebase.autoStash, …), largely from how Git core devs configure Git.
nixenv --version # nixenv 0.1.0--version is dispatched before the embedded context is materialised, so it
needs no container engine, no network, and writes nothing — which is what makes
it usable as a packaging smoke test.
GPL-3.0-or-later. See LICENSE.
nixenv is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.