polats Claude Opus 5 (1M context) commited on
Commit
5bd2d41
·
0 Parent(s):

Add deployable opencode server image for HF Spaces and Railway

Browse files

One Dockerfile serving both hosts. entrypoint.sh resolves the port at runtime
($PORT on Railway, 7860 to match app_port on HF Spaces), detects a writable
/data mount and points the XDG dirs and workspace at it so sessions, provider
logins and code survive restarts, and refuses to start without
OPENCODE_SERVER_PASSWORD — every route on this server allows shell execution,
so basic auth is not optional on a public URL.

opencode is installed at build time rather than on first boot, since a free
Space's filesystem is ephemeral and would otherwise re-download the release on
every cold start. The installer hardcodes $HOME/.opencode/bin and ignores
OPENCODE_INSTALL_DIR, so the binary is moved onto the shared PATH afterwards.
The node images already ship a uid-1000 user, which is what HF Spaces requires,
so `node` is reused instead of adding a colliding one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (7) hide show
  1. .gitignore +5 -0
  2. Dockerfile +45 -0
  3. LICENSE +21 -0
  4. README.md +119 -0
  5. docs/RAILWAY.md +46 -0
  6. entrypoint.sh +78 -0
  7. railway.json +12 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ node_modules/
5
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # opencode-cloud — one image, two homes (Hugging Face Spaces + Railway).
2
+ #
3
+ # The port is decided at runtime by entrypoint.sh:
4
+ # * Railway injects $PORT
5
+ # * HF Spaces does not, so we fall back to 7860 (matches app_port in README.md)
6
+ FROM node:22-bookworm-slim
7
+
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ ca-certificates \
10
+ curl \
11
+ git \
12
+ jq \
13
+ less \
14
+ openssh-client \
15
+ ripgrep \
16
+ tar \
17
+ unzip \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Install opencode at BUILD time so cold starts don't re-download the release.
21
+ # The official installer hardcodes $HOME/.opencode/bin (it ignores
22
+ # OPENCODE_INSTALL_DIR), so move the binary onto the shared PATH afterwards.
23
+ # Pin a release with: --build-arg OPENCODE_VERSION=1.18.18
24
+ ARG OPENCODE_VERSION=""
25
+ RUN VERSION="${OPENCODE_VERSION}" bash -c 'curl -fsSL https://opencode.ai/install | bash' \
26
+ && mv "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode \
27
+ && chmod 755 /usr/local/bin/opencode \
28
+ && rm -rf "$HOME/.opencode" \
29
+ && opencode --version
30
+
31
+ # Hugging Face Spaces runs containers as uid 1000. The node images already ship
32
+ # a `node` user at uid 1000, so reuse it rather than useradd'ing a colliding one.
33
+ ENV HOME=/home/node
34
+ ENV PATH=/usr/local/bin:${PATH}
35
+
36
+ COPY --chown=node:node entrypoint.sh /home/node/entrypoint.sh
37
+ RUN chmod +x /home/node/entrypoint.sh \
38
+ && mkdir -p /home/node/workspace \
39
+ && chown -R node:node /home/node
40
+
41
+ USER node
42
+ WORKDIR /home/node/workspace
43
+
44
+ EXPOSE 7860
45
+ ENTRYPOINT ["/home/node/entrypoint.sh"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 polats
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: OpenCode Cloud
3
+ emoji: 🤖
4
+ colorFrom: gray
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # opencode-cloud
13
+
14
+ Run [opencode](https://opencode.ai) — an open source AI coding agent — as a hosted
15
+ server you reach from a browser, from the desktop app, or from a local build of the
16
+ web app. One Dockerfile, deployable to **Hugging Face Spaces** or **Railway**.
17
+
18
+ `opencode serve` is not just an API: the server also serves the web UI on its
19
+ catch-all route, so opening the deployment URL gives you the full app, same-origin.
20
+
21
+ ---
22
+
23
+ ## Deploy on Hugging Face Spaces
24
+
25
+ 1. **Create a Space** → SDK **Docker**, blank template. Push this repo to it
26
+ (or duplicate an existing Space built from it).
27
+ 2. **Settings → Variables and secrets** and add:
28
+
29
+ | Secret | Required | Notes |
30
+ |---|---|---|
31
+ | `OPENCODE_SERVER_PASSWORD` | **yes** | The container refuses to start without it. This is the only thing protecting a shell. |
32
+ | `OPENCODE_SERVER_USERNAME` | no | Defaults to `opencode`. |
33
+ | `OPENCODE_API_KEY` | pick ≥1 | [opencode zen](https://opencode.ai/zen) curated models |
34
+ | `ANTHROPIC_API_KEY` | pick ≥1 | https://console.anthropic.com |
35
+ | `OPENAI_API_KEY` | pick ≥1 | https://platform.openai.com/api-keys |
36
+ | `GEMINI_API_KEY` | pick ≥1 | https://aistudio.google.com/apikey |
37
+
38
+ 3. Open the Space URL and log in with `opencode` + your password.
39
+
40
+ Provider keys work as plain env vars — opencode registers any provider whose
41
+ models.dev env var is present, no login step needed.
42
+
43
+ ### Persistence
44
+
45
+ Free Spaces are **ephemeral** and are stopped after ~48h idle. Sessions, provider
46
+ logins and any uncommitted code are lost on restart. Two options:
47
+
48
+ - **Free:** `git clone` your repo into the workspace and push before you walk away.
49
+ - **Paid:** enable persistent storage (Settings → Storage). It mounts at `/data`,
50
+ which `entrypoint.sh` detects and uses for the XDG dirs *and* the workspace, so
51
+ state survives restarts. Watch the boot log — it prints which mode it picked.
52
+
53
+ ### Public or private Space?
54
+
55
+ A **private** Space adds your HF login in front of everything, which is the safer
56
+ default for browser use. The catch: HF authenticates private Spaces with an
57
+ `Authorization: Bearer hf_…` header, and opencode wants `Authorization: Basic …` —
58
+ one header, two claimants. So a private Space works in a browser (HF session
59
+ cookie) but a **desktop-app or local-app connection to a private Space will fight
60
+ over that header**. Keep the Space public with a long password if you want those.
61
+
62
+ ---
63
+
64
+ ## Deploy on Railway
65
+
66
+ ```bash
67
+ railway init # or point a service at this GitHub repo
68
+ railway variables --set OPENCODE_SERVER_PASSWORD=...
69
+ railway up
70
+ ```
71
+
72
+ Railway injects `$PORT`, which `entrypoint.sh` uses automatically. Attach a volume
73
+ with mount path `/data` for persistence. Details and caveats: [docs/RAILWAY.md](docs/RAILWAY.md).
74
+
75
+ ---
76
+
77
+ ## Connecting the desktop app or a local web app
78
+
79
+ You don't have to use the in-browser UI. In the app, **Settings → Servers → Add**
80
+ and enter the deployment URL plus the username/password. The server's CORS
81
+ allowlist already covers `localhost`, `*.opencode.ai` and the desktop app's
82
+ `oc://renderer` origin, so no extra flags are needed. Only a frontend you host on
83
+ your own domain needs `OPENCODE_CORS_ORIGINS=https://your.domain`.
84
+
85
+ ---
86
+
87
+ ## Read this before you deploy
88
+
89
+ - **This is a remote shell.** Every route allows command execution and file
90
+ read/write in the container. Basic auth over HTTPS is the entire security model,
91
+ so use a long random password, and think twice about what credentials you put in
92
+ the container alongside it.
93
+ - **Provider OAuth "login with browser" flows don't work on a remote server.**
94
+ They bind a `http://localhost:<port>/auth/callback` listener *inside* the
95
+ container, so your browser is redirected to your own machine instead. Use API
96
+ keys.
97
+ - **CPU only, and modest.** Fine for the agent; you can't run local models.
98
+ - **Check the host's terms.** A general-purpose remote shell is not the ML-demo
99
+ use case Spaces are described for; a public one with a weak password is the way
100
+ to get flagged.
101
+
102
+ ---
103
+
104
+ ## Environment variables
105
+
106
+ | Variable | Default | Purpose |
107
+ |---|---|---|
108
+ | `OPENCODE_SERVER_PASSWORD` | — | **Required.** Basic auth password. |
109
+ | `OPENCODE_SERVER_USERNAME` | `opencode` | Basic auth username. |
110
+ | `PORT` | `7860` | Listen port. Railway sets this; HF must match `app_port`. |
111
+ | `OPENCODE_STATE_ROOT` | `/data` | Where to look for a writable volume. |
112
+ | `OPENCODE_WORKSPACE` | `$STATE_ROOT/workspace` or `$HOME/workspace` | Directory to serve. |
113
+ | `OPENCODE_CORS_ORIGINS` | — | Comma-separated extra CORS origins. |
114
+
115
+ Build arg `OPENCODE_VERSION` pins a release (default: latest).
116
+
117
+ ---
118
+
119
+ MIT. opencode itself is © Anomaly Innovations; this repo is deployment wrapper code.
docs/RAILWAY.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying on Railway
2
+
3
+ Railway is the sturdier of the two targets: no port/`app_port` coupling, cheap
4
+ volumes, and no always-on/idle-GC surprises. There is no free always-on tier.
5
+
6
+ ## Steps
7
+
8
+ 1. Create a project and point a service at this repo (or `railway init` + `railway up`).
9
+ `railway.json` selects the Dockerfile builder.
10
+ 2. Set variables:
11
+
12
+ ```bash
13
+ railway variables --set OPENCODE_SERVER_PASSWORD="$(openssl rand -base64 24)"
14
+ railway variables --set OPENCODE_API_KEY=... # and/or ANTHROPIC_API_KEY, etc.
15
+ ```
16
+
17
+ 3. **Attach a volume** with mount path `/data`. Without it, every redeploy wipes
18
+ sessions, provider logins and uncommitted code. `entrypoint.sh` picks `/data`
19
+ up automatically and logs which mode it chose.
20
+ 4. Generate a domain (Settings → Networking → Generate Domain) and open it.
21
+
22
+ `$PORT` is injected by Railway and used automatically — do not hardcode a port.
23
+
24
+ ## Do not set a healthcheck path
25
+
26
+ Every route requires basic auth, so `/api/health` returns `401` to an
27
+ unauthenticated prober and Railway would mark the deploy failed. Leave
28
+ `healthcheckPath` unset and let Railway consider the service live once the
29
+ process is listening.
30
+
31
+ ## Getting your code in
32
+
33
+ There is no repo in the container. From the app's terminal:
34
+
35
+ ```bash
36
+ git clone https://github.com/you/your-repo.git
37
+ ```
38
+
39
+ For pushes, add a deploy key or use a PAT. Anything outside `/data` is lost on
40
+ redeploy.
41
+
42
+ ## Restarts kill in-flight runs
43
+
44
+ A redeploy or crash takes the container with it, including any agent run in
45
+ progress. Session state survives if it lives on the volume; the interrupted run
46
+ does not.
entrypoint.sh ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Boot opencode's headless server for a PaaS (Hugging Face Spaces / Railway).
3
+ set -euo pipefail
4
+
5
+ # Allow `docker run <image> bash` etc. to override the server for debugging.
6
+ # PaaS hosts pass no arguments, so the normal path is unaffected.
7
+ if [ "$#" -gt 0 ]; then
8
+ exec "$@"
9
+ fi
10
+
11
+ # --- port -------------------------------------------------------------------
12
+ # Railway injects $PORT. HF Spaces does not; 7860 must match app_port in README.md.
13
+ PORT="${PORT:-7860}"
14
+
15
+ # --- refuse to run unauthenticated -----------------------------------------
16
+ # opencode itself only warns. On a public URL an unauthenticated server means
17
+ # anyone can run shell commands and read/write files in this container, so fail
18
+ # loudly instead.
19
+ if [ -z "${OPENCODE_SERVER_PASSWORD:-}" ]; then
20
+ cat >&2 <<'MSG'
21
+ FATAL: OPENCODE_SERVER_PASSWORD is not set.
22
+
23
+ Every route on this server allows shell execution and file access, and basic
24
+ auth is the only thing standing in front of it. Set the secret and redeploy:
25
+
26
+ Hugging Face Settings -> Variables and secrets -> New secret
27
+ Railway Variables -> New Variable
28
+
29
+ Log in as $OPENCODE_SERVER_USERNAME (default "opencode") with that password.
30
+ MSG
31
+ exit 1
32
+ fi
33
+
34
+ # --- persistence -----------------------------------------------------------
35
+ # opencode keeps sessions, provider credentials and its SQLite DB under the XDG
36
+ # dirs (packages/core/src/global.ts), so pointing those at a mounted volume is
37
+ # what makes state survive a restart.
38
+ # HF Spaces persistent storage is mounted at /data (paid add-on)
39
+ # Railway attach a volume with mount path /data
40
+ STATE_ROOT="${OPENCODE_STATE_ROOT:-/data}"
41
+
42
+ if [ -d "$STATE_ROOT" ] && [ -w "$STATE_ROOT" ]; then
43
+ export XDG_DATA_HOME="$STATE_ROOT/share"
44
+ export XDG_STATE_HOME="$STATE_ROOT/state"
45
+ export XDG_CACHE_HOME="$STATE_ROOT/cache"
46
+ export XDG_CONFIG_HOME="$STATE_ROOT/config"
47
+ WORKSPACE="${OPENCODE_WORKSPACE:-$STATE_ROOT/workspace}"
48
+ mkdir -p "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" "$XDG_CONFIG_HOME"
49
+ echo "persistence: $STATE_ROOT (sessions, logins and code survive restarts)"
50
+ else
51
+ WORKSPACE="${OPENCODE_WORKSPACE:-$HOME/workspace}"
52
+ echo "persistence: NONE — $STATE_ROOT is not a writable mount."
53
+ echo " Sessions, provider logins and uncommitted code are lost on restart."
54
+ echo " Push to git before you walk away, or attach a volume at $STATE_ROOT."
55
+ fi
56
+
57
+ mkdir -p "$WORKSPACE"
58
+ cd "$WORKSPACE"
59
+
60
+ # Volumes and bind mounts routinely carry a different owner than uid 1000,
61
+ # which makes git refuse to touch the repo. Trust what we already control.
62
+ git config --global --add safe.directory '*' 2>/dev/null || true
63
+
64
+ # --- extra CORS origins ----------------------------------------------------
65
+ # localhost, *.opencode.ai and the desktop app's oc://renderer are allowed out
66
+ # of the box (packages/server/src/cors.ts). Only a frontend you host yourself
67
+ # on some other domain needs this.
68
+ CORS_ARGS=()
69
+ if [ -n "${OPENCODE_CORS_ORIGINS:-}" ]; then
70
+ IFS=',' read -ra origins <<<"$OPENCODE_CORS_ORIGINS"
71
+ for origin in "${origins[@]}"; do
72
+ origin="$(echo "$origin" | xargs)"
73
+ [ -n "$origin" ] && CORS_ARGS+=(--cors "$origin")
74
+ done
75
+ fi
76
+
77
+ echo "opencode $(opencode --version) starting on 0.0.0.0:$PORT (workspace: $WORKSPACE)"
78
+ exec opencode serve --hostname 0.0.0.0 --port "$PORT" "${CORS_ARGS[@]}"
railway.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://railway.com/railway.schema.json",
3
+ "build": {
4
+ "builder": "DOCKERFILE",
5
+ "dockerfilePath": "Dockerfile"
6
+ },
7
+ "deploy": {
8
+ "restartPolicyType": "ON_FAILURE",
9
+ "restartPolicyMaxRetries": 10,
10
+ "numReplicas": 1
11
+ }
12
+ }