Skip to content

Commit bdcc59d

Browse files
committed
Refactor devcontainer configuration to implement home-persist feature for user state persistence and update related documentation
1 parent 57bb2b6 commit bdcc59d

11 files changed

Lines changed: 418 additions & 238 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111
"remoteUser": "${localEnv:DEVCONTAINER_USERNAME:dev}",
1212
"containerUser": "${localEnv:DEVCONTAINER_USERNAME:dev}",
1313
"mounts": [
14-
"source=/mnt/devhome,target=/home/${localEnv:DEVCONTAINER_USERNAME:dev},type=bind"
14+
"source=/mnt/home-persist,target=/mnt/home-persist,type=bind"
1515
],
16-
"onCreateCommand": "test -z \"$(ls -A $HOME 2>/dev/null)\" && cp -rT /etc/skel $HOME || true",
1716
"features": {
1817
"ghcr.io/sourecode/devcontainer-features/nvm:2": {},
1918
"ghcr.io/sourecode/devcontainer-features/claude-code:2": {},
2019
"ghcr.io/sourecode/devcontainer-features/rtk:2": {},
21-
"ghcr.io/sourecode/devcontainer-features/context-mode:2": {}
20+
"ghcr.io/sourecode/devcontainer-features/context-mode:2": {},
21+
"ghcr.io/sourecode/devcontainer-features/home-persist:1": {}
2222
}
2323
}

README.md

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ that hosts the devcontainers these features get installed into — see
1414

