The problem: OpenCode is a brilliant AI coding agent that runs as a CLI — but it also ships a full browser-based IDE via opencode web. Naturally, you want both. The CLI for when you’re at your desk, the web UI for when you’re on your phone, on another machine, or just want a nicer interface.
So you spin up the web container, point it at the same project folder, and… your sessions stop loading. Or the container crash-loops with a cryptic disk I/O error. Or worse — you corrupt the very database holding every chat you’ve ever had.
This article is the fix I landed on after breaking things badly enough to earn it: run the CLI and the web container against separate databases, and treat the web copy as a one-way snapshot. No shared SQLite file. No path gymnastics on the live database. Both sides run simultaneously, indefinitely, without touching each other.
Why Sharing the Database Fails
OpenCode stores everything in a SQLite database at ~/.local/share/opencode/opencode.db — sessions, messages, project state. On a Windows host the paths inside look like C:\Users\you\Desktop\projects. Inside the Docker container the same folder is bind-mounted as /workspace.
Two problems follow the moment you try to share that one file between host and container:
- Dual-writer WAL corruption. SQLite runs in WAL mode, which needs reliable file locking and shared-memory files (
-wal,-shm). A Docker Desktop Windows bind mount doesn’t give you proper locking across that filesystem boundary, so two processes writing the same file end indisk I/O errorand a corrupted database. - Path mismatch. The host records sessions under
C:\Users\you\...; the container expects/workspace/.... Whichever side you “flip” leaves the other side pointing at paths that no longer resolve — the classic “workspace folder not detected” symptom.
I tried the obvious fixes first. Sharing the db via bind mount: corrupted. Flipping paths in-place: broke whichever side wasn’t running. The answer wasn’t a smarter mount — it was to stop sharing altogether.
The Architecture: Two Databases, One Snapshot
| Component | Location | Paths |
|---|---|---|
| Native CLI (host) | ~/.local/share/opencode | Windows paths (C:/Users/you/...) |
| Web container DB | named volume opencode-data | /workspace paths (flipped copy) |
| Workspace bind mount | ~/projects → /workspace | read-write |
| Web config | named volume opencode-config | container-only |
| Web auth | copied into volume (auth.json, account.json, storage/) | snapshot |
The host database never changes. When you want the web UI to see your latest sessions, you take a consistent snapshot with SQLite’s .backup, flip the paths on the copy, and drop it into the web container’s volume. The host stays Windows-pathed. The web copy is /workspace-pathed. There is exactly one writer per file, and they never meet.
The Compose Stack
The stack is two services: the OpenCode container itself, and a Tailscale sidecar that exposes it over HTTPS on your private tailnet — no public ports.
version: "3.8"
services:
opencode:
image: smanx/opencode:latest
container_name: opencode
restart: unless-stopped
ports:
# localhost-only: tailnet access goes through the sidecar
- "127.0.0.1:4096:4096"
environment:
- OPENCODE_HOSTNAME=0.0.0.0
- OPENCODE_PORT=4096
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME}
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD}
volumes:
- opencode-config:/root/.config/opencode
- opencode-data:/root/.local/share/opencode:rw
- ~/projects:/workspace:rw
healthcheck:
test: ["CMD-SHELL", "curl -fsS -u \"$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD\" http://localhost:4096/global/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
ts-proxy:
image: tailscale/tailscale:latest
container_name: opencode-ts
hostname: opencode-web
restart: unless-stopped
environment:
- TS_STATE_DIR=/var/lib/tailscale
- TS_EXTRA_ARGS=--ssh=false
volumes:
- ts-state:/var/lib/tailscale
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
opencode:
condition: service_healthy
volumes:
opencode-config:
opencode-data:
ts-state:
Two details matter. First, the host port is bound to 127.0.0.1 only — remote access goes through the Tailscale sidecar, never a raw public port. Second, the health check sends basic auth, because the server requires it once you set a password (and you absolutely should).
The One-Way Sync + Path Flip
This is the heart of the whole approach. .backup produces a consistent snapshot even while the native CLI is running (it’s a read-only operation), so you never have to close your editor to refresh the web UI.
# 1. Consistent snapshot of the host DB (native CLI can stay running)
sqlite3 ~/.local/share/opencode/opencode.db ".backup '/tmp/web-sync.db'"
# 2. Flip Windows paths to /workspace on the COPY ONLY
sqlite3 /tmp/web-sync.db ".read fix_paths_to_workspace.sql"
# 3. Swap it into the container volume
docker compose down
docker run --rm -v opencode-data:/data -v /tmp:/sync \
--entrypoint sh smanx/opencode:latest \
-c "rm -f /data/opencode.db /data/opencode.db-wal /data/opencode.db-shm \
&& cp /sync/web-sync.db /data/opencode.db"
docker compose up -d
The path-flip SQL is deliberately blunt — it only touches the temp copy, so it can be aggressive:
-- Flip Windows host paths -> container workspace paths (temp copy only)
PRAGMA busy_timeout=30000;
update session set directory = replace(directory, '\', '/');
update session set directory = replace(directory, 'C:/Users/you/projects', '/workspace');
update session set directory = replace(directory, 'C:/Users/you', '/workspace');
update session set path = replace(path, '\', '/');
update session set path = replace(path, 'Users/you/projects', '/workspace');
update session set path = '/' || path where path '' and path not like '/%' and path not like 'C:%';
update session set path = '/workspace' where path = '';
update or ignore project set worktree = replace(worktree, '\', '/');
update or ignore project set worktree = replace(worktree, 'C:/Users/you/projects', '/workspace');
update or ignore project set worktree = '/' || worktree where worktree '' and worktree not like '/%';
update or ignore project_directory set directory = replace(directory, 'C:/Users/you/projects', '/workspace');
update or ignore project_directory set directory = '/' || directory where directory '' and directory not like '/%';
Because the web container’s database is just a copy, any new sessions you start there stay in the container volume. They don’t get merged back into your host database. For my workflow — web for reading history and quick edits, CLI for real work — that’s a feature, not a bug. If you ever want them back, you reverse the process.
Config That Saves You from a 3.5 GB Workspace
Two settings in the container’s opencode.jsonc made the difference between “usable” and “hang forever”:
{
"$schema": "https://opencode.ai/config.json",
"model": "deepseek/deepseek-v4-pro",
"small_model": "deepseek/deepseek-v4-flash",
"snapshot": false,
"disabled_providers": ["opencode"]
}
snapshot: false— OpenCode’s git change-snapshot was walking a massivenode_modulestree and hanging every message for minutes. Turning it off removed the hang; the trade-off is no undo/revert of agent file changes in the UI.disabled_providers— the free “OpenCode Zen” models rate-limit aggressively and retry forever, leaving you staring at “Thinking” with no reply. Disabling them and pointing at a real paid key got responses back down to a few seconds.
Tailscale Sidecar for Remote HTTPS
The sidecar joins your tailnet as a node and serves the web UI over HTTPS — no firewall rules, no exposed ports, no Let’s Encrypt choreography. On first boot, docker logs opencode-ts prints an auth URL to authorize the node, then you enable the serve config once:
docker exec opencode-ts tailscale serve --bg --yes http://opencode:4096
After that, the UI is available at https://opencode-web.<your-tailnet>.ts.net with the same basic-auth login as localhost. The config persists in the ts-state volume.
Gotchas I Hit So You Don’t Have To
| Symptom | Cause | Fix |
|---|---|---|
| Empty session list on first open | Sessions are scoped to the opened project | Add project → open /workspace |
Container disk I/O error | Stale -wal/-shm in volume | compose down, delete them, restart |
| “Thinking” forever after send | Free model 429 retry loop, or snapshot hang | Disable Zen models + snapshot: false |
| @-mention finds nothing | File index still building on a huge folder | Wait ~10 min after boot, retry |
| Proxy models missing in web | Host relay bound to 127.0.0.1 | Expose relay on LAN, use host.docker.internal |
Wrap-Up
The temptation with any self-hosted AI tool is to point everything at one shared database and call it a day. For OpenCode, that’s exactly how you corrupt three and a half gigabytes of session history. Separate the databases, sync one-way, and flip paths on the copy only — and you get a browser IDE that coexists peacefully with your terminal, reachable from anywhere on your tailnet.
This builds on the same OpenCode-in-Docker foundation covered in the Authentik guide. If you want SSO in front of the web UI rather than basic auth, start there.
Leave a Reply