Skip to content

Commit dcfd313

Browse files
pRizzclaude
andcommitted
fix(docker): persist SSH keys and workspace across container recreations
Repos were lost on reinstall because workspace.root defaulted to /home/opencoder/opencode (ephemeral) instead of /home/opencoder/workspace (the persistent mount). SSH keys were lost because ~/.ssh had no volume. Changes: - Add 7th Docker volume (opencode-ssh) for /home/opencoder/.ssh with host bind mount at ~/.local/share/opencode-cloud/ssh - Set workspace.root to /home/opencoder/workspace via opencode.jsonc - Move workspace host bind mount from ~/.local/share/opencode/workspace to ~/opencode (matches upstream opencode default) - Move config host bind mount from ~/.config/opencode to ~/.config/opencode-cloud/opencode to avoid colliding with upstream - Seed config from baked-in template at /etc/opencode-cloud/ to survive volume mounts that hide the Dockerfile COPY'd config - Add docs/persistence.md as authoritative reference for volumes, host paths, backup strategies, and design rationale - Update deploy docs (docker-desktop, railway, digitalocean) with SSH volume in tables and commands Breaking change: existing workspace data at ~/.local/share/opencode/workspace and config at ~/.config/opencode must be migrated manually. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 343d84e commit dcfd313

15 files changed

Lines changed: 331 additions & 23 deletions

File tree

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ services:
3636
- opencode-workspace:/home/opencoder/workspace
3737
- opencode-config:/home/opencoder/.config/opencode
3838
- opencode-users:/var/lib/opencode-users
39+
- opencode-ssh:/home/opencoder/.ssh
3940

4041
volumes:
4142
opencode-data:
@@ -44,3 +45,4 @@ volumes:
4445
opencode-workspace:
4546
opencode-config:
4647
opencode-users:
48+
opencode-ssh:

docs/deploy/digitalocean-droplet.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ The current default is `"auto"`; setting `trustProxy: true` explicitly is still
202202
This file lives on the host bind-mount. If you ran `occ` as `root`, it is
203203
typically:
204204

205-
- `/root/.config/opencode/opencode.jsonc`
205+
- `/root/.config/opencode-cloud/opencode/opencode.jsonc`
206206

207207
Example config:
208208

@@ -232,7 +232,7 @@ Use this if you don't want `occ` installed on the Droplet.
232232
233233
### Path 2A: Docker Compose (Recommended)
234234

235-
Docker Compose configures all 6 named volumes automatically.
235+
Docker Compose configures all 7 named volumes automatically.
236236

237237
The quickest way is the one-liner script, which installs Docker, by default refreshes the Compose file from upstream (with backup if your local copy differs), pulls the latest image, reconciles services, and prints the IOTP:
238238

@@ -331,6 +331,7 @@ docker volume create opencode-cache
331331
docker volume create opencode-workspace
332332
docker volume create opencode-config
333333
docker volume create opencode-users
334+
docker volume create opencode-ssh
334335
```
335336

336337
#### 3) Run the container (SSH tunnel default: bind host port to localhost)
@@ -345,6 +346,7 @@ docker run -d --name opencode-cloud-sandbox \
345346
-v opencode-workspace:/home/opencoder/workspace \
346347
-v opencode-config:/home/opencoder/.config/opencode \
347348
-v opencode-users:/var/lib/opencode-users \
349+
-v opencode-ssh:/home/opencoder/.ssh \
348350
prizz/opencode-cloud-sandbox:15.2.0
349351
```
350352

docs/deploy/docker-desktop.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ docker volume create opencode-cache
7777
docker volume create opencode-workspace
7878
docker volume create opencode-config
7979
docker volume create opencode-users
80+
docker volume create opencode-ssh
8081
```
8182

8283
### 2) Run the container
@@ -92,6 +93,7 @@ docker run -d --name opencode-cloud-sandbox \
9293
-v opencode-workspace:/home/opencoder/workspace \
9394
-v opencode-config:/home/opencoder/.config/opencode \
9495
-v opencode-users:/var/lib/opencode-users \
96+
-v opencode-ssh:/home/opencoder/.ssh \
9597
prizz/opencode-cloud-sandbox:latest
9698
```
9799

