Your own self-hosted AI coding agent — like GitHub Copilot coding agent, Claude Code, or Devin, but running on your own machine, fully under your control.
This tool sets up a secure Docker sandbox for OpenCode (an open-source AI coding agent) and connects it to Discord via remote-opencode, so you can manage your coding sessions from anywhere — your phone, another computer, or any device with Discord. This tool sets up a secure Docker sandbox for OpenCode (an open-source AI coding agent) and connects it to Discord via , so you can manage your coding sessions from anywhere — your phone, another computer, or any device with Discord.
Cloud-based AI coding agents (Copilot agents, Devin, etc.) run on someone else's infrastructure. They have limited context about your local setup, can't access your local databases or services, and you have no control over the environment. Self-hosted alternatives like running OpenCode directly on your machine give you full control, but come with risks — the agent has unrestricted access to your filesystem, network, and credentials.
remote-opencode-sandbox gives you the best of both worlds:
- Self-hosted cloud agent experience — Set it up once, then manage everything from Discord. Send a message, the AI codes. Review diffs, approve commits, run tests — all from your phone. It's like having your own Copilot coding agent, but it runs against your actual local dev environment (local Supabase, local databases, your real
.envfiles). - Security — OpenCode runs inside a locked-down Docker container as a non-root user with dropped capabilities,
no-new-privileges, and tmpfs mounts. Your host filesystem is untouched except for explicitly bind-mounted project directories. The agent can only see and modify the projects you explicitly give it access to. - Full local context — Unlike cloud agents, the sandbox container can reach your host services (Supabase, Postgres, Redis) via
host.docker.internal. Your dev servers run inside the container with your real config. The AI works with your actual project, not a stripped-down copy. - Multi-project — Mount multiple projects into a single container. Each gets its own
node_modulesvolume, git credentials, and service configuration. Work on a frontend and backend simultaneously. - Reproducible — Everything is generated from config. Tear it down and rebuild in seconds. Share
.sandbox.jsonwith your team so everyone gets the same setup. - Always running — Set up systemd auto-start and the sandbox comes up on boot. Your AI coding agent is always ready, waiting for instructions on Discord.
Host Machine Docker Container
+---------------------------+ +---------------------------+
| sandbox CLI | | /workspace/project-a/ |
| ~/.config/remote-opencode | | /workspace/project-b/ |
| -sandbox/ | | |
| | | Services: |
| Host services: | | bun install (oneshot) |
| supabase start | <--> | vite dev (daemon) |
| supabase functions | | remote-opencode (daemon)|
+---------------------------+ +---------------------------+
- You run
sandbox addto register projects and pick a template sandbox buildgenerates a Dockerfile, docker-compose.yml, entrypoint script, OpenCode config, and secretssandbox upstarts host-side services (e.g., Supabase), then builds and starts the container- Inside the container, a process supervisor runs oneshot tasks (install deps), starts daemon services (dev servers), and launches
remote-opencodelast - A watchdog monitors all daemons and restarts them with crash-loop backoff
Projects are bind-mounted, so edits on the host (or by OpenCode inside the container) are reflected immediately in both directions.
- Docker (Docker Desktop or Docker Engine)
- Bun (for running the CLI)
- Node.js 18+ (for npm global installs inside the container)
curl -fsSL https://raw.githubusercontent.com/Shenato/remote-opencode-sandbox/main/install.sh | bashThis installs Bun, OpenCode, remote-opencode, and the sandbox CLI automatically. Docker must be installed separately.
git clone https://github.com/Shenato/remote-opencode-sandbox.git
cd remote-opencode-sandbox
bun install
bun linkAfter linking, the sandbox command is available globally.
# 1. First-time setup — sets your git identity, GitHub PAT, and optional SSH key
sandbox init
# 2. Add a project — picks a template, scans .env files, configures services
sandbox add ~/projects/my-app --template web-supabase
# 3. Build Docker files and start everything
sandbox upThat's it. OpenCode is now running inside Docker and reachable via Discord.
| Command | Description |
|---|---|
sandbox init |
First-time setup: git identity, GitHub PAT, SSH key |
sandbox add <path> |
Add a project (interactive: template, env scanning, services) |
sandbox remove <project> |
Remove a project from an instance |
sandbox build |
Generate Docker files from config |
sandbox up |
Build and start the sandbox (container + host services) |
sandbox down |
Stop the sandbox and all host services |
sandbox restart |
Stop, rebuild, and restart |
sandbox logs [-f] |
View container logs (optionally follow) |
sandbox shell |
Open a bash shell inside the running container |
sandbox status |
Show status of all instances and their projects |
sandbox config show [project] |
Show global or project config as JSON |
sandbox config edit |
Open the config directory in $EDITOR |
sandbox templates |
List available project templates |
sandbox instance create <name> |
Create a new instance |
sandbox instance list |
List all instances |
sandbox instance remove <name> |
Delete an instance and its config |
sandbox setup-autostart |
Set up systemd auto-start on boot (Linux) |
All commands accept --instance <name> to target a specific instance (default: default).
Templates are pre-configured setups for common project types. They define default services, env rewrites, MCP servers, and Docker image settings.
Full-stack web app with Supabase local development. Vite dev server runs inside the container, Supabase runs on the host.
| Details | |
|---|---|
| Base image | node:24-bookworm |
| Container services | bun install (oneshot), vite dev --host 0.0.0.0 (daemon, port 8080) |
| Host services | supabase start (oneshot), supabase functions serve (daemon) |
| MCP servers | chrome-devtools (headless Chrome), supabase-local (Supabase MCP) |
| Env rewrites | localhost:54321 → host.docker.internal:54321 (Supabase API) |
localhost:54322 → host.docker.internal:54322 (Supabase Postgres) |
|
| Ports | 8080:8080 |
| Installs | Chrome, Bun, Supabase CLI, GitHub CLI, OpenCode |
Plain Node.js project with no host-side services.
| Details | |
|---|---|
| Base image | node:24-bookworm |
| Container services | bun install (oneshot) |
| Host services | None |
| MCP servers | chrome-devtools (headless Chrome) |
| Env rewrites | Any localhost:<port> → host.docker.internal:<port> |
| Ports | None (add your own) |
| Installs | Chrome, Bun, GitHub CLI, OpenCode |
Create a new file in src/templates/ implementing the Template interface, register it in src/templates/index.ts, and submit a PR.
All config lives in ~/.config/remote-opencode-sandbox/. Nothing is written to your project repos (except the optional .sandbox.json).
~/.config/remote-opencode-sandbox/
├── config.json # Global: git identity, default PAT
└── instances/
└── default/
├── instance.json # Instance: project list, docker overrides
├── projects/
│ └── my-project.json # Per-project: template, services, env, ports
└── generated/
├── Dockerfile # Generated (editable, regenerated by build)
├── docker-compose.yml
├── docker-entrypoint.sh
├── opencode.docker.json
├── .env # Secrets (never committed)
└── git-credentials/ # Per-project PAT files (never committed)
Configuration is merged in layers, with later layers overriding earlier ones:
- Template defaults — base services, env rewrites, MCP servers
- Global config — git identity, default GitHub PAT
- Instance config — docker overrides, extra packages, instance-level MCPs
- Project
.sandbox.json— committed to the repo, shared with team - Per-project config — stored in
~/.config/..., machine-specific overrides
Created by sandbox init. Contains your git identity, default GitHub PAT, and optional SSH key configuration.
{
"git": {
"name": "Your Name",
"email": "you@example.com"
},
"defaultGithubPat": "ghp_...",
"ssh": {
"keyPath": "~/.ssh/id_ed25519",
"githubUsername": "your-github-username"
},
"defaultInstance": "default"
}Each instance can override Docker settings and add instance-level configuration:
{
"name": "default",
"projects": ["my-frontend", "my-api"],
"extraPackages": ["python3", "postgresql-client"],
"docker": {
"baseImage": "node:24-bookworm",
"installChrome": true,
"installBun": true,
"installSupabaseCli": true,
"extraPackages": []
},
"mcp": {
"custom-mcp": {
"type": "remote",
"url": "http://host.docker.internal:9000/mcp"
}
}
}Created by sandbox add. Contains template selection, services, env overrides, and secrets:
{
"name": "my-project",
"hostPath": "/home/user/projects/my-project",
"instance": "default",
"template": "web-supabase",
"githubPat": "default",
"services": {
"container": [
{
"name": "install",
"command": "bun install",
"type": "oneshot",
"restart": "never"
},
{
"name": "dev",
"command": "bun run vite --mode localDev --host 0.0.0.0",
"port": 8080,
"type": "daemon",
"restart": "always",
"dependsOn": ["install"]
}
],
"host": [
{
"name": "supabase",
"start": "supabase start",
"stop": "supabase stop",
"healthCheck": "supabase status",
"type": "oneshot"
}
]
},
"envOverrides": {
"VITE_SUPABASE_URL": "http://host.docker.internal:54321",
"SUPABASE_URL": "http://host.docker.internal:54321"
},
"envPassthrough": ["VITE_SUPABASE_ANON_KEY"],
"envSecrets": ["GH_TOKEN"],
"ports": ["8080:8080"],
"permission": "allow"
}Place a .sandbox.json in your project root to commit sandbox config alongside your code. This is useful for sharing configuration with your team:
{
"template": "web-supabase",
"services": {
"container": [
{
"name": "test-watcher",
"command": "bun run test --watch",
"type": "daemon",
"restart": "on-failure"
}
]
},
"env": {
"override": {
"API_URL": "http://host.docker.internal:3000"
},
"passthrough": ["VITE_SUPABASE_ANON_KEY"],
"secrets": ["STRIPE_SECRET_KEY"]
},
"ports": ["3000:3000"],
"mcp": {
"my-custom-mcp": {
"type": "local",
"command": ["node", "mcp-server.js"]
}
},
"permission": "allow"
}When sandbox add detects a .sandbox.json, it merges it with the selected template's defaults.
A Next.js app with no backend services. Just run the dev server inside the container.
sandbox init
sandbox add ~/projects/my-nextjs-app --template node-basic
sandbox upAfter sandbox add, edit the generated project config to add the dev server:
sandbox config editOr create a .sandbox.json in the project root before running sandbox add:
{
"template": "node-basic",
"services": {
"container": [
{
"name": "install",
"command": "bun install",
"type": "oneshot",
"restart": "never"
},
{
"name": "dev",
"command": "bun run next dev --hostname 0.0.0.0 --port 3000",
"port": 3000,
"type": "daemon",
"restart": "always",
"dependsOn": ["install"]
}
]
},
"ports": ["3000:3000"]
}Then access the dev server at http://localhost:3000 on your host machine.
A Vite + React frontend with Supabase for auth, database, and edge functions.
sandbox init
sandbox add ~/projects/my-saas-app --template web-supabase
sandbox upThe web-supabase template handles everything automatically:
- Starts Supabase on the host (
supabase start+supabase functions serve) - Starts the Vite dev server inside the container on port 8080
- Rewrites
localhost:54321tohost.docker.internal:54321so the container can reach Supabase - Configures the
supabase-localMCP server so OpenCode can interact with your database - Configures
chrome-devtoolsMCP so OpenCode can inspect your running app
Your .env file might look like this on the host:
VITE_SUPABASE_URL=http://localhost:54321
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIs...The sandbox automatically detects the localhost:54321 reference and rewrites it to host.docker.internal:54321 for the container. Your original .env is never modified.
A frontend and API running in the same container:
sandbox init
sandbox add ~/projects/frontend --template web-supabase
sandbox add ~/projects/api --template node-basic
sandbox upBoth projects are mounted at /workspace/frontend and /workspace/api inside the container. Each gets its own node_modules volume.
Add a .sandbox.json to the API project:
{
"template": "node-basic",
"services": {
"container": [
{
"name": "install",
"command": "bun install",
"type": "oneshot",
"restart": "never"
},
{
"name": "api-server",
"command": "bun run dev",
"port": 4000,
"type": "daemon",
"restart": "always",
"dependsOn": ["install"]
}
]
},
"ports": ["4000:4000"],
"env": {
"override": {
"DATABASE_URL": "postgresql://postgres:postgres@host.docker.internal:54322/postgres"
}
}
}When projects have conflicting ports or you want full isolation:
# Production-like staging environment
sandbox instance create staging
sandbox add ~/projects/staging-app --template web-supabase --instance staging
# Development environment
sandbox add ~/projects/dev-app --template web-supabase
# Start them independently
sandbox up # starts default instance
sandbox up --instance staging # starts staging instanceEach instance gets its own Docker container, ports, and secrets.
A project with a custom background worker and a project-specific MCP server:
{
"template": "node-basic",
"services": {
"container": [
{
"name": "install",
"command": "npm install",
"type": "oneshot",
"restart": "never"
},
{
"name": "dev",
"command": "npm run dev",
"port": 3000,
"type": "daemon",
"restart": "always",
"dependsOn": ["install"]
},
{
"name": "worker",
"command": "npm run worker",
"type": "daemon",
"restart": "on-failure",
"dependsOn": ["install"]
}
],
"host": [
{
"name": "redis",
"start": "docker compose -f docker-compose.redis.yml up -d",
"stop": "docker compose -f docker-compose.redis.yml down",
"healthCheck": "docker compose -f docker-compose.redis.yml ps --quiet redis",
"type": "oneshot"
}
]
},
"ports": ["3000:3000"],
"env": {
"override": {
"REDIS_URL": "redis://host.docker.internal:6379"
},
"secrets": ["OPENAI_API_KEY", "STRIPE_SECRET_KEY"]
},
"mcp": {
"project-docs": {
"type": "local",
"command": ["node", "tools/mcp-docs-server.js"]
}
}
}Override the Docker config for a Python project:
{
"template": "node-basic",
"services": {
"container": [
{
"name": "install",
"command": "pip install -r requirements.txt",
"type": "oneshot",
"restart": "never"
},
{
"name": "dev",
"command": "python manage.py runserver 0.0.0.0:8000",
"port": 8000,
"type": "daemon",
"restart": "always",
"dependsOn": ["install"]
}
]
},
"ports": ["8000:8000"],
"docker": {
"extraPackages": ["python3", "python3-pip", "python3-venv"]
}
}The sandbox uses a layered approach to environment variables, ensuring your project's .env files are never modified:
| Layer | Source | Mechanism | Precedence |
|---|---|---|---|
Project .env |
Your repo's .env files |
Bind-mounted into container | Lowest |
| Overrides | envOverrides in config |
Docker Compose environment: block |
Higher (overrides .env) |
| Secrets | ~/.config/.../generated/.env |
Docker Compose env_file: |
Highest |
When you sandbox add a project, the CLI scans all .env* files and looks for localhost or 127.0.0.1 references. These can't work inside a container, so it proposes rewriting them to host.docker.internal:
SUPABASE_URL: http://localhost:54321 → http://host.docker.internal:54321
From: .env (Supabase API)
Accept these overrides? (Y/n)
The overrides are injected via Docker Compose's environment: block, which takes precedence over the bind-mounted .env file. Your original files are never touched.
Secrets (GitHub PAT, API keys) are stored in ~/.config/remote-opencode-sandbox/instances/<name>/generated/.env and loaded via env_file: in the compose config. This file is never committed (it's in your home directory, not in the project).
Instead of (or in addition to) PAT-based HTTPS authentication, you can mount an SSH private key into the container for git operations. This is useful when your GitHub account uses SSH keys for authentication.
During sandbox init, the CLI auto-detects SSH private keys in ~/.ssh/ and lets you select one. You can also configure it manually in ~/.config/remote-opencode-sandbox/config.json:
{
"ssh": {
"keyPath": "~/.ssh/id_ed25519",
"githubUsername": "your-github-username"
}
}When SSH is configured, sandbox build generates the following:
- Volume mounts — Your SSH private key (and public key) are mounted read-only into the container at
/home/coder/.ssh/id_key - SSH config — The entrypoint creates
/home/coder/.ssh/configpointing to the mounted key withIdentitiesOnly yes - known_hosts — GitHub's host keys are fetched via
ssh-keyscanat container startup - Git URL rewriting — All
https://github.com/URLs are rewritten togit@github.com:viagit config url."git@github.com:".insteadOf, so git automatically uses SSH for all GitHub operations
- The SSH private key is mounted read-only — the container cannot modify it
- The key file is only accessible to the
coderuser (UID 1000) StrictHostKeyCheckingis set toaccept-new— new host keys are accepted but changed keys are rejected- The
.sshdirectory is created with700permissions in the Dockerfile
SSH and PATs coexist cleanly with per-project scoping:
- Projects with a PAT — Use HTTPS authentication via
git-credential-store. The PAT always takes precedence. - Projects without a PAT — If SSH is configured, git URLs are rewritten from
https://github.com/togit@github.com:for that project directory only. - Fallback — For repos outside any project directory: uses the default PAT if set, otherwise SSH.
- gh CLI — Always uses the PAT via
GH_TOKENenv var (SSH doesn't affect the GitHub CLI).
This means you can mix authentication methods: some projects use PATs (with fine-grained scoping), others use your SSH key.
Services are defined per-project and orchestrated by a bash process supervisor inside the container.
| Type | Behavior |
|---|---|
| oneshot | Runs once, must exit 0 before dependents start. Used for bun install, pip install, etc. |
| daemon | Long-running, monitored by a watchdog every 15 seconds. Restarted according to its restart policy. |
| Policy | Behavior |
|---|---|
always |
Restart on any exit |
on-failure |
Restart only on non-zero exit code |
never |
Let it stay dead |
If a daemon crashes more than 5 times within 120 seconds, the watchdog backs off for 60 seconds before restarting it. This prevents runaway restart loops.
Services declare dependencies via dependsOn. The supervisor performs a topological sort and starts services in the correct order. Independent services start in parallel.
{
"name": "dev",
"command": "bun run dev",
"type": "daemon",
"dependsOn": ["install"]
}In multi-project setups, service names are automatically namespaced as project:service to avoid collisions. For example, if both frontend and api have an install service, they become frontend:install and api:install.
remote-opencode is always added as the last daemon automatically. It's the Discord bridge that lets you interact with OpenCode from anywhere. You don't need to configure it — it's injected by the entrypoint script.
sandbox build generates these files in ~/.config/remote-opencode-sandbox/instances/<name>/generated/:
| File | Purpose |
|---|---|
Dockerfile |
Container image: Node.js 24, Bun, Chrome, OpenCode, remote-opencode, GitHub CLI |
docker-compose.yml |
Service definition: volumes, ports, env vars, security hardening |
docker-entrypoint.sh |
Process supervisor: dependency resolution, daemon monitoring, watchdog |
opencode.docker.json |
OpenCode config for the container: permission level, MCP servers |
.env |
Secrets: GitHub PAT, git identity (never committed) |
git-credentials/ |
Per-project PAT files + gitconfig routing via includeIf |
All generated files are human-readable and editable. They are regenerated by sandbox build, so manual edits are overwritten unless you skip the build (sandbox up --no-build).
The container is hardened with several layers:
- Non-root user — Everything runs as
coder(UID 1000) no-new-privileges— Prevents privilege escalation- Dropped capabilities — All capabilities dropped, only
CHOWN,DAC_OVERRIDE,FOWNER,SETGID,SETUID(andSYS_ADMINfor Chrome) are added back - tmpfs mounts —
/tmp(512MB) and/run(64MB) are tmpfs - Read-only credentials — Git credentials, SSH keys, and OpenCode config are mounted read-only
- No host network — Container uses
host.docker.internalto reach host services, not--network host - Per-project PATs — Git credentials are routed via
includeIfdirectives, so each project can use a different GitHub PAT with minimal scope
An "instance" is a single Docker container running one or more projects.
# Both projects share a container
sandbox add ~/projects/frontend --template web-supabase
sandbox add ~/projects/backend --template node-basic
sandbox upInside the container:
/workspace/
├── frontend/ # bind-mounted from ~/projects/frontend
│ └── node_modules/ # named Docker volume (not from host)
└── backend/ # bind-mounted from ~/projects/backend
└── node_modules/ # named Docker volume (not from host)
If projects conflict (port collisions, incompatible env vars), create separate instances:
sandbox instance create project-b
sandbox add ~/projects/other-app --template node-basic --instance project-b
sandbox up --instance project-bsandbox setup-autostartThis creates:
- A systemd user service (
~/.config/systemd/user/sandbox.service) that runssandbox upon login - An XDG autostart entry for Docker Desktop (if installed) so Docker starts before the sandbox
On next login: Docker Desktop starts → systemd starts the sandbox → all projects are running.
Make sure host.docker.internal resolves inside the container. The generated docker-compose.yml includes:
extra_hosts:
- "host.docker.internal:host-gateway"If you're using Docker Engine (not Docker Desktop) on Linux, this requires Docker 20.10+.
Each project's node_modules uses a named Docker volume. If you see permission errors, the volume may have been created with the wrong ownership. Fix it:
# Remove the volume and let it be recreated
docker volume rm <project-name>-node-modules
sandbox upCheck which project is using the port:
sandbox statusEither change the port in the project's config or move the conflicting project to a separate instance:
sandbox instance create other
sandbox remove my-project
sandbox add ~/projects/my-project --template node-basic --instance othersandbox down
sandbox build
sandbox upTo fully rebuild the Docker image (no cache):
sandbox down
# Go to the generated directory and rebuild
cd ~/.config/remote-opencode-sandbox/instances/default/generated
docker compose build --no-cache
sandbox up --no-build# Last 100 lines
sandbox logs
# Follow live
sandbox logs -f
# Open a shell to debug
sandbox shell- Create
src/templates/my-template.tsimplementing theTemplateinterface:
import type { Template } from "../types.ts";
import { DEFAULT_BASE_IMAGE } from "../constants.ts";
export const myTemplate: Template = {
name: "my-template",
description: "Description of what this template is for",
docker: {
baseImage: DEFAULT_BASE_IMAGE,
installChrome: true,
installBun: true,
installSupabaseCli: false,
extraPackages: [],
},
services: {
container: [
{
name: "install",
command: "npm install",
type: "oneshot",
restart: "never",
},
],
host: [],
},
envOverrides: {},
envRewriteRules: [
{
pattern: "localhost:(\\d+)",
replace: "host.docker.internal:$1",
description: "Any localhost service",
},
],
mcp: {
"chrome-devtools": {
type: "local",
command: [
"npx", "-y", "chrome-devtools-mcp@latest",
"--chrome-arg=--no-sandbox",
"--chrome-arg=--disable-gpu",
"--chrome-arg=--disable-dev-shm-usage",
"--headless",
],
},
},
ports: [],
permission: "allow",
defaultSecrets: ["GH_TOKEN"],
};- Register it in
src/templates/index.ts:
import { myTemplate } from "./my-template.ts";
const builtinTemplates: Record<string, Template> = {
"web-supabase": webSupabaseTemplate,
"node-basic": nodeBasicTemplate,
"my-template": myTemplate,
};- Submit a PR.
git clone https://github.com/Shenato/remote-opencode-sandbox.git
cd remote-opencode-sandbox
bun install
# Run the CLI in dev mode
bun run dev
# Type-check
bun run typecheckMIT