feat: add openhands and opencode-web services

openhands runs each agent session in a container it spawns through the host
docker socket, so the socket is mounted read-write and host.docker.internal is
resolved. No host workspace is exposed.

opencode-web serves the opencode agent as a browser UI. The vendor image ships
only the opencode binary on bare Alpine, so a local Dockerfile adds bash, git,
curl and an ssh client.
This commit is contained in:
tiennm99 committed 2026-09-18 17:04:04 +07:00
1 parent 3574c55e0a
commit 99f4f8b820
7 files changed
+262

No files matched your search

+2
View File
@@ -60,6 +60,8 @@ Each links to its own README for variables, ports, and storage.
| [gitea-mirror-local](gitea-mirror-local/README.md) | Gitea + PostgreSQL + gitea-mirror, mirroring GitHub repos |
| [netdata](netdata/README.md) | Netdata monitoring agent |
| [ollama](ollama/README.md) | Ollama LLM server |
| [opencode-web](opencode-web/README.md) | opencode coding agent, served as a browser UI |
| [openhands](openhands/README.md) | OpenHands coding agent, running each session in a container it spawns |
| [paseo](paseo/README.md) | Paseo coding-agent daemon and web UI |
| [traffmonetizer](traffmonetizer/README.md) | TraffMonetizer bandwidth-sharing client |
+17
View File
@@ -0,0 +1,17 @@
# Copy to .env and fill in. Never commit .env.
#
# cp .env.example .env
# Web UI and API login password. MUST NOT be blank -- opencode serves the web
# interface, and with it a shell on this container, to anyone when
# OPENCODE_SERVER_PASSWORD is unset.
# Generate one with: openssl rand -base64 24
OPENCODE_SERVER_PASSWORD=
# Username to pair with it. opencode defaults to `opencode` when unset.
OPENCODE_SERVER_USERNAME=opencode
# Git identity for the commits the agent makes, as author and committer. git
# reads these directly, so no `git config` step is needed.
GIT_NAME=
GIT_EMAIL=
+10
View File
@@ -0,0 +1,10 @@
FROM ghcr.io/anomalyco/opencode:latest
# Shell, git, HTTP and SSH clients for the commands the agent runs.
RUN apk add --no-cache \
bash \
curl \
git \
openssh-client
WORKDIR /workspace
+108
View File
@@ -0,0 +1,108 @@
# opencode-web
[opencode](https://opencode.ai) 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
1. Set the variables from `.env.example` in Coolify or Dokploy.
2. Point the domain at port `4096` and deploy.
3. Log in to a model provider — the UI cannot do it, so use a shell:
```sh
docker compose exec opencode-web 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.
4. 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.
`--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.
## 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`. |
| `GIT_NAME` / `GIT_EMAIL` | Git author and committer identity for the agent's commits. |
`TZ` is set in `compose.yml` rather than here: it is a property of this setup,
not of whoever deploys it, and Compose interpolation would let a `TZ` exported
by the deploying shell win over the `.env` file anyway.
Model provider credentials are not variables — see [Setup](#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.
Anything written outside those two volumes — an `apk add` from the agent's own
terminal — is lost on the next deploy. Add it to the `Dockerfile` instead.
## Networking
Listens on `4096`, published nowhere — the platform maps the domain to it. See
the [root README](../README.md) for why.
## 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 a terminal covers a one-off. Something needed on every deploy
belongs in the `Dockerfile`, since `/usr` is not on a volume.
`ENTRYPOINT` stays the image's own `opencode`, so `command:` in `compose.yml`
is just the subcommand and its flags.
## Related
- [openhands](../openhands/README.md) — a coding agent that runs each session in a container it spawns
- [paseo](../paseo/README.md) — runs the opencode CLI, among others, in a terminal
+23
View File
@@ -0,0 +1,23 @@
services:
opencode-web:
build: .
command:
- web
- '--hostname'
- 0.0.0.0
- '--port'
- '4096'
environment:
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD}
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME}
- TZ=Asia/Ho_Chi_Minh
- GIT_AUTHOR_NAME=${GIT_NAME}
- GIT_AUTHOR_EMAIL=${GIT_EMAIL}
- GIT_COMMITTER_NAME=${GIT_NAME}
- GIT_COMMITTER_EMAIL=${GIT_EMAIL}
volumes:
- 'opencode-home:/root'
- 'opencode-workspace:/workspace'
volumes:
opencode-home:
opencode-workspace:
+87
View File
@@ -0,0 +1,87 @@
# openhands
[OpenHands](https://openhands.dev) — a coding agent with a web UI. The app
container is only the front half: every agent session runs in a separate
*agent-server* container that this one creates on the host through the Docker
socket. Nothing is built here; the upstream image is complete.
## Setup
1. Point the domain at port `3000` and deploy. There is nothing to fill in —
the service has no environment file.
2. Open the domain and set the LLM provider and API key in the UI. They are
written to the state volume and survive a redeploy.
## Docker socket
Mounted read-write, and it has to be: the app creates and destroys containers,
and connecting to a Unix socket needs write permission on it. The `:ro` mount
that [code-server](../code-server/README.md) uses would fail here.
That is full control of the host daemon, which is root-equivalent — the agent
can start a container that mounts anything. It is the price of the Docker
runtime; the alternative is running OpenHands somewhere it does not share a
daemon with anything else.
`host.docker.internal` is required for the same architecture. Agent-server
containers are siblings started by the host daemon, not members of this compose
project's network, and they publish their port on the host. The app reaches
them by that name, so without the `extra_hosts` entry the UI loads and every
session then fails to connect.
## Workspace
There is none on the host. `SANDBOX_VOLUMES` is deliberately unset, so the
agent sees only the storage inside its own sandbox container and cannot touch
a host path. Clone into the sandbox from the agent's terminal instead.
Setting it would mean a *host* path — the sibling container's mounts are
resolved by the host daemon, so a named volume or a path inside the app
container is not a valid value.
## Environment
Nothing is per-deployment, so there is no `.env.example`. What
`compose.yml` sets:
| Variable | Purpose |
| --- | --- |
| `AGENT_SERVER_IMAGE_REPOSITORY` | Where to pull the sandbox image from |
| `AGENT_SERVER_IMAGE_TAG` | Which sandbox image to run. `-python` is the Python/Node toolchain variant; `-golang`, `-java` and others exist |
| `TZ` | Timestamps in the UI and logs |
| `LOG_ALL_EVENTS` | Full agent event stream in the container logs, which is what makes a failed session diagnosable |
The app image tracks `latest` while the agent-server tag is pinned, which looks
inconsistent and is not. GHCR publishes no floating tag for
`openhands/agent-server` at all — its tags are version-and-toolchain triples
like `1.26.0-python` — so there is nothing to track. Bump it by hand when the
app image moves far enough that the two disagree; the app pulls it on first
session, so a wrong tag shows up as a session that never starts, not as a
failed deploy.
## Storage
| Volume | Mount | Holds |
| --- | --- | --- |
| `openhands-state` | `/.openhands` | Settings, LLM credentials, conversation history |
`/.openhands` is at the filesystem root, not under `$HOME` — the upstream
`docker run` mounts the host's `~/.openhands` there, and only the container
side matters to a named volume.
Sandbox containers keep their own storage and are not covered by this volume.
## Networking
Listens on `3000`, published nowhere — the platform maps the domain to it. See
the [root README](../README.md) for why.
Sandbox containers do publish on the host, on ports the app picks, because that
is how the app reaches them. They are not reachable through the domain; serving
a preview running inside a sandbox would need `AGENT_SERVER_USE_HOST_NETWORK`
and `SANDBOX_CONTAINER_URL_PATTERN`, which this setup does not configure.
## Related
- [opencode-web](../opencode-web/README.md) — a coding agent that runs in its own container instead of spawning one
- [paseo](../paseo/README.md) — runs agent CLIs, including OpenHands' competitors, in a terminal
+15
View File
@@ -0,0 +1,15 @@
services:
openhands:
image: 'ghcr.io/openhands/openhands:latest'
environment:
- AGENT_SERVER_IMAGE_REPOSITORY=ghcr.io/openhands/agent-server
- AGENT_SERVER_IMAGE_TAG=1.26.0-python
- TZ=Asia/Ho_Chi_Minh
- LOG_ALL_EVENTS=true
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
- 'openhands-state:/.openhands'
- '/var/run/docker.sock:/var/run/docker.sock'
volumes:
openhands-state: