Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/publish-docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Publish Docker Image

on:
release:
types: [published]
workflow_dispatch: {}

jobs:
docker:
name: Build and push to Docker Hub
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
if: ${{ secrets.DOCKERHUB_USERNAME && secrets.DOCKERHUB_TOKEN }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Compute tags
id: meta
run: |
tag="${GITHUB_REF_NAME#v}"
sha=$(echo "$GITHUB_SHA" | cut -c1-7)
if [ -z "$tag" ]; then tag="$sha"; fi
echo "version=$tag" >> $GITHUB_OUTPUT

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: ${{ secrets.DOCKERHUB_USERNAME && secrets.DOCKERHUB_TOKEN }}
platforms: linux/amd64,linux/arm64
tags: |
opencodeai/opencode:server
opencodeai/opencode:server-${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
38 changes: 38 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
FROM oven/bun:latest AS base

# Core tools required by server features (downloads, unzip, etc.) and gopls support
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl unzip tar git golang-go nodejs npm jq \
&& rm -rf /var/lib/apt/lists/*

# Create non-root user
RUN groupadd -g 1001 opencode && \
useradd -r -u 1001 -g opencode -m opencode

# Set working directory for the app layer
WORKDIR /app

# Copy only the opencode package files for a minimal build
COPY packages/opencode/package.json ./package.json
# Provide workspace catalog mapping for catalog: versions
COPY package.json /tmp/root.package.json
RUN sed -i 's/"@opencode-ai\/sdk": "workspace:\*"/"@opencode-ai\/sdk": "latest"/g' package.json && \
sed -i 's/"@opencode-ai\/plugin": "workspace:\*"/"@opencode-ai\/plugin": "latest"/g' package.json && \
node -e 'const fs=require("fs"); const root=JSON.parse(fs.readFileSync("/tmp/root.package.json","utf8")); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); const cat=(root.workspaces&&root.workspaces.catalog)||{}; if(pkg.dependencies){for(const k of Object.keys(pkg.dependencies)) if(pkg.dependencies[k]==="catalog:") pkg.dependencies[k]=cat[k]||pkg.dependencies[k];} if(pkg.devDependencies){for(const k of Object.keys(pkg.devDependencies)) if(pkg.devDependencies[k]==="catalog:") pkg.devDependencies[k]=cat[k]||pkg.devDependencies[k];} fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2));'

# Install dependencies (production preferred, fall back to full)
RUN bun install --production || bun install

# Copy source code
COPY packages/opencode/src ./src
COPY packages/opencode/tsconfig.json ./

# Expose port
EXPOSE 8080

# Switch to non-root user
USER opencode

# Start the server
CMD ["bun", "run", "/app/src/index.ts", "serve", "--hostname", "0.0.0.0", "--port", "8080"]
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,58 @@ $ bun install
$ bun dev
```

#### Docker Server Mode

You can optionally run the opencode server in a Docker container with the current directory mounted for isolation. When started with `--docker`, opencode securely syncs only its own provider credentials (from `auth.json`) into the container; no other local credentials or home directories are mounted.

```bash
# TUI with server in Docker (mounts $PWD to /workspace)
# Uses Docker Hub image by default: opencodeai/opencode:server
opencode --docker

# Headless server in Docker
opencode serve --docker --port 8080 --docker-image opencode:latest
```

This maps a host port to the container’s server and mounts your current directory at `/workspace`.

Build from a local Dockerfile (handy for dev):

```bash
# Build with the repo Dockerfile, then run
opencode --docker --docker-build --dockerfile ./Dockerfile

# Or headless
opencode serve --docker --docker-build --dockerfile ./Dockerfile --port 8080
```

The default Docker image is `opencodeai/opencode:server`. The provided Dockerfile uses the `oven/bun` base image, adds essential tools (`curl`, `unzip`, `tar`, `git`, `nodejs`, `npm`) and Go (for optional `gopls`), installs the opencode server, and exposes port `8080`.

If you prefer to build the image manually:

```bash
docker build -t opencode:latest .
```

Or use the helper:

```bash
# Tags both opencodeai/opencode:server and opencode:local
./script/docker-build [Dockerfile] [context]
```

Auto-enable Docker mode via config:

```jsonc
// ~/.config/opencode/config.json
{
"server": {
"docker": true,
"image": "opencodeai/opencode:server"
}
}
```

#### Development Notes

**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"packageManager": "bun@1.2.19",
"scripts": {
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
"docker:build": "./script/docker-build",
"typecheck": "bun run --filter='*' typecheck",
"generate": "(cd packages/sdk && ./js/script/generate.ts) && (cd packages/sdk/stainless && ./generate.ts)",
"postinstall": "./script/hooks"
Expand Down
147 changes: 139 additions & 8 deletions packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import { Auth } from "../../auth"
import path from "path"
import { ModelsDev } from "../../provider/models"

export const ServeCommand = cmd({
command: "serve",
builder: (yargs) =>
yargs
.option("docker", {
type: "boolean",
describe: "run server in docker with current dir mounted",
})
.option("docker-image", {
type: "string",
describe: "docker image for server",
default: "opencodeai/opencode:server",
alias: ["dockerImage"],
})
.option("dockerfile", {
type: "string",
describe: "path to a local Dockerfile to build before running",
})
.option("docker-context", {
type: "string",
describe: "docker build context directory (defaults to Dockerfile's dir)",
alias: ["dockerContext"],
})
.option("docker-build", {
type: "boolean",
describe: "force build the docker image before running",
alias: ["dockerBuild"],
})
.option("port", {
alias: ["p"],
type: "number",
Expand All @@ -19,14 +48,116 @@ export const ServeCommand = cmd({
}),
describe: "starts a headless opencode server",
handler: async (args) => {
const hostname = args.hostname
const port = args.port
const server = Server.listen({
port,
hostname,
const cwd = process.cwd()
await bootstrap(cwd, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}

const srv = await (async () => {
if (!args.docker) return Server.listen({ port: args.port, hostname: args.hostname })
const docker = Bun.which("docker")
if (!docker) return Server.listen({ port: args.port, hostname: args.hostname })
const df = (args as { dockerfile?: string }).dockerfile
const needBuild = !!df || (args as { dockerBuild?: boolean }).dockerBuild === true
const img = await (async () => {
const defaultImg = "opencodeai/opencode:server"
if (!needBuild) return (args as { dockerImage?: string }).dockerImage ?? defaultImg
const f = df ?? "Dockerfile"
const ctx = (args as { dockerContext?: string }).dockerContext ?? path.dirname(path.resolve(f))
const base = (args as { dockerImage?: string }).dockerImage ?? defaultImg
const tag = base === defaultImg ? "opencode:local" : base
const b = Bun.spawn({ cmd: [docker, "build", "-t", tag, "-f", f, ctx], stdout: "inherit", stderr: "inherit" })
const code = await b.exited
if (code !== 0) return base
return tag
})()
const alloc = () => {
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("ok") })
const p = s.port
s.stop()
return p
}
const port = args.port && args.port > 0 ? args.port : alloc()
const host = args.hostname ?? "127.0.0.1"
const cport = 8080
const vol = process.cwd() + ":/workspace"
const db = await ModelsDev.get()
const envlist: string[] = []
for (const p of Object.values(db)) {
for (const k of p.env) {
const v = process.env[k]
if (v) envlist.push(`${k}=${v}`)
}
}
const cmd = [
docker,
"run",
"--rm",
"-d",
"-p",
`${port}:${cport}`,
"-v",
vol,
"-w",
"/workspace",
...envlist.flatMap((e) => ["-e", e]),
img,
"bun",
"run",
"/app/src/index.ts",
"serve",
"--hostname",
"0.0.0.0",
"--port",
String(cport),
]
const p = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" })
const code = await p.exited
const id = await new Response(p.stdout).text().then((x) => x.trim())
if (code !== 0 || !id) return Server.listen({ port: args.port, hostname: args.hostname })
const url = new URL("http://" + host + ":" + String(port))
const until = Date.now() + 30_000
let ready = false
while (Date.now() < until) {
const ok = await fetch(new URL("/doc", url)).then((r) => r.ok).catch(() => false)
if (ok) {
ready = true
break
}
await Bun.sleep(250)
}
if (!ready) return Server.listen({ port: args.port, hostname: args.hostname })
return {
hostname: host,
port,
url,
stop: async () => {
const stop = Bun.spawn({ cmd: [docker, "stop", id], stdout: "ignore", stderr: "inherit" })
await stop.exited
},
}
})()

if (args.docker) {
const auth = await Auth.all()
await Promise.all(
Object.entries(auth).map(([id, info]) =>
fetch(new URL("/auth/" + encodeURIComponent(id), srv.url), {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(info),
}).catch(() => {}),
),
)
}

console.log(`opencode server listening on http://${srv.hostname}:${srv.port}`)

await new Promise(() => {})

srv.stop()
})
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})
server.stop()
},
})
Loading
Loading