Skip to content

Commit b4b6486

Browse files
committed
Update devcontainer configuration to use bind mounts for home directory and add onCreateCommand for seeding
1 parent bd33fbc commit b4b6486

3 files changed

Lines changed: 132 additions & 56 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
"remoteUser": "${localEnv:DEVCONTAINER_USERNAME:dev}",
1212
"containerUser": "${localEnv:DEVCONTAINER_USERNAME:dev}",
1313
"mounts": [
14-
"source=devhome-${localEnv:OWNER_USERNAME:shared},target=/home/${localEnv:DEVCONTAINER_USERNAME:dev},type=volume"
14+
"source=/mnt/devhome,target=/home/${localEnv:DEVCONTAINER_USERNAME:dev},type=bind"
1515
],
16+
"onCreateCommand": "test -z \"$(ls -A $HOME 2>/dev/null)\" && cp -rT /etc/skel $HOME || true",
1617
"features": {
1718
"ghcr.io/sourecode/devcontainer-features/nvm:2": {},
1819
"ghcr.io/sourecode/devcontainer-features/claude-code:2": {},

docs/migration-guide.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@ An existing devcontainer that:
4848
"remoteUser": "${localEnv:DEVCONTAINER_USERNAME:dev}",
4949
"containerUser": "${localEnv:DEVCONTAINER_USERNAME:dev}",
5050
"mounts": [
51-
"source=devhome-${localEnv:OWNER_USERNAME:shared},target=/home/${localEnv:DEVCONTAINER_USERNAME:dev},type=volume"
51+
"source=/mnt/devhome,target=/home/${localEnv:DEVCONTAINER_USERNAME:dev},type=bind"
5252
],
53+
"onCreateCommand": "test -z \"$(ls -A $HOME 2>/dev/null)\" && cp -rT /etc/skel $HOME || true",
5354
"features": {
5455
// pick from the feature reference below
5556
},
@@ -62,10 +63,15 @@ An existing devcontainer that:
6263
Rules:
6364

6465
- `name` — keep the project's existing name.
65-
- Volume name is **always** `devhome-${localEnv:OWNER_USERNAME:shared}`. Do
66-
**not** add the project name to the volume — the point is that every
67-
project shares one home per user. Fallback `:shared` kicks in when running
68-
the devcontainer locally outside Coder.
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).
6975
- `remoteUser` and `containerUser` both use `${localEnv:DEVCONTAINER_USERNAME:dev}`.
7076
- Do **not** set `CLAUDE_CONFIG_DIR` or `SCCACHE_DIR` in `containerEnv`
7177
their defaults (`~/.claude`, `~/.cache/sccache`) already land inside the
@@ -178,19 +184,24 @@ Typical `features` block for a C++ project:
178184

179185
## Persistent home caveats
180186

181-
One volume per Coder user, shared across every devcontainer they open. This
182-
is intentional — same bash history, same `~/.gitconfig`, same `~/.claude`
187+
One volume per Coder *owner*, bind-mounted through every outer workspace
188+
they open. Shared across every devcontainer they open, in every workspace
189+
they open. Same bash history, same `~/.gitconfig`, same `~/.claude`,
183190
everywhere. Side effects to understand:
184191

185-
- First-time volume creation seeds from the image's `/home/<user>` (Docker
186-
copies dir contents when an empty named volume is mounted over a
187-
non-empty target). Subsequent rebuilds use the volume; changes to the
188-
image's home dir will **not** propagate.
192+
- Bind mounts don't auto-seed. The `onCreateCommand` in the template
193+
handles it: empty `$HOME` → copy `/etc/skel`, populated `$HOME` → no-op.
189194
- Put long-lived shell config in `/etc/bash.bashrc` (image-side) so it
190-
stays authoritative.
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.
191198
- Tools that store env-specific state in `$HOME` (some pyenv/nvm layouts,
192199
`.cache/` bloat) can collide across projects. Usually fine for dotfiles
193200
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.
194205

195206
## Checklist for the migrating agent
196207

docs/persistence.md

Lines changed: 107 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# Persisting state across devcontainer rebuilds
22

3-
Our convention: mount a single named volume at the user's home directory,
4-
scoped per-Coder-user, shared across every devcontainer that user opens.
5-
Everything that lives under `$HOME` persists automatically — bash history,
6-
git config, SSH keys, tool caches, Claude Code state, sccache cache, pipx
7-
installs, anything.
3+
Our convention: a single per-owner home volume, bind-mounted through the
4+
outer Coder workspace into every devcontainer the owner opens. Everything
5+
under `$HOME` persists automatically — bash history, git config, SSH keys,
6+
tool caches, Claude Code state, sccache cache, pipx installs, anything —
7+
and it persists *across workspaces*, not just across rebuilds of one
8+
workspace.
89