@@ -149,6 +151,7 @@ The container uses these paths for persistent data:
149151
| `/home/opencoder/workspace` | `opencode-workspace` | Project files |
150152
| `/home/opencoder/.config/opencode` | `opencode-config` | Configuration |
151153
| `/var/lib/opencode-users` | `opencode-users` | User accounts |
154+
| `/home/opencoder/.ssh` | `opencode-ssh` | SSH keys |
152155

153156
### What Survives What
154157

@@ -164,9 +167,12 @@ The container uses these paths for persistent data:
164167
**Recommendation:** Always use named volumes (via Docker Compose or `-v` flags)
165168
for data you want to keep.
166169

170+
For backup instructions, host path details, and design rationale, see
171+
[Persistence and Data Storage](../persistence.md).
172+
167173
### Anonymous Volume Fallback
168174

169-
The Docker image declares `VOLUME` directives for all six persistent paths.
175+
The Docker image declares `VOLUME` directives for all seven persistent paths.
170176
If you run the container without explicit `-v` flags (e.g., clicking "Run" in
171177
Docker Desktop), Docker creates anonymous volumes automatically. This protects
172178
against accidental data loss on container restarts, but anonymous volumes are

docs/deploy/railway.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ The container uses these paths for persistent data:
131131
|------|---------|----------|
132132
| `/home/opencoder/.local/share/opencode` | Session data, project storage, application state | **Critical** |
133133
| `/home/opencoder/workspace` | Project files (working directory) | High |
134+
| `/home/opencoder/.ssh` | SSH keys | High |
134135
| `/var/lib/opencode-users` | User account records (password hashes, lock status) | High |
135136
| `/home/opencoder/.config/opencode` | opencode configuration | Medium |
136137
| `/home/opencoder/.local/state/opencode` | Application state | Medium |
@@ -141,7 +142,10 @@ With Railway's single-volume limitation, mounting
141142
in the other paths will be reset on each redeploy unless additional
142143
persistence is configured.
143144

144-
The Docker image declares `VOLUME` directives for all six paths, which
145+
For the full volume reference, backup instructions, and design rationale, see
146+
[Persistence and Data Storage](../persistence.md).
147+
148+
The Docker image declares `VOLUME` directives for all seven paths, which
145149
provides anonymous volume persistence across container **restarts**. However,
146150
anonymous volumes do **not** survive container **recreation** (which is what
147151
Railway does on each redeploy). That is why the explicit Railway Volume is

