9.6 KiB
OpenCode Sandbox
A rootless Podman container for running OpenCode with a pinned toolchain — Java, Node, pnpm, git — without touching your host machine's environment.
What this gives you
- A hash-pinned, integrity-verified build of OpenCode (not "latest" — an exact version, checked against a SHA256 captured at build time).
- JDK 25 (LTS), Node 24 (LTS), Go, Rust, pnpm, git, curl, openssh-client.
- Language servers for Go, Rust, TypeScript/JavaScript, Java, and Kotlin, ready for OpenCode's built-in LSP client (see LSP support below).
- The current directory mounted read-write as
/workspace. - OpenCode's config, data, and session state shared across every workdir/container, so logging in once carries over everywhere.
- Every container joined to a shared Podman network so it can reach other dev containers by name.
Requirements
- Podman (rootless).
- On macOS: a running
podman machine—makewill start a stopped one automatically, but if you've never run Podman before you need to initialize one first:podman machine init - GNU Make (macOS's bundled Make 3.81 works fine; the Makefile deliberately avoids features added in 3.82+).
Usage
make build # build (or rebuild) the container image
make opencode # launch the OpenCode TUI in the current directory
make shell # open a bash shell in the container instead
make install # build, then install a global `opencode-sandbox` shortcut (see below)
Run make opencode or make shell from any project directory — the image is shared, so there's nothing per-project to set up.
make install
Installs a shim at ~/.local/bin/opencode-sandbox that runs make opencode
against this repo's Makefile, using whatever directory you're standing in —
not this repo — as /workspace. Once installed, opencode-sandbox works as
a global command from any directory, no cd into this repo required. (Only
opencode gets a shortcut; make shell still has to be run from here.)
It also makes sure ~/.local/bin is on PATH for future shells, based on
your login shell ($SHELL):
- zsh → appended to
~/.zshrc(or~/.zshenvif that's the only one of the two that already exists;~/.zshrcis created if neither does) - bash →
~/.bash_profileon macOS,~/.bashrcon Linux (created if missing) - fish → added via
fish_add_path(no config file edit needed) - anything else → appended to
~/.profile
The edit is wrapped in # >>> opencode-sandbox >>> markers so re-running
make install never duplicates it, and is skipped entirely if
~/.local/bin is already on PATH.
LSP support
OpenCode's built-in LSP client is off by default — turn it on per-project with "lsp": true in that project's opencode.json (or configure individual servers there; see OpenCode's own docs for the object form). Once enabled, this image is ready for it:
| Language | Server | How it gets there |
|---|---|---|
| Go | gopls |
Built from source at image-build time (go install), pinned via GOPLS_VERSION in the Containerfile. |
| Rust | rust-analyzer |
Installed as a rustup component at image-build time — OpenCode has no auto-download fallback for this one, so it has to already be on PATH. |
| TypeScript / JavaScript | typescript + typescript-language-server |
Installed as global npm packages, pinned via TYPESCRIPT_VERSION/TS_LS_VERSION. Pinned to TypeScript's last JavaScript-based release (6.0.x) — TypeScript 7 is a native Go rewrite that dropped tsserver.js, which typescript-language-server needs to attach to. NODE_PATH is set so the global typescript package resolves even from a project with no local node_modules. |
| Java | jdtls |
Pre-baked — OpenCode has no auto-download fallback for Java at all, so this has to be on PATH. Fetched straight from Eclipse's own download server (not GitHub Releases; architecture-independent, pure JVM). No bin/jdtls ships upstream, so scripts/jdtls is a hand-written launcher wrapper — the same convention every other jdtls consumer (nvim-jdtls, mason.nvim, coc-java) uses — which also manages a persistent, per-project -data workspace dir under OpenCode's host-persisted data dir. |
| Kotlin | kotlin-lsp |
Pre-baked — JetBrains' official server (github.com/Kotlin/kotlin-lsp), fetched per-arch and hash-verified by hand (JetBrains publishes no checksum). Ships its own bundled JetBrains Runtime, so it needs none of this image's JDK. Symlinked as both kotlin-lsp and kotlin-language-server, since it's unconfirmed which name OpenCode's Kotlin integration expects. |
Bumping a pinned LSP version means editing the corresponding ARG/FROM tag in the Containerfile and rebuilding (make build).
How it's put together
Containerfile — multi-stage build on debian:trixie-slim:
- JDK and Node are copied in from their official upstream images (
eclipse-temurin:25-jdk-noble,node:24-trixie-slim) rather than apt-installed, keeping the final image slim while staying on a mainstream glibc distro (Alpine/musl is avoided — it breaks JVM and native Node addon compatibility). - pnpm is installed via its own standalone installer script, pinned to a specific version.
- OpenCode is downloaded directly from its canonical GitHub releases (
github.com/anomalyco/opencode) for the container's architecture, and its SHA256 is checked against a hash pinned in the Containerfile before it's installed — there's no maintainer-published signature for OpenCode releases, so this hash is captured by hand and is the actual root of trust here. BumpOPENCODE_VERSIONand re-run thesha256sumsteps documented in the Containerfile's comments whenever you upgrade it. - A few build-time smoke tests (
ldd,java -version,node --version, etc.) fail the build immediately if anything is broken, rather than surfacing at firstmake opencode.
Makefile — four targets:
-
buildbuilds the image. -
opencodeandshellboth run the container with the same flags:--userns=keep-id(so files created in the container are owned by your real host user, not root — verified: rootless Podman's user-namespace remapping meansidshowsrootinside the container's own namespace view, but the file lands on the host owned by your actual UID/GID), the current directory bind-mounted to/workspace, and OpenCode's three XDG directories bind-mounted from your home directory:Host path Container path Contents ~/.config/opencode/home/dev/.config/opencodesettings, plugins, themes ~/.local/share/opencode/home/dev/.local/share/opencodeauth, database, snapshots ~/.local/state/opencode/home/dev/.local/state/opencodesession/model history, locks Because these are real host paths shared by every container regardless of workdir, logging into OpenCode once carries over to every project you use the sandbox in.
-
Both run targets join a shared, auto-created Podman network (
opencode-sandbox-net) under a--network-aliasderived from the current directory's name, so a container started from another dev-container setup on the same network can reach this one by that name (and vice versa). -
Both run targets also cap the container to
--memory=3072m --memory-swap=3072m --cpus=4. Without this, a heavy JVM build tool (Gradle's daemon plus its separate Kotlin-compiler daemon are the worst offenders — both auto-size their heap off "available" memory with no idea they're sharing a fixed-size VM) can consume the entirepodman machine's RAM. That starves the VM's own guest kernel, which stalls the processes Podman itself needs to stay responsive — so the whole machine hangs, not just the container. The cap forces a contained OOM kill instead. Sized to leave headroom below the VM's own allocation for the VM/podman/gvproxy itself — adjust both together if you resize the machine (podman machine set --memory <MB> --cpus <N> podman-machine-default, machine must be stopped first). TheContainerfilealso sets a defaultGRADLE_OPTS=-Xmx1024mso the Gradle daemon itself doesn't try to claim the whole container ceiling; a project whose Kotlin-compiler daemon also needs bounding should setkotlin.daemon.jvmargsin its owngradle.properties. These defaults can be overridden per-invocation by exportingOPENCODE_SANDBOX_MEMORY,OPENCODE_SANDBOX_MEMORY_SWAP, and/orOPENCODE_SANDBOX_CPUSbefore runningmake opencode/make shell(e.g.OPENCODE_SANDBOX_MEMORY=4096m OPENCODE_SANDBOX_CPUS=6 make shell).
macOS-specific notes
- Podman on macOS runs everything inside a Linux VM (
podman machine, backendlibkrun/applehv, virtiofs file sharing). The Makefile detects this and starts a stopped machine automatically beforebuild,opencode, orshell. This machine has a fixed CPU/memory allocation (podman machine inspect) — bump it (see above) if JVM-heavy builds are still tight even with the container's own cap. - SELinux volume-relabeling suffixes (
:Z/:z) are intentionally omitted — macOS's host filesystem has no SELinux concept, so they'd be a no-op. - Known virtiofs limitation: inotify/file-watch events from the host don't propagate into the container. Not a blocker for normal use, just worth knowing if a tool inside the container tries to watch
/workspacefor live host-side changes.
Connecting to other dev containers
Any other container you attach to the opencode-sandbox-net network (podman network connect opencode-sandbox-net <container>, or by starting it with --network opencode-sandbox-net) can resolve and reach this sandbox by its workdir-derived alias, and vice versa.