910
## The mount
1011

@@ -13,20 +14,60 @@ In every `.devcontainer/devcontainer.json`:
1314
```jsonc
1415
{
1516
"mounts": [
16-
"source=devhome-${localEnv:OWNER_USERNAME:shared},target=/home/${localEnv:DEVCONTAINER_USERNAME:dev},type=volume"
17-
]
17+
"source=/mnt/devhome,target=/home/${localEnv:DEVCONTAINER_USERNAME:dev},type=bind"
18+
],
19+
"onCreateCommand": "test -z \"$(ls -A $HOME 2>/dev/null)\" && cp -rT /etc/skel $HOME || true"
1820
}
1921
```
2022

21-
- `OWNER_USERNAME` is injected by the Coder template (`coder_agent.main.env`)
22-
and resolves to the Coder workspace owner's username.
23-
- `DEVCONTAINER_USERNAME` is the in-container user (also from
23+
- `/mnt/devhome` is a stable path inside the outer Coder workspace where
24+
the per-owner home volume is mounted (see Topology below).
25+
- `DEVCONTAINER_USERNAME` is the in-container user (from
2426
`coder_agent.main.env`; default `dev`).
25-
- Running outside Coder, both fall back to sensible defaults —
26-
`devhome-shared` mounted at `/home/dev`.
27+
- `onCreateCommand` seeds the home from `/etc/skel` on first-ever attach.
28+
Bind mounts don't auto-seed from the image the way named volumes do, so
29+
the command copies skeleton dotfiles on the first workspace per owner
30+
and no-ops on every subsequent create.
31+
- Running outside Coder, change the mount `source` to a local path or a
32+
named volume — the rest of the devcontainer stays the same.
2733

28-
One volume per Coder user. Open three different project devcontainers and
29-
they all see the same home.
34+
One volume per Coder user. Open three different project devcontainers,
35+
open them from three different workspaces — they all see the same home.
36+
37+
## Topology
38+
39+
```
40+
┌───────────────────────────────────────────────────────────────────┐
41+
│ host dockerd │
42+
│ │
43+
│ docker volume: coder-<owner>-devhome ◄──── one per owner │
44+
│ │ │
45+
│ ├─► outer workspace A at /mnt/devhome │
46+
│ │ └─► inner devcontainer (bind) /home/<dev-user> │
47+
│ │ │
48+
│ ├─► outer workspace B at /mnt/devhome │
49+
│ │ └─► inner devcontainer (bind) /home/<dev-user> │
50+
│ │ │
51+
│ └─► outer workspace C at /mnt/devhome │
52+
│ └─► inner devcontainer (bind) /home/<dev-user> │
53+
└───────────────────────────────────────────────────────────────────┘
54+
```
55+
56+
The volume lives in the **host** dockerd, above any individual workspace.
57+
It's defined in the Coder template as `docker_volume "devhome"`, scoped
58+
by `coder_workspace_owner.me.name`, and bind-mounted into the outer
59+
workspace container at `/mnt/devhome`. Each inner devcontainer then
60+
bind-mounts that path as its own `/home/<user>`, so the filesystem every
61+
workspace's IDE and shell see is literally the same volume.
62+
63+
Properties that fall out of this layout:
64+
65+
- Survives workspace deletion. Blowing away a workspace doesn't touch
66+
`coder-<owner>-devhome`.
67+
- Survives `rebuild_no_cache`. That parameter only drops inner dockerd
68+
state (`vsc-*` images + BuildKit cache) — not the host-level volume.
69+
- Nothing in `docs/migration-guide.md`'s volume name changes when you
70+
rename a workspace. Identity is tied to the owner, not the workspace.
3071

3172
## What persists automatically
3273

@@ -43,55 +84,78 @@ Any path under `$HOME`. A non-exhaustive list that matters for us:
4384
| `~/.local/` | pipx installs, user-local binaries |
4485
| `~/.config/` | Per-app config |
4586

46-
Nothing extra to configure. `SCCACHE_DIR` and `CLAUDE_CONFIG_DIR` default to
47-
paths under `$HOME`, so the env vars the old pattern used to override those
48-
are unnecessary and should be removed.
87+
Nothing extra to configure. `SCCACHE_DIR` and `CLAUDE_CONFIG_DIR` default
88+
to paths under `$HOME`, so the env vars the old pattern used to override
89+
them are unnecessary and should be removed.
4990

5091
## What doesn't persist
5192

52-
- Anything **outside** `$HOME``/usr/local/...`, `/opt/...`, etc. These come
53-
from the image, the Dockerfile, or feature installs. Rebuild the image to
54-
change them.
55-
- The workspace folder itself (usually `/workspace`). This is bind-mounted
56-
from the outer Coder workspace container's `/home/<coder>/<repo>`, which
57-
has its own persistence (via the outer `docker_volume.home_volume` in the
58-
Coder template). Your git working tree survives outer-workspace restarts;
59-
inside the devcontainer it's the same filesystem surface.
93+
- Anything **outside** `$HOME``/usr/local/...`, `/opt/...`, etc. These
94+
come from the image, the Dockerfile, or feature installs. Rebuild the
95+
image to change them. This is also why our features install to
96+
`/usr/local/bin` rather than `~/.local/bin`: feature-installed binaries
97+
need to live outside the home volume.
98+
- The workspace folder itself (usually `/workspaces/<repo>`). That's the
99+
git clone under the outer workspace's `/home/coder`, persisted by the
100+
per-workspace `docker_volume.home_volume`. Your working tree survives
101+
restarts of *its* workspace, but is scoped to that workspace.
102+
103+
## Seeding on first create
104+
105+
Bind mounts don't auto-seed from the image. The first time the per-owner
106+
volume is attached, it's empty, and the devcontainer's image-side
107+
`/home/<user>` (skeleton dotfiles, defaults from `/etc/skel`) is *not*
108+
copied into it automatically.
60109

61-
## First-create seeding
110+
The `onCreateCommand` above handles seeding explicitly:
111+
112+
```bash
113+
test -z "$(ls -A $HOME 2>/dev/null)" && cp -rT /etc/skel $HOME || true
114+
```
62115

63-
When Docker mounts an empty named volume over a non-empty directory, it
64-
copies the image's directory contents into the volume. So the first time a
65-
user opens any devcontainer, the image's `/home/<user>` (skeleton dotfiles,
66-
defaults from `/etc/skel`) seeds the volume.
116+
- On the first-ever create for an owner, `$HOME` is empty → copy
117+
`/etc/skel` in.
118+
- On every subsequent create (any workspace, any rebuild), `$HOME` has
119+
content → no-op.
67120

