opencode
opencode served as a browser UI by opencode web, which
starts the headless server and its web interface together. The agent runs in
this container — there is no sandbox layer between it and the filesystem.
Built from a local Dockerfile, because the official image is the opencode
binary on bare Alpine and nothing else: no shell, no git, no curl, no
ssh. An agent whose main tool is "run a command" has nothing to run, so one
apk layer puts them back.
Setup
For OpenCode Go (or Zen), set OPENCODE_API_KEY to the key from the OpenCode
Console and skip the login below. opencode picks the key up from the
environment for both the opencode-go and opencode providers — models.dev,
where opencode gets its provider list, names OPENCODE_API_KEY as their
variable — so no auth.json entry or config file is needed.
Any other provider needs a login, the one step the UI cannot do. After the first deploy, from a shell:
docker compose exec opencode opencode auth login
It is interactive, which is why it is not an environment variable; exec
gives it the TTY it needs. The credentials land on the opencode-home volume
and survive a redeploy.
Then open the domain and sign in with OPENCODE_SERVER_USERNAME and
OPENCODE_SERVER_PASSWORD.
The container logs a Bun stack trace ending in
Executable not found in $PATH: "xdg-open" on every start. opencode web
tries to open the UI in a local browser; there isn't one. It is noise — the
server is already listening by then, and the container keeps running.
Installing xdg-utils would silence it at the cost of pulling X11 in and
tripling the image, which is not worth it for a log line.
Authentication
OPENCODE_SERVER_PASSWORD is the only thing between the domain and a shell on
this container. opencode's own words: "If OPENCODE_SERVER_PASSWORD is not
set, the server will be unsecured." Unset, every request is served — and every
request can ask the agent to run a command. Treat a blank value as publishing a
root terminal.
Environment
| Variable | Purpose |
|---|---|
OPENCODE_SERVER_PASSWORD |
Web UI and API login. Blank means no authentication at all. Generate with openssl rand -base64 24. |
OPENCODE_SERVER_USERNAME |
Username to go with it. opencode falls back to opencode. |
OPENCODE_API_KEY |
OpenCode Go / Zen API key. Optional; blank means log in instead. |
GIT_NAME / GIT_EMAIL |
Git author and committer identity for the agent's commits. |
Other model provider credentials are not variables — see Setup.
Storage
| Volume | Mount | Holds |
|---|---|---|
opencode-home |
/root |
Provider credentials, config, session database, caches |
opencode-workspace |
/workspace |
Code the agent works on |
The whole home directory is one volume because opencode spreads its state over
four places under it — .config/opencode, .local/share/opencode (the SQLite
session database), .local/state/opencode and .cache/opencode — and mounting
them separately would only be three more chances to miss one. Dotfiles and
anything else installed into $HOME persist as a side effect.
The container runs as root, which is what the upstream image does; $HOME is
/root because of it.
WORKDIR /workspace in the Dockerfile is what makes the agent start in the
workspace volume.
Networking
Listens on 4096; point the domain at it.
--hostname 0.0.0.0 in command: is what makes the service reachable at all;
both web and serve bind 127.0.0.1 by default. --port 4096 is there
because the default is 0, a random port, which the platform cannot map a
domain to.
If the UI is loaded from a different origin than it is served from, add
--cors <url> to command:. The default setup does not need it.
Image
FROM ghcr.io/anomalyco/opencode:latest, the vendor image. The opencode repo
moved out of the sst organisation; ghcr.io/sst/opencode still exists but no
longer serves anonymous pulls, so it is not the one to use.
The apk layer adds bash, git, curl and openssh-client — the floor for
an agent that clones, commits and fetches. It carries no language toolchain:
none is wanted often enough to justify rebuilding the image for everybody, and
apk add from the agent's own terminal covers a one-off — but /usr is not on
a volume, so it is gone on the next deploy. Something needed every time belongs
in the Dockerfile.
ENTRYPOINT stays the image's own opencode, so command: in compose.yml
is just the subcommand and its flags.