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.
-
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/.
Install the single self-contained script onto your PATH so you can call
nixenv from anywhere:
./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>.
./nixenv.sh build # download all deps into the volume (slow once)
./nixenv.sh init myapp # scaffold a project, prompts for git identity
./nixenv.sh run myapp # start the service (prints the SSH port;
# also auto-starts the shared HTTPS proxy)
./nixenv.sh 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), and optionally pick where it mounts in the container:
./nixenv.sh init myapp git@github.com:me/app.git
./nixenv.sh init web git@github.com:me/web.git --app-path=/var/www/htmlDon't want to set up keys? ./nixenv.sh 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). See Per-project tooling.init <project> [git-url] [--build] [--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 byrun,shell, and the logincd. For anhttp(s)URL it also prompts for a username + Personal Access Token and stores them (see HTTPS credentials).run <project>— 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).proxy [up|stop|status|logs|renew|remove-cert]— shared HTTPS reverse proxy for all projects (see Reverse proxy).up <project>— build if needed, then start the service.stop <project>— stop and remove the project's service container.logs <project>— follow the service container logs.delete <project>(aliasrm) — permanently remove a project: its container(s), the app/home/databases volumes, 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.update— refreshflake.lock, then rebuild into the volume.status— show context, volume, and shared-profile state.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.sh ssh myapp # convenience wrapper
ssh -p <port> app@127.0.0.1 # equivalentOpen local login. For local-dev convenience there is no key and no password:
the app account has an empty password (via the bind-mounted /etc/shadow) and
sshd permits the empty-password ("none") method, so you connect with no prompt.
The published port is bound to 127.0.0.1 only, so the container is
reachable from your machine but not
from the network. Root login is disabled.
This is intentionally insecure and meant for a trusted local machine. If you later want key-only access, drop your public key into
home/.ssh/authorized_keysand ask to re-enableAuthenticationMethods publickey.
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.
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.sh expose myapp 8080 # → 127.0.0.1:8080:8080
./nixenv.sh expose myapp 3000:3000 # host:container
./nixenv.sh 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 run. 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
run, 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.sh proxy up | 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).
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.sh 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 run 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.sh 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.sh init myapp https://gitlab.example.com/team/app.git
# → gitlab.example.com auto-allowed; everything else denied
./nixenv.sh restrict myapp off # opt OUT (full internet access)
./nixenv.sh restrict myapp on # re-enable (the default)
./nixenv.sh 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 inside the shared proxy
container. Enforcement is the missing route; squid is just policy, so nothing
in the container can bypass the list. 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, …)
10.0.0.5 # a literal IP
init seeds the forge domain from the clone URL automatically; projects
created before this feature have an empty list, so allow their forge before
pulling. Manage it from the host:
./nixenv.sh allow myapp registry.npmjs.org api.stripe.com # add + reload
./nixenv.sh egress myapp # allowed vs DENIED domains
./nixenv.sh 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); DNS resolution still
works for any name (data can't flow, but lookups aren't blocked); 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/9418.
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.sh allow myapp github.com release-assets.githubusercontent.com
# npm / npx / pnpm
./nixenv.sh allow myapp registry.npmjs.org
# yarn
./nixenv.sh allow myapp registry.yarnpkg.com
# Composer (PHP) — packagist metadata + GitHub-hosted dists
./nixenv.sh allow myapp repo.packagist.org api.github.com codeload.github.com github.com
# pip / uv (Python)
./nixenv.sh allow myapp pypi.org files.pythonhosted.org
# cargo (Rust) — sparse index + crate downloads; rustup toolchains
./nixenv.sh allow myapp index.crates.io static.crates.io crates.io static.rust-lang.org
# go modules
./nixenv.sh allow myapp proxy.golang.org sum.golang.org
# Claude CLI (platform.claude.com serves OAuth login/token refresh)
./nixenv.sh allow myapp api.anthropic.com statsig.anthropic.com platform.claude.com
# Neovim / AstroNvim first launch (lazy.nvim clones plugins from GitHub)
./nixenv.sh allow myapp github.com
# VS Code Remote-SSH — server download + extension marketplace
./nixenv.sh 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.sh egress myapp — it names the exact domains.
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.sh 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, …)
/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), and ssh/config. 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.
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.
The shared profile includes git, zsh + oh-my-zsh + starship, OpenSSH, runit,
Caddy (for the shared reverse proxy), 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), language runtimes: Node 22
(with npx), Go, Rust (rustup), PHP 8.5 + Composer, Python 3.12, and uv (with
uvx), and an editor — Neovim + AstroNvim with language servers for Go,
TypeScript/JavaScript, Python, Rust, PHP, Ruby, Bash, and Lua (see
Editor). Edit the embedded flake.nix block in
nixenv.sh and re-run build to change the 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 base toolchain
(gopls, rust-analyzer, pyright, typescript-language-server,
intelephense, ruby-lsp, bash-language-server, lua-language-server), so
they're on PATH and Mason won't download its own copies.
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.
The Claude CLI keeps state in two places, and both are shared read-write so a
single claude login and config carry across all projects (and survive
container recreation):
~/.nixenv/claude → /home/app/.claude (credentials, settings, backups)
~/.nixenv/claude.json → /home/app/.claude.json (global config file)
Both are created automatically on first run. ~/.claude.json lives in the
home root (not inside .claude), so it's shared as its own file; if it's ever
missing, the newest backup from the shared .claude/backups/ is restored
automatically.
Session transcripts are per-project: while credentials/settings are shared,
.claude/projects is overlaid with a per-project directory, so every session's
JSONL transcript is reviewable on the host at
~/.nixenv/claude/projects/nixenv-<project>/<encoded-cwd>/<session-id>.jsonl
(without this, all projects using the same in-container path would interleave
their transcripts in one folder). Transcripts are auto-pruned after ~30 days;
raise "cleanupPeriodDays" in ~/.nixenv/claude/settings.json to keep them. (If you saw a "Claude configuration file not found" warning, it
was because only .claude was shared before — this resolves it.)
Override via environment variables:
CONTAINER_ENGINE(dockerorpodman; auto-detects, asks if both present)CONTEXT_DIR(default~/.nixenv/context)NIX_VOLUME(defaultnixenv__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(defaultnixenv_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 onrun),PROXY_MKCERT_INSTALL(0 = never runmkcert -install).EGRESS_PORT(default 3128) — squid's port inside the proxy container (not published; used by restricted projects).
Projects always live in ~/.nixenv/projects (not configurable).
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 generation, the
entrypoint's feature hooks, allowlist semantics. Integration tests exercise the
real flows — init/volumes/run/ssh/app-path/hosts/proxy routing/egress
deny+allow/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.
- Code, home, and databases live in named volumes and survive
stop/runand 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.