|
| 1 | +# Devcontainer Migration Guide |
| 2 | + |
| 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. |
| 7 | + |
| 8 | +## Goals of the migration |
| 9 | + |
| 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. |
| 14 | +2. The container user / UID / GID is driven by `DEVCONTAINER_*` env vars so |
| 15 | + the same devcontainer works inside Coder (where the outer template sets |
| 16 | + these) and outside (sensible defaults). |
| 17 | +3. Toolchain installs (cmake, LLVM, sccache, Claude Code, RTK, …) come from |
| 18 | + published devcontainer features, not inline Dockerfile commands. The |
| 19 | + Dockerfile shrinks to base OS + common utilities + user creation. |
| 20 | + |
| 21 | +## Input state (what you'll typically see) |
| 22 | + |
| 23 | +An existing devcontainer that: |
| 24 | + |
| 25 | +- Runs as `root` or a user named `vscode` / `ubuntu` / whatever the base |
| 26 | + image ships. |
| 27 | +- Has a Dockerfile with long inline installs for cmake / LLVM / sccache / |
| 28 | + Claude Code / RTK. |
| 29 | +- Uses ephemeral volumes or `${devcontainerId}`-scoped volumes (state dies |
| 30 | + on rebuild). |
| 31 | +- Hard-codes paths like `/root/.claude`, `/root/.cache/sccache`. |
| 32 | + |
| 33 | +## Output state (what you're producing) |
| 34 | + |
| 35 | +### `.devcontainer/devcontainer.json` |
| 36 | + |
| 37 | +```jsonc |
| 38 | +{ |
| 39 | + "name": "<project-name>", |
| 40 | + "build": { |
| 41 | + "dockerfile": "Dockerfile", |
| 42 | + "args": { |
| 43 | + "USERNAME": "${localEnv:DEVCONTAINER_USERNAME:dev}", |
| 44 | + "USER_UID": "${localEnv:DEVCONTAINER_USER_UID:1000}", |
| 45 | + "USER_GID": "${localEnv:DEVCONTAINER_USER_GID:1000}" |
| 46 | + } |
| 47 | + }, |
| 48 | + "remoteUser": "${localEnv:DEVCONTAINER_USERNAME:dev}", |
| 49 | + "containerUser": "${localEnv:DEVCONTAINER_USERNAME:dev}", |
| 50 | + "mounts": [ |
| 51 | + "source=devhome-${localEnv:OWNER_USERNAME:shared},target=/home/${localEnv:DEVCONTAINER_USERNAME:dev},type=volume" |
| 52 | + ], |
| 53 | + "features": { |
| 54 | + // pick from the feature reference below |
| 55 | + }, |
| 56 | + "customizations": { |
| 57 | + // keep whatever IDE config the project already has |
| 58 | + } |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +Rules: |
| 63 | + |
| 64 | +- `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. |
| 69 | +- `remoteUser` and `containerUser` both use `${localEnv:DEVCONTAINER_USERNAME:dev}`. |
| 70 | +- Do **not** set `CLAUDE_CONFIG_DIR` or `SCCACHE_DIR` in `containerEnv` — |
| 71 | + their defaults (`~/.claude`, `~/.cache/sccache`) already land inside the |
| 72 | + persistent home volume. |
| 73 | +- `customizations` — preserve whatever the project had (JetBrains backend, |
| 74 | + VS Code settings/extensions, etc.). |
| 75 | + |
| 76 | +### `.devcontainer/Dockerfile` |
| 77 | + |
| 78 | +```dockerfile |
| 79 | +FROM <base-image-and-digest> |
| 80 | + |
| 81 | +ARG USERNAME |
| 82 | +ARG USER_UID=1000 |
| 83 | +ARG USER_GID=${USER_UID} |
| 84 | + |
| 85 | +ENV DEBIAN_FRONTEND=noninteractive |
| 86 | +ENV DEVCONTAINER=true |
| 87 | + |
| 88 | +RUN apt-get update && \ |
| 89 | + apt-get install -y --no-install-recommends \ |
| 90 | + <base packages the project actually needs> \ |
| 91 | + sudo ca-certificates curl git openssh-client && \ |
| 92 | + apt-get clean && rm -rf /var/lib/apt/lists/* && \ |
| 93 | + userdel -r ubuntu 2>/dev/null || true && \ |
| 94 | + groupadd --gid "${USER_GID}" "${USERNAME}" && \ |
| 95 | + useradd --uid "${USER_UID}" --gid "${USER_GID}" --create-home --shell /bin/bash "${USERNAME}" && \ |
| 96 | + echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/"${USERNAME}" && \ |
| 97 | + chmod 0440 /etc/sudoers.d/"${USERNAME}" |
| 98 | + |
| 99 | +# Project-specific env, shell config, workdir, etc. go here. |
| 100 | + |
| 101 | +USER ${USERNAME} |
| 102 | +``` |
| 103 | + |
| 104 | +Rules: |
| 105 | + |
| 106 | +- Start with the project's existing `FROM`. Keep the digest pin if present. |
| 107 | +- Declare `ARG USERNAME` (no default — required from build.args), plus |
| 108 | + `ARG USER_UID=1000` and `ARG USER_GID=${USER_UID}`. |
| 109 | +- Create the user in the same `RUN` that does apt installs (fewer layers). |
| 110 | + `userdel -r ubuntu 2>/dev/null || true` covers Ubuntu base images that |
| 111 | + ship a default user. Use soft-fail on non-Ubuntu bases. |
| 112 | +- End with `USER ${USERNAME}`. |
| 113 | +- **Remove** any inline installs that are now handled by features |
| 114 | + (see next section). Also remove: |
| 115 | + - Manual `.bashrc` history-persistence hacks tied to `/commandhistory` or |
| 116 | + `/root/.bash_history` — home is on the volume now, bash history |
| 117 | + persists naturally at `~/.bash_history`. |
| 118 | + - Hard-coded `CC=clang-22` / `CXX=clang++-22` — the `llvm` feature sets |
| 119 | + these via `containerEnv`. |
| 120 | + - Hard-coded `SCCACHE_DIR` — default already works. |
| 121 | +- If the project sets shell-level config (`HISTSIZE`, `PATH` additions, etc.) |
| 122 | + put it in `/etc/bash.bashrc` (system-wide, sourced by non-login interactive |
| 123 | + bashes on Debian/Ubuntu) **not** `~/.bashrc`. The home dir is a volume — |
| 124 | + `~/.bashrc` only gets seeded on first-create and diverges from the image |
| 125 | + on rebuilds. |
| 126 | + |
| 127 | +## Available features |
| 128 | + |
| 129 | +Reference: `ghcr.io/sourecode/devcontainer-features/<id>:<major-version>` |
| 130 | + |
| 131 | +| Feature | Purpose | Notable options | |
| 132 | +|---|---|---| |
| 133 | +| `cmake` | CMake from Kitware GitHub releases, distro-agnostic | `version` (default `latest`) | |
| 134 | +| `llvm` | Clang/LLVM via `apt.llvm.org`. Sets `CC`/`CXX` in containerEnv. | `version` (default `22`), `all` (default `true`) | |
| 135 | +| `sccache` | Mozilla sccache from GitHub releases | `version` (default `latest`) | |
| 136 | +| `nvm` | NVM + optional Node install | `version`, `node` (default `lts`) | |
| 137 | +| `claude-code` | Anthropic Claude Code CLI | — | |
| 138 | +| `rtk` | RTK CLI | — | |
| 139 | +| `context-mode` | Context-mode integration | — | |
| 140 | + |
| 141 | +### How to decide which features to add |
| 142 | + |
| 143 | +Scan the original Dockerfile for inline installs and map them: |
| 144 | + |
| 145 | +- `apt install cmake` (from any source) → drop, add `cmake` feature. |
| 146 | +- `apt.llvm.org/llvm.sh` / `apt install clang` / `apt install clangd` / |
| 147 | + any llvm-* packages → drop, add `llvm` feature (it installs the full |
| 148 | + toolchain with `all: true`). |
| 149 | +- `sccache` tarball install → drop, add `sccache` feature. |
| 150 | +- `curl .../claude-code install` → drop, add `claude-code` feature. |
| 151 | +- `curl .../rtk install` → drop, add `rtk` feature. |
| 152 | +- Anything NOT covered by a feature stays in the Dockerfile. |
| 153 | + |
| 154 | +Typical `features` block for a C++ project: |
| 155 | + |
| 156 | +```jsonc |
| 157 | +"features": { |
| 158 | + "ghcr.io/sourecode/devcontainer-features/cmake:1": {}, |
| 159 | + "ghcr.io/sourecode/devcontainer-features/llvm:1": {}, |
| 160 | + "ghcr.io/sourecode/devcontainer-features/sccache:1": {}, |
| 161 | + "ghcr.io/sourecode/devcontainer-features/claude-code:1": {}, |
| 162 | + "ghcr.io/sourecode/devcontainer-features/rtk:1": {}, |
| 163 | + "ghcr.io/sourecode/devcontainer-features/context-mode:1": {} |
| 164 | +} |
| 165 | +``` |
| 166 | + |
| 167 | +## Where env vars come from |
| 168 | + |
| 169 | +- `DEVCONTAINER_USERNAME`, `DEVCONTAINER_USER_UID`, `DEVCONTAINER_USER_GID`, |
| 170 | + `OWNER_USERNAME` are set on the outer Coder workspace agent |
| 171 | + (`coder_agent.main.env` in the Coder template). The `@devcontainers/cli` |
| 172 | + process inherits them, so `${localEnv:*}` in `devcontainer.json` resolves |
| 173 | + at devcontainer startup. |
| 174 | +- Running the devcontainer locally on a laptop (no Coder): the `:fallback` |
| 175 | + defaults in each `${localEnv:NAME:fallback}` kick in. User ends up as |
| 176 | + `dev` at 1000:1000, volume is `devhome-shared`. |
| 177 | + |
| 178 | +## Persistent home caveats |
| 179 | + |
| 180 | +One volume per Coder user, shared across every devcontainer they open. This |
| 181 | +is intentional — same bash history, same `~/.gitconfig`, same `~/.claude` |
| 182 | +everywhere. Side effects to understand: |
| 183 | + |
| 184 | +- First-time volume creation seeds from the image's `/home/<user>` (Docker |
| 185 | + copies dir contents when an empty named volume is mounted over a |
| 186 | + non-empty target). Subsequent rebuilds use the volume; changes to the |
| 187 | + image's home dir will **not** propagate. |
| 188 | +- Put long-lived shell config in `/etc/bash.bashrc` (image-side) so it |
| 189 | + stays authoritative. |
| 190 | +- Tools that store env-specific state in `$HOME` (some pyenv/nvm layouts, |
| 191 | + `.cache/` bloat) can collide across projects. Usually fine for dotfiles |
| 192 | + and coarse caches; tune per-project if something misbehaves. |
| 193 | + |
| 194 | +## Checklist for the migrating agent |
| 195 | + |
| 196 | +1. Edit `.devcontainer/devcontainer.json` to match the template above. |
| 197 | + Preserve `name` and `customizations` from the original. |
| 198 | +2. Edit `.devcontainer/Dockerfile`: |
| 199 | + - Add `ARG USERNAME / USER_UID / USER_GID`. |
| 200 | + - Fold user creation into the apt `RUN`. |
| 201 | + - Remove inline installs that are covered by features. |
| 202 | + - Move any `.bashrc` shell config to `/etc/bash.bashrc`. |
| 203 | + - End with `USER ${USERNAME}`. |
| 204 | +3. Remove obsolete files: standalone `bashrc`-patching scripts, |
| 205 | + `/commandhistory` directory hacks, hard-coded `CLAUDE_CONFIG_DIR` / |
| 206 | + `SCCACHE_DIR` env vars. |
| 207 | +4. Build-test the devcontainer locally: |
| 208 | + `devcontainer up --workspace-folder .` → should succeed, drop into the |
| 209 | + non-root user's shell, and have `cmake`, `clang`, `sccache`, `claude`, |
| 210 | + `rtk` on `$PATH`. |
| 211 | +5. Commit. |
0 commit comments