68-
After that, the volume wins. Subsequent rebuilds of the image will **not**
69-
propagate changes to the image's `$HOME` into the existing volume.
121+
After the first create the volume wins on its own. Subsequent rebuilds
122+
of the image will **not** propagate changes to the image's `$HOME` into
123+
the existing volume.
70124

71-
Consequence: don't put long-lived shell config in `~/.bashrc` in the
72-
Dockerfile — it'd be stuck at whatever got seeded on first-create. Use
73-
`/etc/bash.bashrc` instead (system-wide, sourced by non-login interactive
74-
bashes on Debian/Ubuntu, image-owned, always authoritative).
125+
Consequence: don't put long-lived shell config in `~/.bashrc` via the
126+
Dockerfile — it'd be stuck at whatever got seeded on the first create.
127+
Use `/etc/bash.bashrc` instead (system-wide, sourced by non-login
128+
interactive bashes on Debian/Ubuntu, image-owned, always authoritative).
75129

76-
## Cross-project side effects
130+
## Cross-workspace side effects
77131

78-
One home for every devcontainer means:
132+
One home for every workspace means:
79133

80134
- Pros: universal `~/.gitconfig`, `~/.ssh/*`, one Claude Code login, one
81-
bash history, shared sccache/`~/.cargo` caches.
135+
shared bash history, shared sccache / `~/.cargo` caches across all
136+
projects the owner opens.
82137
- Cons: tools that write env-specific state to `$HOME` (some pyenv / nvm
83138
layouts, project-specific `~/.config/*` files) will leak between
84139
projects. For most dotfile-level state this is exactly what you want.
85140
If a specific tool misbehaves, override its config dir via `containerEnv`
86-
to point at a project-scoped path under the repo.
141+
to point at a project-scoped path under the repo's working tree.
142+
- Running two workspaces simultaneously means two processes writing to
143+
the same home — the same situation as running two terminals on your
144+
laptop. Tools that support concurrent writers (bash history with
145+
`histappend`, content-addressed caches, atomic-rename lockfiles) cope
146+
fine. Tools that don't (single-writer state files) behave as they would
147+
locally.
87148

88149
## Resetting a home
89150

90151
If the shared home gets into a bad state, nuke the volume from the host:
91152

92153
```bash
93-
docker volume rm devhome-<owner-username>
154+
docker volume rm coder-<owner-username>-devhome
94155
```
95156

96-
Next devcontainer start re-seeds it from the image. You'll need to
97-
re-login to Claude Code, re-set any interactive state, etc.
157+
Next devcontainer start sees an empty `/mnt/devhome`, the `onCreateCommand`
158+
re-seeds it from `/etc/skel`, and you're back to a clean home. You'll need
159+
to re-login to Claude Code, re-populate `~/.ssh` (the Coder sub-agent does
160+
this automatically via `coder_script "git_ssh_signing"`), and re-set any
161+
interactive state.

0 commit comments

Comments
 (0)