docs/persistence.md

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# Persistence and Data Storage
2+
3+
This document is the authoritative reference for how opencode-cloud persists
4+
data across container restarts, updates, and recreations. It covers the volume
5+
architecture, host paths, backup strategies, and design rationale.
6+
7+
## Two Persistence Modes
8+
9+
opencode-cloud supports two ways to persist container data:
10+
11+
| Mode | When it's used | How data is stored |
12+
|------|---------------|-------------------|
13+
| **Named Docker volumes** | Docker Compose (`docker-compose.yml`), `docker run -v` | Docker manages storage on the host; paths are opaque |
14+
| **Bind mounts** | `occ start` (the CLI) | Host directories are mounted directly into the container |
15+
16+
With **named volumes** (Docker Compose / `docker run`), Docker manages the
17+
storage location. You interact with volumes by name (e.g., `opencode-data`)
18+
rather than by host path.
19+
20+
With **bind mounts** (CLI mode), the CLI creates directories on the host and
21+
mounts them into the container. You can see and back up these directories
22+
directly.
23+
24+
Both modes mount to the same container paths, so the application behaves
25+
identically regardless of which mode you use.
26+
27+
## Volume Reference
28+
29+
The container uses 7 persistent storage locations:
30+
31+
| Volume Name | Container Path | CLI Bind Mount (Host Path) | Contents | Backup Priority |
32+
|-------------|---------------|---------------------------|----------|----------------|
33+
| `opencode-data` | `/home/opencoder/.local/share/opencode` | `~/.local/share/opencode` | Session data, project metadata, SSH key metadata | Critical |
34+
| `opencode-workspace` | `/home/opencoder/workspace` | `~/opencode` | Cloned repositories, project files | Critical |
35+
| `opencode-ssh` | `/home/opencoder/.ssh` | `~/.local/share/opencode-cloud/ssh` | SSH private/public keys, SSH config | High |
36+
| `opencode-users` | `/var/lib/opencode-users` | *(named volume only)* | User account records (password hashes, lock status) | High |
37+
| `opencode-config` | `/home/opencoder/.config/opencode` | `~/.config/opencode-cloud/opencode` | opencode app configuration (auth settings, workspace root) | Medium |
38+
| `opencode-state` | `/home/opencoder/.local/state/opencode` | `~/.local/state/opencode` | Application state, update command files | Medium |
39+
| `opencode-cache` | `/home/opencoder/.cache/opencode` | `~/.cache/opencode` | Cache data (regenerable) | Low |
40+
41+
**Note:** `opencode-users` has no default bind mount in CLI mode. It always
42+
uses a named Docker volume because `/var/lib/opencode-users` is a system path
43+
managed internally by the CLI. Users don't need direct filesystem access to
44+
this data.
45+
46+
## What Survives What
47+
48+
| Scenario | Named Volumes | Anonymous Volumes |
49+
|----------|--------------|-------------------|
50+
| `docker restart` | Preserved | Preserved |
51+
| `docker stop` + `docker start` | Preserved | Preserved |
52+
| `docker rm` + `docker run` | Preserved | **Lost** |
53+
| Image update (`docker pull` + recreate) | Preserved | **Lost** |
54+
| `docker compose down` | Preserved | Preserved |
55+
| `docker compose down -v` | **Lost** | **Lost** |
56+
| `occ update container` | Preserved | N/A (CLI uses bind mounts) |
57+
| `occ reset container` | Preserved | N/A |
58+
| `occ reset container --volumes` | **Lost** | N/A |
59+
60+
**Recommendation:** Always use named volumes (Docker Compose or `-v` flags) or
61+
the CLI with bind mounts for data you want to keep. The Docker image declares
62+
`VOLUME` directives for all 7 paths as an anonymous volume fallback, but
63+
anonymous volumes do not survive container removal.
64+
65+
## Non-Persistent Paths
66+
67+
Any container path **not listed above** is ephemeral. Data written to
68+
unlisted paths is lost when the container is removed or recreated.
69+
70+
The entrypoint detects non-persistent paths at startup and logs a warning if
71+
any critical path lacks persistent storage. Look for
72+
`WARNING: Persistence is not configured` in container logs.
73+
74+
## Backing Up Your Data
75+
76+
### Bind Mount Mode (CLI)
77+
78+
When using the CLI (`occ start`), data lives in regular host directories. Back
79+
them up with any file-level backup tool:
80+
81+
```bash
82+
# Back up all critical data
83+
tar czf opencode-backup-$(date +%Y%m%d).tar.gz \
84+
~/.local/share/opencode \
85+
~/opencode \
86+
~/.local/share/opencode-cloud/ssh \
87+
~/.config/opencode-cloud/opencode \
88+
~/.local/state/opencode
89+
```
90+
91+
The `opencode-users` volume is managed by Docker even in CLI mode. To back it
92+
up, use the named volume method below.
93+
94+
### Named Volume Mode (Docker Compose / `docker run`)
95+
96+
Docker named volumes are not directly accessible as host directories. Use a
97+
temporary container to extract the data:
98+
99+
```bash
100+
# Back up a single volume
101+
docker run --rm \
102+
-v opencode-data:/data:ro \
103+
-v "$(pwd)":/backup \
104+
alpine tar czf /backup/opencode-data.tar.gz -C /data .
105+
106+
# Back up all 7 volumes
107+
for vol in opencode-data opencode-workspace opencode-ssh \
108+
opencode-users opencode-config opencode-state \
109+
opencode-cache; do
110+
docker run --rm \
111+
-v "${vol}":/data:ro \
112+
-v "$(pwd)":/backup \
113+
alpine tar czf "/backup/${vol}.tar.gz" -C /data .
114+
done
115+
```
116+
117+
### Restoring from Backup
118+
119+
**Bind mount mode:** Extract the archive back to the original host paths:
120+
121+
```bash
122+
tar xzf opencode-backup-20250101.tar.gz -C /
123+
```
124+
125+
**Named volume mode:** Stop the container first, then restore:
126+
127+
```bash
128+
docker compose down # or: docker stop opencode-cloud-sandbox
129+
130+
for vol in opencode-data opencode-workspace opencode-ssh \
131+
opencode-users opencode-config opencode-state \
132+
opencode-cache; do
133+
docker run --rm \
134+
-v "${vol}":/data \
135+
-v "$(pwd)":/backup \
136+
alpine sh -c "rm -rf /data/* && tar xzf /backup/${vol}.tar.gz -C /data"
137+
done
138+
139+
docker compose up -d # or: docker start opencode-cloud-sandbox
140+
```
141+
142+
## Design Rationale
143+
144+
### Why `~/opencode` for the workspace host path
145+
146+
Upstream opencode's default `workspace.root` is `~/opencode`. By using the
147+
same host path for the CLI bind mount, users who previously ran vanilla
148+
upstream opencode locally will see their existing repos immediately when
149+
switching to opencode-cloud. The host `~/opencode/` maps to the container's
150+
`/home/opencoder/workspace/`, and the container's `workspace.root` config
151+
points there, so clones land on the persistent mount.
152+
153+
### Why SSH keys are isolated from the host's `~/.ssh`
154+
155+
The default CLI bind mount for SSH is `~/.local/share/opencode-cloud/ssh`
156+
rather than the host's `~/.ssh` directory. This is intentional:
157+
158+
1. **Security.** The container runs AI-generated code. If that code (or a
159+
malicious package) reads files in the mounted SSH directory, an isolated
160+
path limits exposure to only keys generated inside the sandbox rather than
161+
the user's entire SSH keyring (which may include keys for production
162+
servers, infrastructure, etc.).
163+
164+
2. **Write safety.** The opencode app writes to `~/.ssh/config` and creates
165+
keys under `~/.ssh/opencode/`. If the container modified the host's real
166+
`~/.ssh/config`, it could break the user's existing SSH setup. SSH config
167+
parsing is order-sensitive; an opencode-added `Host *` block could shadow
168+
existing entries.
169+
170+
3. **Permission compatibility.** SSH enforces strict permissions (`~/.ssh`
171+
must be 700, keys must be 600, owned by the correct user). The container
172+
runs as `opencoder` (UID 1000), but the host user may have a different
173+
UID. A bind mount preserves host UID ownership, so the container process
174+
can't read/write the files unless UIDs happen to match. This would
175+
silently fail on many setups.
176+
177+
4. **macOS Docker Desktop.** Docker Desktop's Linux VM emulates file
178+
permissions for bind mounts inconsistently across versions, making host
179+
`~/.ssh` mounts unreliable for SSH's strict permission checks.
180+
181+
**Power-user escape hatch:** If you want the container to use your host SSH
182+
keys directly, you can override the mount:
183+
184+
```bash
185+
occ mount add ~/.ssh:/home/opencoder/.ssh
186+
occ restart
187+
```
188+
189+
This replaces the default isolated mount with a direct bind to your host's
190+
`~/.ssh`. Use this if you understand the security implications and want
191+
seamless access to your existing keys.
192+
193+
### Why the config host path is `~/.config/opencode-cloud/opencode`
194+
195+
The config bind mount uses `~/.config/opencode-cloud/opencode` rather than
196+
`~/.config/opencode` (the upstream opencode config directory). This prevents
197+
the container's entrypoint from modifying an upstream opencode installation's
198+
config if the user runs both locally. The entrypoint patches config files
199+
(enabling auth, setting `workspace.root`), and writing those changes to
200+
`~/.config/opencode` would alter the user's standalone opencode setup. The
201+
`opencode-cloud` subdirectory keeps our fork's config isolated, consistent
202+
with other fork-owned host paths (`~/.config/opencode-cloud/config.json` for
203+
CLI config, `~/.local/share/opencode-cloud/ssh` for SSH keys).
204+
205+
### Why `opencode-users` has no default bind mount
206+
207+
The `/var/lib/opencode-users` path is a system-level directory managed
208+
internally by the CLI for user account records (JSON files with password
209+
hashes and lock status). It uses a named Docker volume rather than a bind
210+
mount because there is no meaningful host-side equivalent, and the CLI manages
211+
these records programmatically.
212+
213+
## Platform-Specific Notes
214+
215+
### Railway
216+
217+
Railway currently supports one volume per service. Mount it at
218+
`/home/opencoder/.local/share/opencode` to cover the most critical data.
219+
Other paths reset on each redeploy. See [Railway deployment guide](deploy/railway.md)
220+
for details.
221+
222+
### Docker Desktop (GUI)
223+
224+
Clicking "Run" in Docker Desktop without configuring named volumes creates
225+
anonymous volumes. These survive container restarts but are lost if the
226+
container is removed. For durable persistence, use Docker Compose or the CLI
227+
method with named `-v` flags. See [Docker Desktop guide](deploy/docker-desktop.md).
228+
229+
### Isolated Sandbox Instances
230+
231+
When using `--sandbox-instance` (worktree isolation), volume names are
232+
suffixed with the instance ID (e.g., `opencode-data-mytree`,
233+
`opencode-ssh-mytree`). Each instance has its own independent set of 7
234+
volumes.

