A CLI tool to manage isolated development environments using Incus system containers. Each environment comes with Docker pre-installed, SSH access, and shared host configurations.
This is especially useful when you need to run complex projects requiring multiple containers locally and switch between them quickly. The port forwarding system lets you access services across containers seamlessly, while directory mounts keep your configs and code synchronized.
- Isolated dev environments - Each container is fully isolated with its own Docker daemon
- Non-root user - Runs as
devuser with matching UID, passwordless sudo available - SSH access - Unique port per container (starting at 2200, incrementing by 10)
- Service ports - 10 additional forwarded ports per container for services (2300-2309, 2310-2319, etc.)
- Shared configs - Automatically mounts
~/.config,~/.opencode,~/.claude,~/.codex,~/.omp,~/.ssh,~/.gitconfig - Docker-in-Docker - Full Docker support via Incus nesting
- Low overhead - ~100-200MB RAM per container vs 512MB+ for VMs
- Custom setup scripts - Run post-create scripts to install additional tools
-
Incus installed and initialized
sudo apt install incus sudo incus admin init
-
User in incus-admin group
sudo usermod -aG incus-admin $USER # Log out and back in for group to take effect
-
~/.local/bin in PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc source ~/.bashrc
From the repository root:
mkdir -p ~/.local/bin
ln -sf "$(pwd)/bin/ocdev" ~/.local/bin/ocdev# Create a new dev environment
ocdev create myproject
# Output: Container 'myproject' created (SSH: 2200, Services: 2300-2309)
# Create with a custom setup script
ocdev create myproject --post-create ~/dotfiles/dev-setup.sh
# Clone an existing environment (source stays running; in-flight filesystem changes may not be fully consistent)
ocdev create myproject-clone --from myproject
# List all environments
ocdev list
# Access via shell (direct)
ocdev shell myproject
# Access via SSH
ssh -p 2200 dev@localhost
# Or get the command:
ocdev ssh myproject
# Run Docker inside
ocdev shell myproject
docker run hello-world # Works!
# Stop/Start
ocdev stop myproject
ocdev start myproject
# Delete when done
ocdev delete myproject
# View all port allocations
ocdev ports| Command | Description |
|---|---|
ocdev create <name> [--post-create <script>] [--from <container[/snapshot]>] |
Create new dev environment |
ocdev list |
List all dev environments |
ocdev start <name> |
Start a stopped environment |
ocdev stop <name> |
Stop a running environment |
ocdev shell <name> |
Get interactive shell inside |
ocdev ssh <name> |
Show SSH connection info |
ocdev delete <name> |
Remove environment |
ocdev ports |
Show all port mappings |
ocdev bind <name> <port> [--list] |
Bind a dynamic port to a container |
ocdev unbind <name> <port> |
Remove a dynamic port binding |
ocdev rebind <name> <port> |
Move a port binding to a different container |
ocdev bindings |
List all dynamic port bindings across containers |
ocdev export <name> [--output <path>] |
Export container as portable tarball |
ocdev import <name> --file <path> |
Import container from exported tarball |
- Incus Profile: Creates an
ocdevprofile with Docker nesting enabled - Container: Launches Ubuntu 25.10 system container with the profile
- Mounts: Binds host directories into
/home/dev/inside container - Provisioning: Installs Docker, SSH server, git, curl
- Port Forwarding: Maps host ports to container ports:
- SSH: host
22X0-> container22(where X is 0, 1, 2, ... for each VM) - Services: host
23X0-23X9-> container23X0-23X9(10 ports per VM)
- SSH: host
~/.local/bin/ocdev # Executable (or symlink)
~/.ocdev/ # Config directory
~/.ocdev/ports # Port assignments (name:port format)
~/.ocdev/.lock # Lock file for concurrent operations
Each container gets 11 forwarded ports:
- 1 SSH port (host -> container port 22)
- 10 service ports (host -> same port in container)
| VM # | SSH Port | Service Ports | Use For |
|---|---|---|---|
| 1 | 2200 | 2300-2309 | First container |
| 2 | 2210 | 2310-2319 | Second container |
| 3 | 2220 | 2320-2329 | Third container |
| n | 2200+(n-1)*10 | 2300+(n-1)*10 to 2309+(n-1)*10 | nth container |
Service ports are forwarded to the same port inside the container. For example, if your app inside container 1 listens on port 2300, access it from host at localhost:2300.
In addition to the static service ports above, you can dynamically bind any port to a container:
# Bind host port 5173 to the same port in the container
ocdev bind myproject 5173
# Bind host port 8080 to container port 3000
ocdev bind myproject 3000:8080
# List current dynamic bindings
ocdev bind myproject --list
# Remove a binding
ocdev unbind myproject 5173
# Move a binding from one container to another
# (automatically unbinds from the current owner)
ocdev rebind otherproject 5173
# See all dynamic bindings across all containers
ocdev bindings
# CONTAINER HOST CONTAINER STATUS
# myproject 5173 5173 RUNNING
# otherproject 8080 3000 STOPPEDThe rebind command is useful when switching between projects — it finds which container currently owns the port, unbinds it, and binds it to the target container in one step. If the port is not bound anywhere, it acts as a regular bind.
Use ocdev bindings for a global overview of which ports are bound where and whether those containers are running.
By default, ocdev ports are bound to all interfaces (0.0.0.0). It is recommended to restrict access to a trusted network interface (e.g., Tailscale) using UFW.
sudo ufw enable# Allow SSH and service ports on Tailscale interface
sudo ufw allow in on tailscale0 to any port 2200:2399 proto tcp
# Block these ports on public interfaces (adjust interface names as needed)
sudo ufw deny in on eth0 to any port 2200:2399 proto tcp
sudo ufw deny in on wlan0 to any port 2200:2399 proto tcpsudo ufw status numbered| Host Path | Container Path | Mode |
|---|---|---|
~/.config |
/home/dev/.config |
read-write |
~/.opencode |
/home/dev/.opencode |
read-write |
~/.claude |
/home/dev/.claude |
read-write |
~/.codex |
/home/dev/.codex |
read-write |
~/.omp |
/home/dev/.omp |
read-write |
~/.ssh |
/home/dev/.ssh |
read-only |
~/.gitconfig |
/home/dev/.gitconfig |
read-only |
Use --from to clone an existing environment. It accepts either container for a live clone or container/snapshot for a snapshot clone.
Live clone an environment from its current container state:
# Clone from the current container state
ocdev create myproject-clone --from myprojectThis works whether the source environment is running or stopped. If the source container is running, ocdev clones it while leaving the source up, but in-flight filesystem changes may not be fully consistent in the clone.
Clone from a specific snapshot when you want a named, stable point-in-time source:
# First, create a snapshot of an existing container
incus snapshot create ocdev-myproject initial
# Then create a new container from that snapshot
ocdev create myproject-clone --from myproject/initialIn both forms, the cloned environment:
- Gets new SSH and service port assignments (no port conflicts)
- Does not inherit proxy devices or dynamic port bindings from the source
- Keeps the same local host directory mounts as the source container
Use a live clone for fast local duplication, or a snapshot clone when you need a deliberate point-in-time base.
Run a custom script after container provisioning using --post-create:
ocdev create myproject --post-create ./setup.shThe script runs as the dev user inside the container after base provisioning (Docker, SSH, git, etc. are already installed). The script has:
- Network access
- Passwordless sudo via
sudo - Full access to install packages, configure tools, etc.
Example setup script:
#!/bin/bash
# Install additional tools
sudo apt-get update
sudo apt-get install -y neovim tmux ripgrep
# Install Node.js via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
source ~/.nvm/nvm.sh
nvm install 20If the post-create script fails, the container is kept so you can debug:
ocdev shell myproject # Debug what went wrongInstall Incus: sudo apt install incus
sudo usermod -aG incus-admin $USER
# Then log out and back inCheck Incus is initialized: incus list
If not: sudo incus admin init
- Check container is running:
ocdev list - Start if stopped:
ocdev start <name> - Verify port:
ocdev ssh <name>
The container needs security.nesting=true. This is set automatically via the ocdev profile. If issues persist:
incus profile show ocdev
# Should show security.nesting: "true"