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
- Snapshot recipes and projects - Reusable setup tasks, hooks, private file references, and pinned configuration
- Task and service operations - Execution history, bounded logs, and optional process-compose control
- Automation -
--jsonon noninteractive management commands; existinglist --jsonstays compatible
-
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
Build from source with Nim and Atlas installed (release binaries need neither).
Atlas installs the dependencies declared in cmd/ocdev/ocdev.nimble into the
project-local cmd/ocdev/deps/ directory:
make dev-setup
makeFrom the repository root:
mkdir -p ~/.local/bin
ln -sf "$(pwd)/bin/ocdev" ~/.local/bin/ocdevRelease (make) and debug (make bin/ocdev-debug) builds embed
git describe --tags --always --dirty at compile time: v0.3.0 at the tag,
v0.3.0-3-gabc1234 three commits later, and a -dirty suffix for tracked
local modifications. Without a reachable tag, Git supplies a commit hash;
untracked files do not mark a build dirty. No Git executable is needed at runtime.
Override the version with make OCDEV_VERSION=v0.3.0-custom (or export
OCDEV_VERSION in the environment). Source archives and builds without usable
Git metadata fall back to dev. Direct Nim builds also default to dev unless
passed -d:Version=.... Nimble's package version remains ordinary release semver.
Make invokes Nim on every build, using its compiler cache, so commits, tag
changes, dirty/clean transitions and version overrides cannot leave stale
version output. A build after a new commit normally reports tag distance, not
exactly v0.3.0; existing release tags and published assets are not changed.
After make dev-setup, run make test on Linux. The unit and integration tests
are written in Nim and use compiled fake Incus executables; they require neither
Python nor a live Incus daemon. Test binaries are built into bin/ using the
same project-local Atlas dependencies as ocdev. Version regression tests also
require Git and GNU Make; run them separately with make test-version.
Use make test-list-json for listing contracts, make test-create-config for
create defaults and --fresh, make test-exec for direct command execution,
or make test-recipes for recipe coverage. Live container tests remain a separate, explicitly authorized workflow;
see verification.
# 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
# Machine-readable list (only public instance metadata)
ocdev list --json
# [{"name":"myproject","instance":"ocdev-myproject","status":"Running","uuid":"00000000-0000-4000-8000-000000000001","ssh_port":2200}]
# 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 portsexec runs a command as dev in an existing, running container. It defaults to
/home/dev; use --cwd to select another absolute guest directory.
ocdev exec myproject -- uname -a
ocdev exec myproject --cwd /home/dev/workspace/app -- npm test
printf 'hello\n' | ocdev exec myproject -- cat
ocdev exec myproject -- sh -c 'printf "%s\n" "$HOME"'The -- separator is required. Everything after it is passed literally to the
program, including empty arguments, --help, and --json. There is no implicit
shell or startup-file loading; invoke a shell explicitly when needed. exec
has no ocdev-level --json: stdin, stdout, stderr, and the command's exit code
pass through directly. Output is neither captured nor redacted by ocdev.
There is no pseudo-terminal, automatic start, or execution timeout. Use
ocdev shell for interactive sessions. Multiple exec sessions can run concurrently
in the same container. Exec does not acquire an environment lock or block lifecycle
operations, and it does not create task/run history or command/output logs.
It checks running state and, for recipe-managed containers, the pinned UUID before
execution. These are point-in-time checks: a container can stop or be replaced
between preflight and execution by name. Stop/delete can interrupt running commands;
exec does not coordinate with setup or teardown. ex is ambiguous with export;
use exec explicitly.
Ctrl+C/SIGTERM interrupts the local client; an unresponsive client is killed and reaped after a two-second grace period. Cancellation returns 130/143. Client termination does not guarantee that a guest command has stopped; check the guest before retrying a command with side effects. Signal forwarding and local cleanup are covered by fake-backend tests, not live Incus verification.
Recipes clone an existing container/snapshot, then deliver project files and run declared tasks/hooks. There is no new image builder, snapshot manager, or server.
ocdev recipe validate ./recipe.yaml --json
ocdev create demo --project ./project.yaml --dry-run --json
ocdev create demo --project ./project.yaml --json
ocdev task run demo test --json
ocdev services list demo --json
ocdev runs list demo --json
ocdev delete demo --dry-run --jsonSee Recipes and projects for the schema, complete CLI, generic examples, JSON contracts, failure recovery, and security boundaries. Recipes are trusted executable configuration; a snapshot may inherit host mounts or contain credentials. No application-specific, cloud, or agent integration is required.
| Command | Description |
|---|---|
ocdev create <name> [--post-create <script>] [--from <container[/snapshot]>] [--fresh] |
Create new dev environment |
ocdev list [--json] |
List all dev environments (table by default) |
ocdev start <name> |
Start a stopped environment |
ocdev stop <name> |
Stop a running environment |
ocdev shell <name> |
Get interactive shell inside |
ocdev exec <name> [--cwd <path>] -- <command> [args...] |
Run a command with raw stdin/stdout/stderr and exit status |
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 |
ocdev list --json prints a JSON array with exactly name (short name),
instance (full Incus name), status (the Incus status string), uuid
(from volatile.uuid, or null when missing), and ssh_port (the integer
from the local port allocation file, or null when unavailable). Raw Incus
configuration is never included. No matching instances returns [] with exit
code 0; query or malformed metadata failures return nonzero, report errors on
stderr, and leave stdout empty. JSON listing does not initialize local state.
Optional defaults live in ~/.ocdev/config.json, outside the repository, and
apply to every project run by the current user:
{
"base_image": "images:ubuntu/26.04",
"default_base_source": "my-base/stable"
}Both fields are optional strings. Without a config, ocdev create provisions
from images:ubuntu/25.10, as before. A nonempty default_base_source makes plain
create clone that container or snapshot instead (omit the ocdev- prefix).
Explicit --from or --from-snapshot overrides the configured source;
--fresh ignores it and provisions from base_image. --fresh cannot be
combined with either source flag. An empty default_base_source disables the
clone default. Invalid or unreadable config fails creation rather than silently
falling back; other commands do not read this file.
Use your own existing container/snapshot name in this file. Do not commit machine-specific defaults into the source code.
- 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/config.json # Optional user-wide create defaults
~/.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, creation now exits nonzero and the container and its port allocation are 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"