A browser-based remote dev environment — OpenCode and OpenChamber in one
container, with key-only SSH for shell access (and Mosh for mobile). Runs
under Docker or Kubernetes; kubernetes.yaml is a worked
MicroK8s example (no Helm chart). The Compose and Kubernetes deployments add
two companion containers: headless Chrome for the DevTools MCP, and
code-server for browser VS Code.
OpenCode, OpenChamber, backlog.md, GitHub CLI, uv, Node 26, Python 3.14, PHP 8.5 (CLI) with Composer, git, tmux, neovim, nano, ripgrep, fd, jq, yq, direnv, mosh. code-server and Chrome are not baked into this image — they run as separate companion containers in the Compose and Kubernetes deployments.
There is no C toolchain — build-essential and python3-dev are left out to
keep the image small, and prebuilt wheels and npm prebuilds cover normal use. If
you need to compile something:
sudo apt-get update && sudo apt-get install -y build-essential python3-devdocker run -d --name opencode \
-p 3000:3000 -p 2222:2222 -p 60000:60000/udp \
-e OPENCHAMBER_PASSWORD='<ui-password>' \
-e GIT_USER_NAME='Your Name' \
-e GIT_USER_EMAIL='you@example.com' \
-v "${PWD}:/workspace" \
-v "${HOME}/.ssh:/home/dev/.ssh:ro" \
-v opencode-host-keys:/etc/ssh/host-keys \
ghcr.io/stephen-cox/opencode-container:latest- SSH:
ssh -p 2222 dev@localhost— public key in your~/.ssh/authorized_keys. - Mosh (optional, mobile):
mosh -p 60000 --ssh="ssh -p 2222" dev@localhost. - OpenChamber: http://localhost:3000.
The Compose deployment adds Chrome for Testing as a hardened, headless sidecar
while OpenCode runs the chrome-devtools-mcp process locally over stdio:
export OPENCHAMBER_PASSWORD='<ui-password>'
export SSH_AUTHORIZED_KEYS_DIR="${HOME}/.ssh"
export GIT_USER_NAME='Your Name'
export GIT_USER_EMAIL='you@example.com'
# Optional: export CONTEXT7_API_KEY='<context7-api-key>'
export WORKSPACE="${PWD}"
docker compose up -d --buildThe two containers share a network namespace. Chrome therefore binds its DevTools
endpoint only to 127.0.0.1:9222; that port is not published to the host. Confirm
the browser and MCP server from the OpenCode container:
docker compose exec opencode \
curl -fsS http://127.0.0.1:9222/json/version
docker compose exec opencode opencode mcp listThen ask OpenCode: Use chrome-devtools to open https://developers.chrome.com and take a snapshot. The Chrome profile is ephemeral and is discarded whenever
the sidecar is recreated. Stop the deployment with docker compose down; add
--volumes only if you also want to remove the persisted OpenCode and
OpenChamber state.
The MCP configuration is injected as an additional config through
OPENCODE_CONFIG, so it merges with rather than replaces the user's config in
/home/dev/.config/opencode. OpenCode loads configuration only at startup;
restart the deployment after changing config/opencode.json.
The Compose and Kubernetes deployments also register the hosted Context7 MCP at
https://mcp.context7.com/mcp. It provides current, version-specific library
documentation. Ask OpenCode to use context7 when a prompt needs library or API
documentation.
CONTEXT7_API_KEY is optional. Without it, Context7 uses its anonymous rate
limit. For higher limits, create a key in the
Context7 dashboard and export it before
starting Compose as shown above. The key is passed at runtime and is not stored
in the image or OpenCode configuration.
For Kubernetes, add CONTEXT7_API_KEY to the openchamber-secrets Secret in
kubernetes.yaml, or manage that key with your normal Secret tooling. Omit the
key to use anonymous access. Restart the Compose deployment or roll out the
Kubernetes Deployment after adding or changing the key, then verify the server:
docker compose exec opencode opencode mcp list
kubectl -n openchamber exec deployment/openchamber -c openchamber -- \
opencode mcp listThe Compose and Kubernetes examples automatically load a curated environment description into OpenCode's agent context, including sessions opened through OpenChamber. No custom agent, plugin, or runtime software scan is required.
config/environment.mdis the single maintained software inventory and general container description. It is baked into the image at/etc/opencode/environment.md. Update it whenever the Dockerfile adds, removes, or changes a deliberately targeted software version. It is not a live inventory; agents should check exact versions when relevant.config/opencode.jsonloads that file through OpenCode'sinstructionssetting for Compose. Kubernetes includes the same setting in its existingchrome-devtools-mcpConfigMap, plus akubernetes.mdkey mounted at/etc/opencode/chrome-devtools/kubernetes.md. That second document describes host networking, persistent mounts, and the companion-container boundaries. Update it when adapting the Kubernetes example to a different topology.- These instructions are additive to personal and project
AGENTS.mdrules; nothing is written into the mounted/home/dev/.config/opencodedirectory. They describe capabilities, not permission to perform privileged operations. Do not put secrets or environment-variable dumps in either document.
For a bare docker run or a custom deployment, the inventory is present in the
image but must be referenced by your OpenCode configuration:
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["/etc/opencode/environment.md"]
}Merge this field into your existing configuration rather than replacing it.
Changes to the inventory require a rebuilt image; changes to configuration
require restarting OpenCode. For Compose, rebuild and recreate with
docker compose up -d --build using the environment from the quick start.
For Kubernetes, first publish an image containing the updated inventory and ensure your adapted manifest references it. Then run these commands from an operator's machine with cluster access, not from the development container:
kubectl apply -f kubernetes.yaml
kubectl -n openchamber rollout restart deployment/openchamber
kubectl -n openchamber rollout status deployment/openchamber
kubectl -n openchamber exec deployment/openchamber -c openchamber -- \
test -r /etc/opencode/environment.md
kubectl -n openchamber exec deployment/openchamber -c openchamber -- \
test -r /etc/opencode/chrome-devtools/kubernetes.mdThe explicit restart also reloads ConfigMap-only changes. Open a fresh session in OpenChamber and ask: "Using your supplied environment instructions, summarize where your tools run, which software is installed, what persists, and whether Chrome and code-server share your filesystem and network." Confirm the answer matches the two documents; file readability alone does not prove prompt loading.
| Port | Process | Authentication | Expose externally? |
|---|---|---|---|
3000 |
OpenChamber UI | OPENCHAMBER_PASSWORD |
Yes |
2222/tcp |
sshd | Public key only, dev user |
Yes — SSH encrypts its own traffic |
8080 |
code-server | Password login (CODE_SERVER_PASSWORD) |
Yes, over TLS |
4096 |
OpenCode API | None | No — trusted LAN or authenticating proxy |
60000/udp |
mosh-server | Via SSH, then session keys | Optional — only if you use Mosh |
A fourth process, ssh-agent, listens on /home/dev/.ssh-agent.sock.
SSH_AUTH_SOCK is set for every shell, so run ssh-add once and Git over SSH
works for the life of the container. start.sh exits if any of the four dies.
OpenChamber reaches OpenCode over loopback, so port 4096 only needs publishing
if you want the API itself.
sshd is configured key-only (PasswordAuthentication no,
KbdInteractiveAuthentication no, PermitRootLogin no, AllowUsers dev);
the public keys live in /home/dev/.ssh/authorized_keys, typically delivered
by mounting your ~/.ssh read-only. Startup fails closed if that file is
missing or empty.
A bind mount keeps the host's uid on that file, and sshd's StrictModes
rejects key files owned by neither dev nor root — which would lock out
anyone whose host uid isn't 1000 (CI runners use 1001, macOS 501). start.sh
therefore copies the delivered keys to a root-owned path on every boot, and
AuthorizedKeysFile lists that copy first; ~/.ssh/authorized_keys stays a
live second source for keys you add interactively inside the container.
Every interactive login attaches to one shared tmux session (main, in
/workspace, via /etc/profile.d/tmux-attach.sh). Dropping a connection
leaves work running and reconnecting reattaches to it; a second device joins
the same session, so run tmux new -s other for an independent one. A
system-wide /etc/tmux.conf enables mouse mode (touch scrolling in mobile SSH
apps) and a 50000-line history; your own ~/.tmux.conf still overrides it.
ClientAliveInterval 30 keeps NAT mappings warm and reaps dead clients, which
matters on mobile networks.
Mosh survives Wi-Fi↔cellular roaming and gives instant local echo on lossy links. It authenticates through SSH and then moves to UDP — by default the server picks a port from 60000–61000; pin it so your firewall only needs one hole:
mosh -p 60000 --ssh="ssh -p 2222" dev@example.lanRun mosh inside tmux if you want scrollback — mosh itself only redraws the visible screen.
Two ways to push from the container, both already wired up:
- In-container agent: run
ssh-addonce inside the container; every shell seesSSH_AUTH_SOCKand Git over SSH just works. - Agent forwarding: connect with
ssh -A(orForwardAgent yesin your client config) and no key material is ever stored in the container. Only forward to hosts you trust — a rooted container could otherwise use your agent socket while you are connected.
Host opencode
HostName example.lan
Port 2222
User dev
# Reuse one connection for scp/rsync/subsequent shells — no repeat handshakes
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
# Forward your local agent instead of keeping keys in the container
ForwardAgent yesVS Code Remote-SSH and JetBrains Gateway also work against this container.
~/.vscode-server lands under /home/dev, so keep that path on a volume if
you use them and want updates to survive restarts.
The Compose and Kubernetes deployments also run
code-server — VS Code in the browser —
as a companion container using the official codercom/code-server image. It
is deliberately in its own container and network namespace (unlike the
Chrome sidecar, which shares the main container's network): VS Code's Ports
panel can then only forward ports bound inside the code-server container, so
Chrome's DevTools endpoint and the OpenCode API stay outside its automatic
reach. On Kubernetes the openchamber pod runs hostNetwork for sshd, which
makes co-locating the two especially costly — a shared pod would put
node-bound ports directly on VS Code's radar. Do not "simplify" this into
network_mode: service:opencode or a pod sidecar.
It is not supervised by start.sh: it restarts independently, and a
code-server crash never restarts OpenCode or sshd.
- It opens
/workspace, mounted from the same place as the main container. The image'scoderuser is UID 1000, same asdev, so files keep consistent ownership. The Kubernetes example shares the workspace through its single-node hostPath; multi-node clusters need RWX storage, where VS Code's file watcher also loses inotify events and falls back to polling. - Your SSH key directory is mounted read-only (the same directory sshd reads
for
authorized_keys), so git over SSH works from VS Code terminals. Extensions come from Open VSX, not the Microsoft marketplace, so proprietary extensions are unavailable. - VS Code terminals run inside the code-server container — Debian with git,
git-lfs, curl and not much else. They are not the main container's
environment (no
gh,uv, Node 26, and they cannot attach to the shared tmux session); SSH in for those. - Log in at
http://localhost:8080(Compose) orcode-server.example.lan(Kubernetes) withCODE_SERVER_PASSWORD. - The image floats:
latestwithpull_policy: alwaysin Compose andimagePullPolicy: Alwaysin Kubernetes, so the nextdocker compose upor pod restart picks up new releases.
| Variable | Required | Default | Purpose |
|---|---|---|---|
OPENCHAMBER_PASSWORD |
Yes | — | OpenChamber UI password. |
SSHD_PORT |
No | 2222 |
sshd listen port. |
CODE_SERVER_PORT |
No | 8080 |
Host port for code-server (Compose). |
CODE_SERVER_PASSWORD |
No | OPENCHAMBER_PASSWORD |
code-server login password (Compose; the Kubernetes Secret key has the same name). |
OPENCHAMBER_PORT |
No | 3000 |
OpenChamber listen port. |
OPENCODE_PORT |
No | 4096 |
OpenCode listen port. |
OPENCODE_HOSTNAME |
No | 0.0.0.0 |
OpenCode bind address. |
OPENCODE_READY_TIMEOUT |
No | 30 |
Seconds to wait for OpenCode. |
GIT_USER_NAME |
No | — | Global Git commit author name. |
GIT_USER_EMAIL |
No | — | Global Git commit author email. |
GITHUB_TOKEN |
No | — | Passed through for gh. |
CONTEXT7_API_KEY |
No | — | Higher Context7 MCP rate limits. |
Startup fails if OPENCHAMBER_PASSWORD is unset, or if
/home/dev/.ssh/authorized_keys is missing or empty — the container refuses
to start an sshd nobody can log into, or an OpenChamber anyone can reach.
Supply the password from Kubernetes Secrets, not the image. In Compose,
SSHD_PORT and MOSH_PORT also set the host-side published ports, and
SSH_AUTHORIZED_KEYS_DIR (default ${HOME}/.ssh) is the directory mounted
read-only at /home/dev/.ssh.
Replace the manifest's placeholders — USER (hostPath owner), example.lan
(Ingress DNS suffix), CHANGEME (Secret and Git identity values) — then:
kubectl apply -f kubernetes.yamlThat creates a namespace, Secret, two ConfigMaps, two PVCs, two Deployments,
one NodePort Service, one ClusterIP Service and three Ingresses. The
openchamber Deployment includes the same headless Chrome sidecar as the
Compose setup; the code-server Deployment runs browser VS Code in its own pod
and network (see Code editor). Chrome has its own
readiness and liveness probes
on its pod-local DevTools endpoint; port 9222 is not included in a Service or
Ingress. One of the existing Ingresses publishes OpenCode's unauthenticated API
— delete it unless you want port 4096 reachable from the LAN. The image's
Docker HEALTHCHECK is ignored by Kubernetes, which is why the manifest defines
its own probes.
The pod runs with hostNetwork so sshd binds the node's :2222 directly —
SSH is plain TCP and cannot go through the HTTP Ingress. Connect with
ssh -p 2222 dev@<node> (or mosh -p 60000 --ssh="ssh -p 2222" dev@<node>;
the UDP port also binds on the node). Make sure nothing else on the host
already binds 2222, 3000 or 4096, and restrict node-port access at the
firewall — SSH is key-only, but there is no rate limiting in front of it.
After applying the manifest, confirm Chrome is reachable only from the companion OpenCode container:
kubectl -n openchamber rollout status deployment/openchamber
kubectl -n openchamber rollout status deployment/code-server
kubectl -n openchamber exec deployment/openchamber -c openchamber -- \
curl -fsS http://127.0.0.1:9222/json/version
kubectl -n openchamber exec deployment/openchamber -c openchamber -- \
opencode mcp listThe Ingress hosts route plain HTTP with long timeouts and buffering off (WebSocket-friendly defaults for OpenChamber, OpenCode and code-server); nginx handles any upgrade handshake itself.
Mount these to survive rescheduling:
| Path | Purpose |
|---|---|
/workspace |
Repositories and working files. |
/home/dev/.config/opencode |
OpenCode configuration. |
/home/dev/.local/share/opencode |
OpenCode sessions and data. |
/home/dev/.local/state/opencode |
OpenCode runtime state. |
/home/dev/.config/openchamber |
OpenChamber configuration. |
/home/dev/.ssh |
authorized_keys + Git keys — read-only. |
/home/dev/.ssh-state |
known_hosts. |
/etc/ssh/host-keys |
sshd host key — stable fingerprints. |
/home/dev/.config/gh |
GitHub CLI auth (not in the example). |
/home/coder/.local/share/code-server |
code-server data and extensions — own container (Compose volume code-server-data, PVC code-server-pvc). |
Use Secrets for keys and tokens, PVCs for the rest.
- Shell access is root-equivalent:
devhas passwordless sudo. A stolen private key with a line inauthorized_keysowns the container, so treat that file as root-equivalent too — mount it read-only, as the examples do. - sshd is configured key-only:
PasswordAuthentication no,KbdInteractiveAuthentication no,PermitRootLogin no,AllowUsers dev,MaxAuthTries 3,LoginGraceTime 30. There is still no rate limiting or lockout in front of it — on an untrusted network, keep2222behind a firewall or reach the pod withkubectl port-forward. - The sshd host key lives in
/etc/ssh/host-keys. Persist it (the examples do) so client fingerprints stay stable across restarts; an unpersisted key regenerates on every container recreation and triggers host-key-mismatch warnings. - Mosh derives its session key from the SSH exchange and encrypts all traffic (AES-128 OCB), but it does not encrypt host keystrokes you type before the mosh session starts, and a roaming client trusts the network it lands on for UDP delivery. Keep the SSH hop on a trusted path.
- Agent forwarding (
ssh -A) exposes your agent socket to the container for as long as you are connected. Only forward to containers you trust. - OpenCode's port
4096has no authentication of any kind. - Chrome's DevTools endpoint grants complete control of the browser. Never add
port
9222to Docker port publishing, a Kubernetes Service, or an Ingress. - Do not use the automated Chrome profile for sensitive personal browsing or accounts. Browser content, cookies and credentials are available to the MCP client. The supplied configuration disables usage statistics and CrUX lookups and redacts sensitive network headers returned by MCP tools.
- Chrome runs with
--no-sandboxinside a dedicated non-root sidecar with all Linux capabilities dropped, no privilege escalation and a read-only root filesystem. Keep those controls together; do not reuse the sidecar as a general-purpose browser service. - code-server runs in its own container and network namespace on purpose: VS Code's Ports panel can only forward ports inside that container, which keeps the OpenCode API and Chrome's DevTools endpoint outside its automatic forwarding. Keep it that way.
- A code-server login is a shell in the code-server container (which has
passwordless sudo there) plus read-only access to your
~/.ssh. It is not root in the main container, but the SSH keys alone justify a long password and TLS. - The Kubernetes code-server pod is hardened less than the Chrome sidecar —
the image's
fixuidsetuid helper conflicts with no-new-privileges hardening. It is still non-root, seccomp-confined, and code-server rate-limits password attempts. - Never bake tokens or keys into the image; pass them at runtime.
- Requires an AVX2-capable x86-64 CPU (Haswell, 2013 or later).
The remote --browser-url connection supports normal navigation, debugging,
network and performance tools. Features that require a direct browser pipe,
including some extension and PWA operations, are not available in this mode.
Chrome requests 512Mi memory and is limited to 2Gi in the example Kubernetes
manifest; tune those values for the pages and traces you run.
docker build -t opencode-remote:latest .Only start.sh and config/environment.md are copied in; .dockerignore
excludes the rest. CI checks that the packaged inventory is readable by the
image's default user and exactly matches the maintained source document.
.github/workflows/publish.yml pushes to
ghcr.io/stephen-cox/opencode-container.
| Tag | Written by |
|---|---|
latest |
pushes to main, weekly |
sha-abc1234 |
every build |
YYYYMMDD |
weekly rebuild — use to roll back a bad week |
The weekly run (Mondays 04:17 UTC) builds with no-cache and pull, which is
what picks up new Ubuntu patches (including openssh-server and mosh
security updates) and new opencode-ai, @openchamber/web and backlog.md
releases; a cached rebuild would change nothing. Every build publishes SBOM
and provenance attestations.
Two things to know:
- New GHCR packages are private. Make the package public after the first push, or nobody else can pull it.
- GitHub disables scheduled workflows after 60 days of repository inactivity, so a silent weekly build may mean the schedule is off, not passing.
Builds are linux/amd64 only. For arm64, build on a native arm runner and merge
the manifests — QEMU emulation is painfully slow for this image.