Safe, isolated development environments for trying out projects without risking your machine.
Key features:
- Runs each project in an isolated Docker container
- CLI-based development: nvim, AI agents, etc.
- Transparent port forwarding: access apps in your browser as if they were running on the host
- Scripts are easy to audit: less than 300 lines of bash running on the host
- Minimal dependencies: docker, docker-compose, git, bash, ssh
As a side effect, you get clean, reproducible, and easy-to-reset development environments.
No more npm install on your host, no AI agents reading your files.
Safely experiment with any project.
Simple, transparent scripts you can easily audit:
- Main script
bin/dev: ~200 lines of bash, mostly if/else and case statements, linear, no recursion - Port forwarding
bin/ports: ~50 lines of bash, starting a ssh tunnel withssh -L ... - Docker-compose file: ~30 lines of yaml, defining services and volumes
- Dockerfile: ~20 lines, defining the base image and which tools to install by default
- Setup scripts in
setup/: ~5-30 lines of bash each
You are supposed to modify the Dockerfile and setup scripts to fit your needs. The docker-compose file can also be used to include additional services or enable permanent port forwarding.
Requires docker, docker-compose and some basic tools like git, bash and ssh.
The command bin/dev should be somewhere in your PATH.
mkdir -p ~/bin
wget https://raw.githubusercontent.com/f0i/dev/refs/heads/main/bin/dev -O ~/bin/dev
echo "export PATH=\"$PATH:$HOME/bin\" >> ~/.bashrc"
All other files will be added per project as needed:
- Assuming a project called
arenain~/projects/arena - Clone this repo into the project root
dev arena init - During init, you'll be prompted to:
- Copy your SSH public key and
.gitconfig(choose yes to copy tosetup/user/, or no to configure manually later) - Clone a nvim configuration (automatically detects your
~/.config/nvimgit remote, or uses default)
- Copy your SSH public key and
- Modify the
Dockerfileto include the services and tools you need - Build the docker container using the command
dev build - Start the docker container with
dev start - Connect to the container with
devand start development - Enable port forwarding to the host
dev ports 3000 8080:80, in this case port 3000 and 8080 of the host will be forwarded to port 3000 and 80 of the container
setup/agent.sh installs Claude Code, opencode and the Gemini CLI.
Comment out the RUN /setup/agent.sh line in the Dockerfile to leave them out, or edit the script to change the set.
setup/user/claude/settings.json is copied to ~/.claude/ and sets three defaults for Claude Code:
- no attribution on commits or pull requests, and no session link
- Remote Control off at startup, so a session is reachable from outside only after you run
/remote-control - approval required before Claude messages one of your sessions on another machine, which is the case that travels through Anthropic's servers
Edit that file to change them. Claude Code writes its own settings there too, so a container keeps whatever you change inside it until it is rebuilt.
During dev init, the nvim configuration is cloned into ./dev/nvim and mounted at /home/node/.config/nvim inside the container.
Why not mount ~/.config/nvim directly?
- Isolation: Each project gets its own nvim config for reproducibility
- No modifications: Prevents the container from modifying your personal config files
- Different needs: Container setup might need different plugins/settings than your host
Using a different nvim config:
- During init, it auto-detects the git remote of your
~/.config/nvimif it exists - Or you can manually clone any nvim config repo to
./dev/nvimbefore building - Or skip the nvim setup during init and add it later
Some tools bring their own containers: Laravel Sail, Testcontainers, a project's own docker compose.
Those need a real Docker daemon rather than a socket pointed at the host's, so the container ships an optional rootless one.
It is rootless rather than the usual privileged Docker-in-Docker.
A privileged daemon can mount host block devices and load kernel modules, so anything able to reach it has root on the host by design.
The rootless daemon runs as node inside a user namespace: container root maps to an unprivileged uid, block devices cannot be mounted at all, and read-only mounts stay read-only.
To enable, uncomment three lines, each commented and cross-referenced:
docker-compose.yml: thesecurity_optblock, without which the daemon cannot create its user namespace and exits immediatelyDockerfile: theRUN /setup/dind.shlineDockerfile: theRUN /setup/section3.shline, which is what starts the daemon.setup/dind.shdeclares it as a service in~/.config/section3/conf.d/, and section3 reads it. Without section3, start it yourself:XDG_RUNTIME_DIR=/run/user/$(id -u) dockerd-rootless.sh
Then dev build and dev start.
To check it worked:
docker info | grep -i 'storage driver' # overlay2, never vfs
docker run --rm hello-world
What it costs. A wider kernel attack surface: user namespaces plus mount reach code paths with a history of local privilege escalation bugs, CVE-2022-0185 and CVE-2023-0386 among them.
It grants no new authority over the host.
The block is needed because Docker's default seccomp profile blocks clone(CLONE_NEWUSER) without CAP_SYS_ADMIN.
A narrower custom profile instead of unconfined is possible, but it still has to allow mount, unshare, setns, pivot_root and the new mount API, so it buys less than it looks like it should.
Sub-containers can be handed anything the dev container can see, so keep the paths you mount into them narrow.
File ownership. The rootless daemon maps container uid 0 to node and everything above it into the subuid range, so a container running as uid 1000 writes files that arrive as 100999 outside.
Run containers as root to keep bind-mounted files owned by node.
For Sail that means sail artisan sail:publish, then changing user=sail to user=root in supervisord.conf.
Setting WWWUSER=0 does not work, because the entrypoint's usermod refuses a duplicate uid.
MIT License. See LICENSE.md for details.