A containerized OpenCode web server: node:trixie-slim plus a curated toolchain (Homebrew, Docker CLI, Git, OpenTofu, gh, fj, kubectl, sops, age, Go, Java, build tools), running opencode web as a non-root user.
Built for self-hosters who want a persistent, browser-accessible OpenCode environment with the tooling an agent needs to do real infrastructure work.
git clone https://github.com/Besi97/opencode-web
cd opencode-web
cp .env.example .env # set GITHUB_TOKEN (and anything else you use)
docker compose --profile dind up -dOpen http://127.0.0.1:4096.
Security: the OpenCode web UI has no authentication. The default compose binds it to
127.0.0.1only. To reach it remotely, put it behind an authenticating reverse proxy (or VPN) — do not expose the port directly.
| Path | Purpose |
|---|---|
/home/node/projects |
Your repos / working files (volume, see below) |
/home/node/.config/opencode |
OpenCode config (volume + read-only mounts from this repo) |
./opencode.jsonc |
Global OpenCode config, mounted read-only |
./instructions/*.md |
Global agent instructions, mounted read-only |
./agents/, ./skills/ |
Your custom agents and skills, mounted read-only |
State defaults to named volumes (opencode-projects, -config, -share, -cache). To keep projects in a host directory instead:
mkdir -p projects && sudo chown 1000:1000 projects
echo 'OPENCODE_PROJECTS_VOLUME=./projects' >> .envThe entrypoint configures credentials automatically at startup:
GITHUB_TOKENset → runsgh auth setup-git(git push/pull against GitHub works out of the box)FORGEJO_TOKEN+FORGEJO_URL+FORGEJO_USERset → configures thefjCLI and a git credential helper for that host
Set GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL (and committer equivalents) so agent commits are attributed correctly.
Point GPG_SIGNING_KEY_PATH at a private key mounted into the container. At startup the entrypoint imports it (gpg --batch --import) and sets user.signingkey plus commit.gpgsign=true globally, so every agent commit is signed. Unset, or unreadable, and the container just starts unsigned.
# On the host: export the secret key. A subkey-only export (--export-secret-subkeys)
# is fine: gpg resolves the signing subkey itself, so the stubbed primary never needs
# its secret material in the container.
gpg --armor --export-secret-subkeys <key-id> > gpg_signing_key.asc
chown 1000:1000 gpg_signing_key.asc && chmod 600 gpg_signing_key.asc
echo 'GPG_SIGNING_KEY_PATH=/run/secrets/gpg_signing_key' >> .env
# and uncomment the ./gpg_signing_key.asc volume line in docker-compose.yamlPassphrase-protected keys work too. Set GPG_SIGNING_KEY_PASSPHRASE and the entrypoint points git at gpg-loopback.sh, which hands the passphrase to gpg on a pipe (--passphrase-fd 3) rather than prompting or putting it on argv, where every process in the container could read it. The key imports fine without the passphrase - it only fails later, at signing time, with signing failed: Inappropriate ioctl for device, so that error means the variable is missing or wrong. It remains visible in docker inspect and to anything the agent runs, which is the usual argument for a dedicated signing key rather than your personal one.
Match the identity. Forges show a commit as Verified only when the key's UID email matches the commit author. GIT_AUTHOR_EMAIL/GIT_COMMITTER_EMAIL are env-driven and override git config, so set them to the same address the key was made for.
~/.gnupg is not a volume, so the key is re-imported on each start (re-importing the same key is a no-op) and signing works in every repository the agent touches.
The container ships the Docker CLI but no daemon. Two options:
- Sidecar (default): run with
--profile dind— adocker:dindservice is reachable attcp://docker-in-docker:2375(unencrypted; it stays on the compose network). - Mounted socket: set
DOCKER_HOST=(empty) in.envand add a volume for your host socket.
This repo intentionally ships agents/ and skills/ empty. Put your own content there (any layout OpenCode accepts), or sync it from wherever you manage it — a git submodule, a CI/deploy-time fetch step, manual copies. The container reads them at startup; it does not care how they got there.
opencode.jsonc includes a disabled remote MCP entry for Stitch. Set STITCH_API_KEY in .env and flip "enabled": true to activate it.
All variables are documented in .env.example.
Published to ghcr.io/besi97/opencode-web (public, no login needed). Tags mirror the bundled OpenCode version:
| Tag | Meaning |
|---|---|
1.18.27 |
Built with OpenCode 1.18.27 (published automatically when the pin lands on main; never moves after publication) |
v1.18.27 |
Same, with v prefix |
latest |
Tip of main |
sha-<full-commit> |
Immutable per-commit build |
1.18.27-rev1 |
Rebuild of the same OpenCode version (new toolchain, same pin). CI creates v1.18.27-rev1 git tags and matching image tags automatically when a main merge doesn't change the pin; you can also push such a tag manually |
Multi-arch: linux/amd64, linux/arm64 (arm64 is best-effort via emulation).
The OpenCode version is pinned in the Dockerfile (ARG OPENCODE_VERSION), so builds are reproducible and every image tag truthfully names what is inside:
docker run --rm ghcr.io/besi97/opencode-web:latest --version # prints the pinned version
cat /etc/opencode-web-versions # inside the container
# or: docker inspect → label dev.opencode-web.opencode-versionKeeping things current:
- OpenCode — update PRs bump the pinned ARG; merging to
mainpublishes a new:<version>tag automatically (the workflow creates the matching git release tag). Merges that don't change the pin (e.g. base image updates) publish:<version>-rev<N>instead, so every version tag keeps describing exactly one image. - Base image (
node:trixie-slim) and CI actions — Dependabot. - Other brew tools (
gh,kubectl,sops, ...) — resolved by Homebrew at build time;latesttracks them, version tags do not promise anything about them.
Build locally:
docker build -t opencode-web .Dockerfile # image definition
entrypoint.sh # git auth + optional commit signing, then exec opencode
forgejo-credential-helper.sh # git credential helper (env-var driven)
gpg-loopback.sh # git gpg.program wrapper for passphrase-protected keys
docker-compose.yaml # reference deployment
opencode.jsonc # default global config (mounted read-only)
instructions/ # global agent instructions (mounted read-only)
agents/ skills/ # your customizations (mounted read-only)
.github/dependabot.yml # base image + CI action updates
.github/workflows/build.yml # PR smoke build (never pushes)
.github/workflows/publish.yml # build + push to ghcr.io, version-mirrored tags
MIT