Skip to content

Latest commit

 

History

46 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ocdev - Incus Development Environment Manager

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.

Features

  • Isolated dev environments - Each container is fully isolated with its own Docker daemon
  • Non-root user - Runs as dev user 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 - --json on noninteractive management commands; existing list --json stays compatible

Prerequisites

  1. Incus installed and initialized

    sudo apt install incus
    sudo incus admin init
  2. User in incus-admin group

    sudo usermod -aG incus-admin $USER
    # Log out and back in for group to take effect
  3. ~/.local/bin in PATH

    echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
    source ~/.bashrc

Installation

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
make

From the repository root:

mkdir -p ~/.local/bin
ln -sf "$(pwd)/bin/ocdev" ~/.local/bin/ocdev

Build version

Release (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.

Testing

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.

Usage

# 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 ports

Run a command

exec 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 and projects

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 --json

See 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.

Commands

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.

Global User Configuration

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.

How It Works

  1. Incus Profile: Creates an ocdev profile with Docker nesting enabled
  2. Container: Launches Ubuntu 25.10 system container with the profile
  3. Mounts: Binds host directories into /home/dev/ inside container
  4. Provisioning: Installs Docker, SSH server, git, curl
  5. Port Forwarding: Maps host ports to container ports:
    • SSH: host 22X0 -> container 22 (where X is 0, 1, 2, ... for each VM)
    • Services: host 23X0-23X9 -> container 23X0-23X9 (10 ports per VM)

Directory Structure

~/.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

Port Allocation

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.

Dynamic Port Bindings

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           STOPPED

The 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.

Firewall Configuration (Recommended)

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.

Enable UFW (if not already enabled)

sudo ufw enable

Allow on Tailscale only

# 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 tcp

Verify rules

sudo ufw status numbered

Host Directory Mounts

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

Cloning Existing Environments

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 myproject

This 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/initial

In 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.

Custom Setup Scripts

Run a custom script after container provisioning using --post-create:

ocdev create myproject --post-create ./setup.sh

The 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 20

If 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 wrong

Troubleshooting

"incus not found"

Install Incus: sudo apt install incus

"User not in incus-admin group"

sudo usermod -aG incus-admin $USER
# Then log out and back in

Container creation fails

Check Incus is initialized: incus list If not: sudo incus admin init

SSH connection refused

  1. Check container is running: ocdev list
  2. Start if stopped: ocdev start <name>
  3. Verify port: ocdev ssh <name>

Docker not working inside container

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"

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages