Self-hosted OpenCode web UI in a Docker image, ready to deploy on Railway (or any PaaS that builds Dockerfiles and forwards $PORT).
The container exposes the OpenCode web UI with no built-in auth. Put Cloudflare Access (or equivalent) in front of the public domain before exposing it — see Auth below.
- OpenCode built from source from the
BYK/opencodefork (byk/cumulativebranch) — carries question-dock UX, plan-mode, and db perf fixes that aren't yet in upstream. Built fresh into the image; auto-update is effectively disabled because the fork has no release feed. - Sentry CLI, GitHub CLI, nvm + Node 22 LTS (
pnpm/yarnvia corepack), Bun, plusgit,ripgrep,fd,fzf,jq,yq, andbuild-essential. - No MCP servers preconfigured — add your own via a project-local
opencode.jsonor by editingopencode-user-config.jsonbefore building. - Bundled OpenCode plugin:
opentower— turns inbound GitHub webhook deliveries into OpenCode agent sessions running in the sameopencodeprocess. Ships withopentower.config.jsonbaked in (3 triggers covering all GitHub events and email notifications). Activates on container start once you setGITHUB_WEBHOOK_SECRET. The plugin lives as a standalone, publishable npm package underpackages/opentower/— see its README for the full config schema and how to use it in your own OpenCode setup. See also GitHub webhooks → agent sessions below for this image's specific wiring. - Bundled agent (permissions pre-broadened so it doesn't stall on approval prompts):
jared— unified agent that receives raw webhook payloads, triages the event, and loads situation-specific skills to drive it to completion. Handles the full lifecycle: issue assignment → draft PR → review → CI fix → comment response.
- Bundled skills (loadable on demand by the agent via the
skilltool):- Situation skills (domain workflows the agent loads based on the event type):
repo-setup— shared clone/checkout/branch boilerplate.resolve-issue— plan, implement, clean up, open a draft PR.review-pr— review a PR; self-fix or post a structured review.fix-ci— diagnose and fix failing CI (3-attempt budget).respond-to-comment— triage PR comments and respond.apply-fixes— apply review findings as smallest code changes.
- Utility skills (cross-cutting tools used by situation skills):
- Utility skills adapted from BYK/dotskills (Apache-2.0).
- Situation skills (domain workflows the agent loads based on the event type):
- Non-root
developeruser. OpenCode starts in~/dev. Mount a single persistent volume at~/dev(=/home/developer/dev) to keep your projects and OpenCode session/auth data across redeploys —~/.local/share/opencodeis symlinked into~/dev/.opencode.
- Push this repo to GitHub.
- Railway: New Project → Deploy from GitHub repo.
- Variables tab: set at least one LLM provider key (e.g.
ANTHROPIC_API_KEY). - (Optional) Add a Volume mounted at
/home/developer/devso projects you clone and OpenCode session history both survive redeploys (sessions live at~/dev/.opencodevia a symlink, so one volume covers both). - Settings → Networking → Generate Domain. Don't open it publicly — first put Cloudflare Access in front (see Auth), then visit the Access-protected URL and sign in via your IdP.
The image runs opencode web with no built-in authentication, so you must front it with an auth proxy that issues a real session cookie (basic auth re-prompts constantly on mobile, which is why it's gone).
Recommended: Cloudflare Access.
- Point your custom domain at the Railway-generated domain via Cloudflare DNS (orange-cloud / proxied).
- Cloudflare Zero Trust → Access → Applications → Add an application → Self-hosted, set the application domain to your custom hostname.
- Add a policy (e.g. allow your email, an
@yourdomainrule, or a GitHub identity). - Visit the domain — you'll get Cloudflare's sign-in page, then a long-lived
CF_Authorizationcookie that mobile browsers keep across app kills and reboots.
Alternatives: Tailscale Serve / Funnel, oauth2-proxy sidecar, Authelia.
See .env.example for the full template.
| Variable | What it does |
|---|---|
One of ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY |
Required. LLM provider key. |
SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT, SENTRY_URL |
For the bundled sentry CLI. |
GH_TOKEN |
For the bundled gh CLI and the opentower plugin's bot identity resolution. PAT with the scopes you need (typical: repo, read:org, workflow). The plugin resolves the bot's login at boot for self-loop prevention ($BOT_LOGIN substitution); without GH_TOKEN, self-loop prevention is degraded. |
GITHUB_WEBHOOK_SECRET |
HMAC secret for the opentower plugin. Required to receive webhooks. |
WEBHOOK_PORT, OPENTOWER_CONFIG |
Optional plugin tuning. See .env.example. |
OPENTOWER_CORS_ORIGIN |
CORS origin for the opentower API. Not needed in production (dashboard is same-origin). Set to the dashboard dev server URL (e.g. http://localhost:5173) during development. |
PORT |
Set automatically by most PaaS providers. Defaults to 4096. |
The bundled opentower plugin
runs inside the OpenCode server process — no sidecar, no second
process to supervise, no loopback HTTP. It opens its own listener on
port 5050 (configurable via WEBHOOK_PORT) and dispatches verified
deliveries into agent sessions via the in-process SDK client.
The image ships with opentower.config.json baked in at
~/.config/opencode/opentower.config.json. It defines 3 triggers
that route all supported GitHub events to the jared agent:
| Trigger | Events | Self-loop guard | Agent |
|---|---|---|---|
github-event |
issues, pull_request, check_suite |
none (bot's own actions should fire) | jared |
github-comment |
pull_request_review_comment, issue_comment, pull_request_review |
ignore_authors: [$BOT_LOGIN] |
jared |
email-event |
email.* (any email notification) |
ignore_authors: [$BOT_LOGIN] |
jared |
The agent receives the raw webhook payload and decides what to do. It triages the event and loads the appropriate skills to execute the work directly:
- Issue assigned → loads
repo-setup+resolve-issue→ draft PR - PR needs review → loads
repo-setup+review-pr→ review or self-fix - CI failed → loads
repo-setup+fix-ci→ smallest fix + comment - Comment/review on PR → loads
repo-setup+respond-to-comment→ triage + reply/fix
The plugin extracts an entity key (owner/repo#N) from each
webhook payload and routes all events for the same entity to the same
OpenCode session. This means:
- When the bot resolves an issue and opens a PR, the subsequent CI failure webhook arrives as a follow-up message in the same session — the agent already has full context about the implementation.
- Review comments on a PR arrive in the session that's already working on that PR, so the agent can act on them immediately.
- If the session is busy (processing a previous prompt), incoming events queue in an in-memory buffer and are flushed as a single batched follow-up when the current prompt completes.
Events without a recognizable entity key fall through to one-shot sessions (fire-and-forget, same as before).
The batch_window_ms config option (default 5s) controls how long
the pipeline waits for additional events before flushing the queue.
Once GITHUB_WEBHOOK_SECRET is set and GH_TOKEN is available
(both required), the plugin boots its listener on port 5050
automatically. No further setup needed.
Self-loop prevention works at two levels:
-
Plugin level — the comment and email triggers use
ignore_authors: ["$BOT_LOGIN"]. The plugin substitutes$BOT_LOGINwith the bot login auto-resolved at boot viagh api user. This prevents the bot's own webhook activity (e.g., opening a PR, posting a comment) from re-triggering a new session. -
Agent level — the
jaredagent runs a self-loop guard as a pre-flight check: ifpayload.sender.loginequals the bot's identity, it stops withSKIPPED: self-triggered. The exception ischeck_suite.completedwhere the sender is the CI app, not the pusher.
If gh api user fails at boot (no GH_TOKEN, network error), the
$BOT_LOGIN placeholder is silently dropped — the trigger doesn't
filter, which is the safer failure mode. The agent's own pre-flight
check provides a second layer of defense.
The bundled file gives you all four agent flows out of the box. To customize:
- Edit before building — change
opentower.config.jsonin this repo and rebuild the image. Triggers stay version-controlled. - Override at runtime — set
OPENTOWER_CONFIG=/home/developer/dev/.opencode/opentower.config.json(or any other path) and put your own file there. Handy for adding per-deployment triggers without rebuilding.
The HMAC secret (secret field) is intentionally not baked into the
file — set GITHUB_WEBHOOK_SECRET as an env var instead.
The minimum-viable trigger:
{
"triggers": [
{
"name": "all-events",
"event": ["issues", "pull_request"],
"agent": "jared",
"prompt_template": "A GitHub webhook arrived.\n\nEvent: {{ event }}\nAction: {{ action }}\n\nPayload:\n{{ payload }}"
}
]
}The bundled opentower.config.json passes the raw payload
to the agent, which decides what to do. The plugin's job is kept
minimal: HMAC verification, dedup, self-loop guard, and dispatch.
Field reference:
| Field | Required | What it does |
|---|---|---|
triggers[].name |
✓ | Unique identifier; surfaces in plugin logs. |
triggers[].event |
✓ | GitHub event header (issues, pull_request, push, ...). Use "*" to match anything. Accepts an array for OR-matching. Supports trailing wildcards ("email.*"). |
triggers[].action |
optional | If set, must match the payload's action exactly. Omit/null to match any action of this event. |
triggers[].agent |
✓ | Agent name to invoke (built-in or from agents/). |
triggers[].prompt_template |
✓ | Mustache-ish template. {{ payload.foo.bar }} looks up paths in the payload; missing paths render empty. |
triggers[].cwd |
optional | Override the session's working directory. Falls back to default_cwd, then to OpenCode's project root. |
triggers[].ignore_authors |
optional | List of GitHub logins to filter out (case-insensitive, exact match) on payload.sender.login. The literal string "$BOT_LOGIN" is substituted with the auto-resolved bot login at boot. Use this to prevent the bot from re-triggering on its own webhook activity. |
port |
optional | Listener port; defaults to 5050 or WEBHOOK_PORT. |
secret |
optional | HMAC secret. Falls back to GITHUB_WEBHOOK_SECRET. |
max_concurrent |
optional | Cap on concurrent agent sessions across all triggers (default 2). |
timeout_ms |
optional | Per-session abort timeout (default 30 min). |
retention_days |
optional | Data retention in days. Dispatches and entities older than this are pruned on startup and every 24h (default 30). Also configurable from the dashboard Settings. |
batch_window_ms |
optional | How long the pipeline waits for additional events before flushing the queue (default 5000). |
default_cwd |
optional | Fallback cwd for triggers without one. |
In the GitHub webhook UI:
- Payload URL:
https://<your-domain>:5050/webhooks/github(or however you route to that port). - Content type:
application/json. - Secret: same value as
GITHUB_WEBHOOK_SECRET. - Events: pick what you need (
Issues,Pull request review, etc.).
The plugin verifies X-Hub-Signature-256, dedups on X-GitHub-Delivery
(redeliveries are ack'd as duplicate: true and don't re-fire agents),
and parses each delivery's action for trigger matching. The dispatched
session itself is the system of record for everything that happens
afterward — view it in OpenCode's UI like any other session.
Railway note. Railway only generates one HTTP domain per service. To reach
5050you'll need a second Railway service pointing at the same image, a TCP proxy, or to route through Cloudflare. The opencode web UI on4096/$PORTand the plugin listener are independent — both speak plain HTTP on0.0.0.0.
GET http://<host>:5050/healthz (the plugin's port, not OpenCode's
4096) returns { "ok": true, "plugin": "opentower" } once the
listener is up. No auth required.
cp .env.example .env # edit, fill in the required values
docker build -t outpost .
docker run --rm -it \
-p 4096:4096 -p 5050:5050 \
--env-file .env outpostOpen http://localhost:4096 for OpenCode. Hit
http://localhost:5050/healthz if you've set up an opentower.config.json and
want to verify the plugin loaded.
- Override Node at build time:
docker build --build-arg NODE_VERSION=22.20.0 -t outpost . - Python isn't installed in the runtime image. If an npm package needs
node-gyp, install on the fly inside an OpenCode bash session:sudo apt-get install -y python3. - Pin a different opencode revision/fork at build time:
docker build --build-arg OPENCODE_REPO=https://github.com/anomalyco/opencode.git --build-arg OPENCODE_REF=dev -t outpost .(defaults:BYK/opencode@byk/cumulative).