1515
| Feature | OCI reference | Summary |
1616
|---|---|---|
17-
| `claude-code` | `ghcr.io/sourecode/devcontainer-features/claude-code:2` | Installs the Claude Code CLI via the official native installer into `/usr/local/bin`, so the binary survives home-directory volume mounts. Requires Node.js — automatically pulls in the `nvm` feature via `dependsOn`. |
18-
| `rtk` | `ghcr.io/sourecode/devcontainer-features/rtk:2` | Installs [rtk](https://github.com/rtk-ai/rtk), an LLM token-reducing CLI proxy, into `/usr/local/bin`. Auto-patches Claude Code via `postCreateCommand` so the hook is written against the mounted home, not the image. |
19-
| `context-mode` | `ghcr.io/sourecode/devcontainer-features/context-mode:2` | Installs the [`context-mode`](https://github.com/mksglu/context-mode) Claude Code plugin via `postCreateCommand`, so the plugin lands in the mounted `~/.claude` rather than the image. |
17+
| `claude-code` | `ghcr.io/sourecode/devcontainer-features/claude-code:2` | Installs the Claude Code CLI via the official native installer into `/usr/local/bin`. Declares `~/.claude` and `~/.claude.json` as persistence targets via the `home-persist` manifest. Requires Node.js — automatically pulls in the `nvm` feature via `dependsOn`. |
18+
| `rtk` | `ghcr.io/sourecode/devcontainer-features/rtk:2` | Installs [rtk](https://github.com/rtk-ai/rtk), an LLM token-reducing CLI proxy, into `/usr/local/bin`. Auto-patches Claude Code via `postCreateCommand` so the hook is written against the live `~/.claude`, not the image. |
19+
| `context-mode` | `ghcr.io/sourecode/devcontainer-features/context-mode:2` | Installs the [`context-mode`](https://github.com/mksglu/context-mode) Claude Code plugin via `postCreateCommand`, so the plugin lands in `~/.claude/plugins` (which `home-persist` symlinks into the persistence volume when installed). |
20+
| `home-persist` | `ghcr.io/sourecode/devcontainer-features/home-persist:1` | Symlinks declared `$HOME` paths into a per-owner persistence volume at `/mnt/home-persist`. Features and users contribute paths via JSON manifests in `/etc/devcontainer-persist.d/`; an `onCreateCommand` resolver materializes the symlinks on every create. |
2021
| `nvm` | `ghcr.io/sourecode/devcontainer-features/nvm:2` | Installs [nvm](https://github.com/nvm-sh/nvm) system-wide at `/usr/local/share/nvm` and optionally a Node version (defaults to LTS), with `node`/`npm`/`npx` symlinked into `/usr/local/bin`. No yarn. |
2122

22-
All binaries land in `/usr/local/bin` (or `/usr/local/share/...`) rather than the user's home, so they survive the shared home-volume pattern described in [`docs/persistence.md`](docs/persistence.md). `rtk` and `context-mode` declare `installsAfter` for both `ghcr.io/sourecode/devcontainer-features/claude-code` and `ghcr.io/anthropics/devcontainer-features/claude-code`, so the runtime orders them after whichever claude-code feature is present.
23+
All binaries land in `/usr/local/bin` (or `/usr/local/share/...`) rather than the user's home, so they stay image-owned. Per-user state that needs to survive rebuilds is declared explicitly via the `home-persist` manifest — see [`docs/persistence.md`](docs/persistence.md). `rtk` and `context-mode` declare `installsAfter` for both `ghcr.io/sourecode/devcontainer-features/claude-code` and `ghcr.io/anthropics/devcontainer-features/claude-code`, so the runtime orders them after whichever claude-code feature is present.
2324

2425
## Using the features
2526

@@ -72,12 +73,24 @@ claude-code feature as well. `installsAfter` handles ordering for either
7273
| `version` | string | `0.40.4` | nvm release tag to install (without the leading `v`). |
7374
| `node` | string | `lts` | Node version to install via nvm. `lts` uses `nvm install --lts`. `none` skips node install. Anything else is passed as-is to `nvm install`. |
7475

76+
#### `home-persist`
77+
78+
| Option | Type | Default | Purpose |
79+
|---|---|---|---|
80+
| `paths` | string | `""` | Comma-separated list of `$HOME`-relative paths to persist (e.g. `.claude,.claude.json,.gitconfig`). Written to `/etc/devcontainer-persist.d/user.json` at build time. Leave empty if you only want features to contribute paths. |
81+
82+
Requires a bind mount from a persistent source to `/mnt/home-persist` in
83+
`devcontainer.json`. See [`docs/persistence.md`](docs/persistence.md) for
84+
the full model.
85+
7586
### Persisting Claude Code state
7687

7788
Claude login (`~/.claude/.credentials.json`) and chat history (`projects/`,
78-
`sessions/`, `session-env/`) live in the user's home. Persist them by mounting
79-
`$HOME` as a named volume — see [`docs/persistence.md`](docs/persistence.md)
80-
for the shared-home pattern we use across every devcontainer.
89+
`sessions/`, `session-env/`) live in `~/.claude`. The `claude-code` feature
90+
declares `.claude` and `.claude.json` in its manifest, so installing
91+
`home-persist` alongside it — plus bind-mounting a persistent source to
92+
`/mnt/home-persist` — is enough to carry state across rebuilds. See
93+
[`docs/persistence.md`](docs/persistence.md) for the full model.
8194

8295
## Coder workspace template
8396

@@ -268,6 +281,10 @@ src/
268281
context-mode/
269282
devcontainer-feature.json
270283
install.sh
284+
home-persist/
285+
devcontainer-feature.json
286+
install.sh
287+
resolve.sh
271288
nvm/
272289
devcontainer-feature.json
273290
install.sh
@@ -289,19 +306,32 @@ that runs as `root` inside the container during the build.
289306

290307
- `install.sh` starts as `root`. **Prefer system-wide install paths**
291308
(`/usr/local/bin`, `/usr/local/share/<id>`, `/etc/profile.d`) over anything
292-
under the remote user's home. The shared-home volume pattern
293-
([`docs/persistence.md`](docs/persistence.md)) means writes into
294-
`/home/<user>` at build time only appear on first-create and then get
295-
shadowed by the named volume on every subsequent run.
296-
- If a tool's upstream installer insists on writing to `$HOME`, run it under
297-
a scratch `HOME` (`mktemp -d`) and relocate the resulting binary to
298-
`/usr/local/bin` (see `src/claude-code/install.sh`). If the tool supports
299-
an override env var (e.g. `RTK_INSTALL_DIR`), pass it directly.
309+
under the remote user's home. Paths under `$HOME` that the feature needs
310+
to persist across rebuilds should be declared via a `home-persist`
311+
manifest (see below), not written at build time — the symlinks don't
312+
exist yet during `install.sh`.
313+
- If a tool's upstream installer insists on writing to `$HOME`, relocate
314+
the resulting binary to `/usr/local/bin` (see `src/claude-code/install.sh`).
315+
If the tool supports an override env var (e.g. `RTK_INSTALL_DIR`), pass
316+
it directly.
300317
- For anything that genuinely needs to live in the user's real home
301318
(credentials, plugin state, shell-rc tweaks), emit a script to
302319
`/usr/local/share/<id>/post-create.sh` and wire it via `postCreateCommand`
303-
in `devcontainer-feature.json` so it runs against the mounted home, not
304-
the image.
320+
in `devcontainer-feature.json` so it runs after the `home-persist`
321+
resolver has symlinked the target paths into place.
322+
- If your feature writes persistent state under `$HOME`, declare those
323+
paths by dropping a JSON manifest in `install.sh`:
324+
325+
```bash
326+
mkdir -p /etc/devcontainer-persist.d
327+
cat > /etc/devcontainer-persist.d/<your-feature>.json <<'EOF'
328+
{ "source": "<your-feature>", "paths": [".your-tool"] }
329+
EOF
330+
```
331+
332+
The `home-persist` feature's `onCreateCommand` picks it up and symlinks
333+
each path into the persistence volume. See
334+
[`docs/persistence.md`](docs/persistence.md).
305335
- Feature options are exposed as **uppercased** environment variables (e.g.
306336
option `autoPatchClaude` → `$AUTOPATCHCLAUDE`). Always apply a default:
307337
`"${FOO:-true}"`.

docs/migration-guide.md

Lines changed: 74 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
# Devcontainer Migration Guide
22

3-
Guide to migrate an existing
4-
`.devcontainer/` setup onto the SoureCode conventions: persistent per-user
5-
home, dynamic user construct, and toolchain installed via features instead
6-
of inline Dockerfile install steps.
3+
Guide to migrate an existing `.devcontainer/` setup onto the SoureCode
4+
conventions: manifest-driven `$HOME` persistence via the `home-persist`
5+
feature, dynamic user construct, and toolchain installed via features
6+
instead of inline Dockerfile install steps.
77

88
## Goals of the migration
99

10-
1. Every devcontainer for the same Coder user shares one named volume mounted
11-
on the user's home directory. Bash history, git config, shell dotfiles,
12-
tool caches, Claude config, etc. persist across devcontainer rebuilds
13-
**and** across different projects.
10+
1. Per-user state that must survive rebuilds (Claude login, anything else
11+
explicitly declared) lives in a single per-owner volume at
12+
`/mnt/home-persist`. The `home-persist` feature symlinks those paths
13+
from `$HOME` into the volume on every create. Everything else stays
14+
image-owned and resets on rebuild.
1415
2. The container user / UID / GID is driven by `DEVCONTAINER_*` env vars so
1516
the same devcontainer works inside Coder (where the outer template sets
1617
these) and outside (sensible defaults).
@@ -26,8 +27,8 @@ An existing devcontainer that:
2627
image ships.
2728
- Has a Dockerfile with long inline installs for cmake / LLVM / sccache /
2829
Claude Code / RTK.
29-
- Uses ephemeral volumes or `${devcontainerId}`-scoped volumes (state dies
30-
on rebuild).
30+
- Uses ephemeral volumes, `${devcontainerId}`-scoped volumes, or a
31+
whole-`$HOME` bind mount.
3132
- Hard-codes paths like `/root/.claude`, `/root/.cache/sccache`.
3233

3334
## Output state (what you're producing)
@@ -48,10 +49,10 @@ An existing devcontainer that:
4849
"remoteUser": "${localEnv:DEVCONTAINER_USERNAME:dev}",
4950
"containerUser": "${localEnv:DEVCONTAINER_USERNAME:dev}",
5051
"mounts": [
51-
"source=/mnt/devhome,target=/home/${localEnv:DEVCONTAINER_USERNAME:dev},type=bind"
52+
"source=/mnt/home-persist,target=/mnt/home-persist,type=bind"
5253
],
53-
"onCreateCommand": "test -z \"$(ls -A $HOME 2>/dev/null)\" && cp -rT /etc/skel $HOME || true",
5454
"features": {
55+
"ghcr.io/sourecode/devcontainer-features/home-persist:1": {},
5556
// pick from the feature reference below
5657
},
5758
"customizations": {
@@ -63,19 +64,19 @@ An existing devcontainer that:
6364
Rules:
6465

6566
- `name` — keep the project's existing name.
66-
- Mount **source** is **always** `/mnt/devhome`, mount **type** is
67-
**always** `bind`. That path is the per-owner home volume mounted into
68-
every outer Coder workspace (see `main.tf`'s `docker_volume "devhome"`).
69-
Do **not** introduce per-project volume names — the whole point is that
70-
every project shares one home per owner.
71-
- `onCreateCommand` is required. Bind mounts don't auto-seed from the
72-
image, so this line copies `/etc/skel` into `$HOME` on the first-ever
73-
create and no-ops on every subsequent create. See
74-
[`persistence.md`](persistence.md#seeding-on-first-create).
67+
- Mount **source** and **target** are both `/mnt/home-persist`, mount
68+
**type** is **bind**. That path is the per-owner persistence volume
69+
mounted into every outer Coder workspace (see `main.tf`'s
70+
`docker_volume "home_persist"`). Do **not** introduce per-project volume
71+
names — the whole point is that every project shares one volume per
72+
owner.
73+
- The `home-persist` feature is required whenever any feature declares a
74+
persistence manifest (e.g. `claude-code` declares `.claude` and
75+
`.claude.json`). Without it, the symlinks are never created and state
76+
resets on rebuild.
7577
- `remoteUser` and `containerUser` both use `${localEnv:DEVCONTAINER_USERNAME:dev}`.
76-
- Do **not** set `CLAUDE_CONFIG_DIR` or `SCCACHE_DIR` in `containerEnv`
77-
their defaults (`~/.claude`, `~/.cache/sccache`) already land inside the
78-
persistent home volume.
78+
- Do **not** set `CLAUDE_CONFIG_DIR` in `containerEnv``home-persist`
79+
already routes `~/.claude` through the volume.
7980
- `customizations` — preserve whatever the project had (JetBrains backend,
8081
VS Code settings/extensions, etc.).
8182

@@ -119,28 +120,28 @@ Rules:
119120
- **Remove** any inline installs that are now handled by features
120121
(see next section). Also remove:
121122
- Manual `.bashrc` history-persistence hacks tied to `/commandhistory` or
122-
`/root/.bash_history`home is on the volume now, bash history
123-
persists naturally at `~/.bash_history`.
123+
`/root/.bash_history``$HOME` is image-owned, bash history resets on
124+
rebuild unless you add `.bash_history` to a `home-persist` manifest.
124125
- Hard-coded `CC=clang-22` / `CXX=clang++-22` — the `llvm` feature sets
125126
these via `containerEnv`.
126-
- Hard-coded `SCCACHE_DIR` — default already works.
127-
- If the project sets shell-level config (`HISTSIZE`, `PATH` additions, etc.)
128-
put it in `/etc/bash.bashrc` (system-wide, sourced by non-login interactive
129-
bashes on Debian/Ubuntu) **not** `~/.bashrc`. The home dir is a volume —
130-
`~/.bashrc` only gets seeded on first-create and diverges from the image
131-
on rebuilds.
127+
- Hard-coded `CLAUDE_CONFIG_DIR` / `SCCACHE_DIR``home-persist` routes
128+
the paths the declared features need.
129+
- Shell-level config (`HISTSIZE`, `PATH` additions, aliases) goes in the
130+
Dockerfile (via `/etc/bash.bashrc` or similar) since `$HOME` is now
131+
image-owned and doesn't drift.
132132

133133
## Available features
134134

135135
Reference: `ghcr.io/sourecode/devcontainer-features/<id>:<major-version>`
136136

137137
| Feature | Purpose | Notable options |
138138
|---|---|---|
139+
| `home-persist` | Manifest-driven `$HOME` persistence into `/mnt/home-persist` | `paths` (comma-separated) |
139140
| `cmake` | CMake from Kitware GitHub releases, distro-agnostic | `version` (default `latest`) |
140141
| `llvm` | Clang/LLVM via `apt.llvm.org`. Sets `CC`/`CXX` in containerEnv. | `version` (default `22`), `all` (default `true`) |
141142
| `sccache` | Mozilla sccache from GitHub releases | `version` (default `latest`) |
142143
| `nvm` | NVM + optional Node install | `version`, `node` (default `lts`) |
143-
| `claude-code` | Anthropic Claude Code CLI ||
144+
| `claude-code` | Anthropic Claude Code CLI. Declares `.claude` + `.claude.json` in the home-persist manifest. ||
144145
| `rtk` | RTK CLI ||
145146
| `context-mode` | Context-mode integration ||
146147

@@ -167,10 +168,26 @@ Typical `features` block for a C++ project:
167168
"ghcr.io/sourecode/devcontainer-features/nvm:2": {},
168169
"ghcr.io/sourecode/devcontainer-features/claude-code:2": {},
169170
"ghcr.io/sourecode/devcontainer-features/rtk:2": {},
170-
"ghcr.io/sourecode/devcontainer-features/context-mode:2": {}
171+
"ghcr.io/sourecode/devcontainer-features/context-mode:2": {},
172+
"ghcr.io/sourecode/devcontainer-features/home-persist:1": {}
171173
}
172174
```
173175

176+
### Adding project-local paths to persistence
177+
178+
If the project has its own `$HOME` state to persist beyond what features
179+
declare, list it on `home-persist`:
180+
181+
```jsonc
182+
"ghcr.io/sourecode/devcontainer-features/home-persist:1": {
183+
"paths": ".gitconfig,.bash_history,.config/my-tool"
184+
}
185+
```
186+
187+
Paths are relative to `$HOME`. On first create, any existing content at
188+
those paths in the image gets moved into the volume; subsequent creates
189+
volume-win.
190+
174191
## Where env vars come from
175192

176193
- `DEVCONTAINER_USERNAME`, `DEVCONTAINER_USER_UID`, `DEVCONTAINER_USER_GID`,
@@ -180,44 +197,44 @@ Typical `features` block for a C++ project:
180197
at devcontainer startup.
181198
- Running the devcontainer locally on a laptop (no Coder): the `:fallback`
182199
defaults in each `${localEnv:NAME:fallback}` kick in. User ends up as
183-
`dev` at 1000:1000, volume is `devhome-shared`.
200+
`dev` at 1000:1000. For persistence to work, you'll need `/mnt/home-persist`
201+
to exist on the host — either create a directory or replace the mount
202+
source with a local path / named volume.
184203

185-
## Persistent home caveats
204+
## Persistence caveats
186205

187206
One volume per Coder *owner*, bind-mounted through every outer workspace
188207
they open. Shared across every devcontainer they open, in every workspace
189-
they open. Same bash history, same `~/.gitconfig`, same `~/.claude`,
190-
everywhere. Side effects to understand:
191-
192-
- Bind mounts don't auto-seed. The `onCreateCommand` in the template
193-
handles it: empty `$HOME` → copy `/etc/skel`, populated `$HOME` → no-op.
194-
- Put long-lived shell config in `/etc/bash.bashrc` (image-side) so it
195-
stays authoritative. `~/.bashrc` lives on the volume and is only
196-
populated once on the first-ever create; rebuilding the image later
197-
does **not** update it.
198-
- Tools that store env-specific state in `$HOME` (some pyenv/nvm layouts,
199-
`.cache/` bloat) can collide across projects. Usually fine for dotfiles
200-
and coarse caches; tune per-project if something misbehaves.
201-
- Running two workspaces simultaneously shares the same home (same
202-
situation as running two terminals on your laptop). Tools that tolerate
203-
concurrent writers cope fine; tools that don't behave as they would
204-
locally.
208+
they open — but only for paths explicitly declared in a manifest.
209+
210+
- `$HOME` itself is image-owned. `~/.bashrc`, `/etc/skel` contents, anything
211+
not in a manifest resets on rebuild. That's a feature — the image is the
212+
source of truth for shell config.
213+
- Two workspaces running simultaneously share the same volume for declared
214+
paths (same as any shared home). Tools that tolerate concurrent writers
215+
cope fine; tools that don't behave as they would locally. Only declare
216+
paths where cross-workspace sharing is what you actually want.
217+
- Resetting state: `docker volume rm coder-<owner>-home-persist`. Next
218+
create starts clean for declared paths; re-login to Claude Code etc.
219+
220+
See [`persistence.md`](persistence.md) for the full model.
205221

206222
## Checklist for the migrating agent
207223

208224
1. Edit `.devcontainer/devcontainer.json` to match the template above.
209-
Preserve `name` and `customizations` from the original.
225+
Preserve `name` and `customizations` from the original. Add
226+
`home-persist:1` to `features`.
210227
2. Edit `.devcontainer/Dockerfile`:
211228
- Add `ARG USERNAME / USER_UID / USER_GID`.
212229
- Fold user creation into the apt `RUN`.
213230
- Remove inline installs that are covered by features.
214-
- Move any `.bashrc` shell config to `/etc/bash.bashrc`.
231+
- Drop any `CLAUDE_CONFIG_DIR` / `SCCACHE_DIR` overrides.
215232
- End with `USER ${USERNAME}`.
216233
3. Remove obsolete files: standalone `bashrc`-patching scripts,
217-
`/commandhistory` directory hacks, hard-coded `CLAUDE_CONFIG_DIR` /
218-
`SCCACHE_DIR` env vars.
234+
`/commandhistory` directory hacks, hard-coded config-dir env vars.
219235
4. Build-test the devcontainer locally:
220236
`devcontainer up --workspace-folder .` → should succeed, drop into the
221237
non-root user's shell, and have `cmake`, `clang`, `sccache`, `claude`,
222-
`rtk` on `$PATH`.
238+
`rtk` on `$PATH`. `ls -la ~/.claude ~/.claude.json` should show symlinks
239+
into `/mnt/home-persist`.
223240
5. Commit.

0 commit comments

Comments
 (0)