packages/cli-rust/src/commands/status.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ use opencode_cloud_core::Config;
2929
use opencode_cloud_core::bollard::service::MountTypeEnum;
3030
use opencode_cloud_core::config;
3131
use opencode_cloud_core::docker::{
32-
MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE, OPENCODE_WEB_PORT,
33-
ParsedMount, active_resource_names, get_cli_version, get_image_version, load_state,
32+
MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_SSH, MOUNT_STATE,
33+
OPENCODE_WEB_PORT, ParsedMount, active_resource_names, get_cli_version, get_image_version,
34+
load_state,
3435
};
3536
use opencode_cloud_core::platform::{get_service_manager, is_service_registration_supported};
3637
use std::collections::HashMap;
@@ -780,6 +781,7 @@ fn mount_purpose(target: &str) -> Option<&'static str> {
780781
MOUNT_CACHE => Some("opencode cache"),
781782
MOUNT_PROJECTS => Some("workspace files"),
782783
MOUNT_CONFIG => Some("opencode config"),
784+
MOUNT_SSH => Some("SSH keys"),
783785
_ => None,
784786
}
785787
}

packages/cli-rust/src/wizard/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ fn display_mounts_info(step: usize, total: usize, mounts: &[String]) -> Result<(
142142
return Ok(());
143143
}
144144

145-
println!("Persist opencode data, state, cache, workspace, and config using these mounts:");
145+
println!(
146+
"Persist opencode data, state, cache, workspace, config, and SSH keys using these mounts:"
147+
);
146148
println!();
147149
for mount in mounts {
148150
println!(" {}", style(mount).cyan());

0 commit comments

Comments
 (0)