Run OpenCode Web and the CLI Side by Side Without Corrupting Your Sessions

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:

  1. 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 in disk I/O error and a corrupted database.
  2. 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

ComponentLocationPaths
Native CLI (host)~/.local/share/opencodeWindows paths (C:/Users/you/...)
Web container DBnamed volume opencode-data/workspace paths (flipped copy)
Workspace bind mount~/projects/workspaceread-write
Web confignamed volume opencode-configcontainer-only
Web authcopied 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 massive node_modules tree 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

SymptomCauseFix
Empty session list on first openSessions are scoped to the opened projectAdd project → open /workspace
Container disk I/O errorStale -wal/-shm in volumecompose down, delete them, restart
“Thinking” forever after sendFree model 429 retry loop, or snapshot hangDisable Zen models + snapshot: false
@-mention finds nothingFile index still building on a huge folderWait ~10 min after boot, retry
Proxy models missing in webHost relay bound to 127.0.0.1Expose 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.


Posted

in

, ,

by

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *