Building a dev machine
How we set up our dev machines: one btrfs data drive for everything installed on purpose, shared toolchains, and sandboxed desktops that share tools and credentials only where we chose to. Follow it top to bottom on a fresh Ubuntu install. Placeholders are in <angle brackets>.
Quick start: the setup script
Most of this guide is automated by setup/devsetup.sh. It runs in two steps. First it asks every question in terminal menus, with nothing changed on the machine. Then it shows the collected answers for review, and runs them in one pass. A collection can be saved and reused on a similar machine.
git clone https://github.com/AnEntrypoint/devsetup
cd devsetup
./setup/devsetup.sh # collect answers in menus, review, then run
./setup/devsetup.sh --collect-only # collect answers and save them; change nothing
./setup/devsetup.sh --dry-run # collect, then print every action; change nothing
./setup/devsetup.sh --answers FILE # run a saved collection with no prompts
./setup/devsetup.sh --install-launcher # desktop icon that opens the menus in a terminal
The menus are terminal-only: whiptail on a terminal, and plain prompts when there is no terminal. Answers are saved to ~/.config/devsetup/answers.env, so a run can be repeated. The run log is in ~/.local/state/devsetup/. Passwords and tokens are never asked for or stored. Formatting a drive needs you to type ERASE <device> exactly. Sections 1 to 18 explain what each module does and why.
1. Principles
- Everything installed on purpose goes to the btrfs data drive. Standalone tools go under
/data/cache, projects under/data/projects, desktop apps through flatpak on/data/flatpak, and Docker under/data/docker. Block deduplication then covers all of it. - apt is for the operating system only. Compilers, libraries and the apt download cache. The download cache is redirected to the data drive.
- Sandboxes share tools, not identity. Each sandbox runs the same toolchains, read-only where possible. Logins and history are shared only when we decide to share them, and each one is a separate, named decision below.
- Sandbox homes reset on every start. Each start clones a fresh home from a template. Only the shared workspace and the shared stores survive.
- Commit with your GitHub no-reply address. GitHub credits commits by author email. Any other address that is linked to a different GitHub account gets those commits attributed to that account. See section 8 for the exact setting.
- Use the current Node LTS line. Odd-numbered and out-of-support releases are not used. Node v24 LTS is the line in use at the time of writing.
2. Disk layout
Two drives. The system drive holds the OS, and the data drive is one btrfs partition with a subvolume per area.
| Partition | Size | Filesystem | Mount | Contents |
|---|---|---|---|---|
| System 1 | 1 GiB | FAT32 | /boot/efi | Bootloader |
| System 2 | 200 GiB | ext4 | / | Ubuntu, apt packages, snaps |
| Data | Rest (about 730 GiB) | btrfs | /data/* subvolumes | Everything else |
Keep about 15% of the btrfs partition free. Drive vendors count in GB, so a "1 TB" drive shows as about 931 GiB. If Windows shares the disk, give it its own NTFS partition before partitioning.
3. Install Ubuntu
In the installer's manual partitioning, create the three partitions above and leave the data partition unformatted. Format it in the next step. Use UUIDs everywhere: device names such as nvme0n1 and nvme1n1 can swap between boots.
4. btrfs subvolumes and mounts
Create the filesystem and one subvolume per area:
sudo mkfs.btrfs -L data /dev/<data-partition>
sudo mkdir -p /mnt/btrfs-root
sudo mount -o subvolid=5 UUID=<uuid> /mnt/btrfs-root
for sv in @projects @cache @models @docker @tmp @sandboxes @workspace; do
sudo btrfs subvolume create /mnt/btrfs-root/$sv
done
sudo umount /mnt/btrfs-root
| Subvolume | Mount | Purpose | Compression |
|---|---|---|---|
@projects | /data/projects | Repos and code | zstd:3 |
@cache | /data/cache | Toolchains, package caches, shared tools (see below) | zstd:3 |
@models | /data/models | Model weights | off (set per subvolume) |
@docker | /data/docker | Docker data root | zstd:3 |
@tmp | /data/tmp and /tmp | Temporary files | zstd:3 |
@sandboxes | /data/sandboxes | Sandbox homes, template, shared stores, logs | zstd:3 |
@workspace | /data/workspace | The shared coding workspace mounted into sandboxes | zstd:3 |
Add to /etc/fstab, replacing <uuid> with the value from blkid:
UUID=<uuid> /data/projects btrfs subvol=@projects,compress=zstd:3,noatime 0 0
UUID=<uuid> /data/cache btrfs subvol=@cache,compress=zstd:3,noatime 0 0
UUID=<uuid> /data/models btrfs subvol=@models,noatime 0 0
UUID=<uuid> /data/docker btrfs subvol=@docker,compress=zstd:3,noatime 0 0
UUID=<uuid> /data/tmp btrfs subvol=@tmp,compress=zstd:3,noatime 0 0
UUID=<uuid> /tmp btrfs subvol=@tmp,compress=zstd:3,noatime 0 0
UUID=<uuid> /data/sandboxes btrfs subvol=@sandboxes,compress=zstd:3,noatime 0 0
UUID=<uuid> /data/workspace btrfs subvol=@workspace,compress=zstd:3,noatime 0 0
Create the mount points, mount everything, and give your user ownership:
sudo mkdir -p /data/{projects,cache,models,docker,tmp,sandboxes,workspace}
sudo systemctl daemon-reload && sudo mount -a
sudo chown -R $USER:$USER /data/{projects,cache,models,sandboxes,workspace}
sudo chmod 1777 /data/tmp
tmp.mount. Masking it also blocks the fstab entry for /tmp, which leaves /tmp in RAM. Leave it unmasked and let fstab mount /tmp from @tmp at boot. Mounting it on a running system hides the files already in /tmp, so do that step at reboot.Turn compression off for model weights:
sudo btrfs property set /data/models compression no
btrfs property get /data/models compression # expect: compression=no
/home/<user> into an @dev subvolume, so browser profiles and dotfiles are deduplicated too. Copy it while the desktop is idle, add a fstab mount at /home/<user> with subvol=@dev, and reboot. The old copy on the system drive can be removed afterwards.5. Boot: root must be read-write
On one machine the root filesystem came up read-only after a reboot. systemd's remount step didn't run, so Docker failed, the display manager restarted in a loop, and the data mounts didn't come up. Manually remounting root made the desktop usable again, but the boot fix is a kernel argument.
# /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash rw"
sudo update-grub
grep -m1 "linux " /boot/grub/grub.cfg # the line should end with: rw
The kernel takes the last of ro and rw, so rw wins. Remove any leftover installer arguments such as autoinstall from this line. After a reboot, check with findmnt -n -o OPTIONS /, which should start with rw.
6. Passwordless sudo (optional)
This is a convenience for a single-user workstation. Anything running as your user can then become root without a password.
echo "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/90-$USER-nopasswd
sudo chmod 0440 /etc/sudoers.d/90-$USER-nopasswd
sudo visudo -c
7. System-wide defaults
Put the tool locations in /etc/environment, so every session (including desktop apps) sees them:
sudo tee -a /etc/environment <<'EOF'
TMPDIR=/data/tmp
RUSTUP_HOME=/data/cache/rustup
CARGO_HOME=/data/cache/cargo
GOPATH=/data/projects/go
GOCACHE=/data/cache/go-build
GOMODCACHE=/data/cache/go-mod
UV_CACHE_DIR=/data/cache/uv
UV_PYTHON_INSTALL_DIR=/data/cache/uv/python
NPM_CONFIG_CACHE=/data/cache/npm
EOF
Redirect the apt download cache to the data drive:
mkdir -p /data/cache/apt/archives/partial
sudo rm -rf /var/cache/apt/archives
sudo ln -s /data/cache/apt/archives /var/cache/apt/archives
Login shells read /etc/profile.d, but some desktop sessions don't. If a desktop menu doesn't show flatpak apps, link their desktop entries into ~/.local/share/applications, which every session searches.
8. Toolchains
Standalone tools live under /data/cache and are linked into ~/.local/bin. Install apt packages only for system libraries and compilers.
System packages (apt)
sudo apt-get install -y build-essential cmake ninja-build pkg-config \
libssl-dev libffi-dev zlib1g-dev libsqlite3-dev unzip jq duperemove git
Rust
curl -fsSLO https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init
chmod +x rustup-init
RUSTUP_HOME=/data/cache/rustup CARGO_HOME=/data/cache/cargo \
./rustup-init -y --no-modify-path --default-toolchain stable
Go
Download the tarball from go.dev, check its SHA-256 against the value on the download page, then:
sudo tar -C /data/cache -xzf go<version>.linux-amd64.tar.gz # creates /data/cache/go
sudo mv /data/cache/go /data/cache/go<version> # keep each version in its own folder
sudo ln -sfn /data/cache/go<version> /data/cache/go
sudo ln -sfn /data/cache/go /usr/local/go
The setup script does the same, and keeps the previous version in a go-prev-<timestamp> folder when it replaces one.
Node
Download the latest LTS tarball from nodejs.org, not the newest release, and check it against SHASUMS256.txt. Check the current LTS with curl -fsSL https://nodejs.org/dist/index.json, which marks LTS releases. The host and every sandbox must run the same version. Because the sandboxes mount /data/cache/node, one install covers all of them:
mkdir -p /data/cache/node && tar -C /data/cache/node -xJf node-v<version>-linux-x64.tar.xz
ln -sfn /data/cache/node/node-v<version>-linux-x64 /data/cache/node/current
mkdir -p ~/.local/bin
ln -sfn /data/cache/node/current/bin/node ~/.local/bin/node
ln -sfn /data/cache/node/current/bin/npm ~/.local/bin/npm
ln -sfn /data/cache/node/current/bin/npx ~/.local/bin/npx
uv
Install the standalone release binary and check its .sha256 file. Keep it in the shared tools folder, which the sandboxes mount too:
mkdir -p /data/cache/bin
# download uv-x86_64-unknown-linux-gnu.tar.gz from github.com/astral-sh/uv/releases and verify it
install -m 0755 uv uvx /data/cache/bin/
ln -sfn /data/cache/bin/uv ~/.local/bin/uv
ln -sfn /data/cache/bin/uvx ~/.local/bin/uvx
uv manages Python versions under /data/cache/uv/python. Use uv python install for project work rather than the system Python.
Git identity
Set your git author to your GitHub no-reply address. The form is <numeric-id>+<login>@users.noreply.github.com, and the numeric ID comes from gh api user --jq .id. Set it on the host and in the sandbox template, so commits from every sandbox are credited to you:
git config --global user.name "<login>"
git config --global user.email "<numeric-id>+<login>@users.noreply.github.com"
git log --format='%an <%ae>' -1 # check after a commit
9. npm, pnpm, bun and Claude Code
Send npm's global installs to a writable prefix on the data drive. The Node install itself is read-only in the sandboxes, so user-installed packages need their own prefix:
npm config set prefix /data/cache/npm-global
npm config set cache /data/cache/npm
npm blocks install scripts by default, and some packages need theirs. Allow them per package rather than globally:
npm install -g --allow-scripts=pnpm,bun pnpm@latest bun@latest
npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code@latest
Claude Code needs its native binary, which comes from its install script, so the --allow-scripts flag matters. Verify with claude --version.
Put the shared npm bin on the host's PATH, so tools installed into the shared prefix run from any shell, including the sandboxes:
export PATH="/data/cache/npm-global/bin:$PATH" # in ~/.bashrc
opencode-ai, stop working with a postinstall error when their script is blocked. We tested the options:
allow-scripts=allin.npmrcdoes not allow install scripts.--allow-scripts=<pkg>works for global installs only. Project-scoped installs reject it withEALLOWSCRIPTSand expect anallowScriptsentry inpackage.jsonor.npmrc.npm install-scripts approve <pkg>followed bynpm rebuild <pkg>works for a package that is already installed.- An
npxrun uses its own cache under the shared npm cache, and the scripts there stay blocked. Don't rely onnpxfor packages that need postinstall.
npm install -g --allow-scripts=opencode-ai opencode-ai
opencode --version
allow-git=all in the template's .npmrc. This means packages fetched from git run their install scripts, so only enable it for sources you trust.Kaggle CLI
UV_TOOL_BIN_DIR=/data/cache/bin UV_TOOL_DIR=/data/cache/uv/tools uv tool install kaggle
export PATH="/data/cache/bin:$PATH" # in ~/.bashrc
kaggle --version
The binary sits in the shared bin folder, which the sandboxes mount. Each sandbox keeps its own ~/.kaggle folder, so the API token (kaggle.json) is never shared. Place it in each sandbox's folder under /data/sandboxes/devN/creds/kaggle, or create it from inside the sandbox.
10. GitHub and Google Cloud CLIs
Add the official apt repositories and install gh and google-cloud-cli from them, so updates arrive with the rest of the system.
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/cloud.google.gpg
echo "deb [signed-by=/etc/apt/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list
sudo apt-get update && sudo apt-get install -y gh google-cloud-cli
Shared gcloud: the SDK is copied from the apt install to /data/cache/google-cloud-sdk, and the sandboxes mount it read-only, ahead of the image's own copy on PATH. The copy doesn't follow apt upgrades, so refresh it after upgrading gcloud on the host:
sudo rm -rf /data/cache/google-cloud-sdk
sudo cp -a /usr/lib/google-cloud-sdk /data/cache/google-cloud-sdk
sudo chown -R root:root /data/cache/google-cloud-sdk
Logins are never copied between machines. Run gh auth login and gcloud auth login on each machine, and in each sandbox that needs them (see section 12 for how sandbox logins persist).
11. gm (shared install)
gm installs its self-updating MCP bundle into $HOME/.gm-tools. To share one bundle across the sandboxes, install it with HOME pointed at the shared folder:
mkdir -p /data/cache/gm/.gm-tools /data/cache/npm-global
cd /data/cache/gm
HOME=/data/cache/gm npm install -g --prefix /data/cache/npm-global --allow-git=all github:AnEntrypoint/gm
HOME=/data/cache/gm /data/cache/npm-global/bin/gm -g
The template links ~/.gm-tools to /data/cache/gm/.gm-tools, so a single self-update serves every sandbox. The installer also registers the MCP server and copies its skills, which the template carries.
gm context server into ~/.config/zed/settings.json on the host, with the path hard-coded to /home/<user>. It also starts an agentplug-runner daemon from the shared folder. After the install, check for both, and remove the Zed entry and stop the daemon if you don't want them on the host.12. Docker on the data drive
sudo apt-get install -y docker.io docker-compose-v2
sudo systemctl stop docker
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{ "data-root": "/data/docker" }
EOF
sudo systemctl start docker
sudo usermod -aG docker $USER # takes effect at next login
sudo docker run --rm hello-world
Restarting Docker stops every running container, including the sandboxes. Docker's overlay storage on btrfs works here, but it isn't the combination Docker's docs recommend. If you see storage errors, move data-root back to the default.
13. Desktop apps (flatpak)
Desktop apps go through flatpak, and its system installation is symlinked onto the data drive, so new installs land there by default:
sudo apt-get install -y flatpak
sudo mkdir -p /data/flatpak
sudo mv /var/lib/flatpak/* /data/flatpak/ 2>/dev/null; sudo rmdir /var/lib/flatpak 2>/dev/null || true
sudo ln -sfn /data/flatpak /var/lib/flatpak
sudo flatpak remote-add --system --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
sudo flatpak install -y --system flathub com.obsproject.Studio
Desktop menus may not see the exported launchers straight away. Link them into your own applications folder:
mkdir -p ~/.local/share/applications
for f in /data/flatpak/exports/share/applications/*.desktop; do ln -sfn "$f" ~/.local/share/applications/; done
update-desktop-database ~/.local/share/applications
Snap cannot be relocated. Snapd keeps its store at /var/lib/snapd and /snap on the system drive. Prefer flatpak for new desktop apps. Existing snaps keep their profiles under the home directory.
Discord screen sharing
The Discord snap crashed when screen sharing on X11. The Flathub build (com.discordapp.Discord) installs from the same remote, and we run it instead. The snap's logs showed seccomp denials for io_uring_setup, which is a likely cause, but we didn't confirm that it's the crash.
14. Deduplication
btrfs compresses but does not deduplicate on its own. We use duperemove in non-destructive mode. It replaces identical blocks with shared copies and removes no files. It runs weekly:
# /etc/systemd/system/dedup-data.service
[Unit]
Description=Non-destructive btrfs dedup (shared extents) for /data
[Service]
Type=oneshot
Nice=19
IOSchedulingClass=idle
ExecStart=/usr/bin/duperemove -r -d --hashfile=/data/cache/duperemove.db --io-threads=4 --cpu-threads=8 /data/projects /data/models /data/cache
# /etc/systemd/system/dedup-data.timer
[Unit]
Description=Weekly dedup of /data
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now dedup-data.timer
15. Sandboxed desktops
Each sandbox is a webtop XFCE desktop running in Docker. It's reachable only from this machine, at 127.0.0.1 on ports 3001 to 3004. The image is built from sandbox/Dockerfile on lscr.io/linuxserver/webtop:ubuntu-xfce, and it adds the apt packages we use (build tools, gh and the gcloud apt package as a fallback).
Shared template
/data/sandboxes/shared-home is a btrfs subvolume that every sandbox clones from on start. It holds:
.profileand.bashrc: the toolchain environment, matching the host shell.gitconfig: your git name and email, and a credential helper that sends GitHub URLs throughgh.npmrc:allow-gitand the shared npm prefix.gm-tools: a symlink to the shared gm bundle.claude/: settings, skills and plugins; symlinks for the shared login and shared projects and history; and.claude.jsonwith the gm MCP registration and the account record.config/gh/hosts.yml: a GitHub login (see the warning below)
Set up the template once:
sudo btrfs subvolume create /data/sandboxes/shared-home
sudo chown 1000:1000 /data/sandboxes/shared-home
# write .profile, .bashrc and .gitconfig, then the .claude and .npmrc links described above
Image and launcher
cd sandbox
sudo docker build -t dev-desktop:latest .
./launch.sh dev1 # starts it and opens http://127.0.0.1:3001 in the default browser
./stop.sh dev1
launch.sh does this on every start:
- leaves a running sandbox alone and reopens its page
- removes the previous home and clones a fresh one from the template (a btrfs snapshot, so it's instant)
- seeds the sandbox's gh login from the template, if the sandbox doesn't have one yet
- sets a plain solid desktop colour for the sandbox, written into its fresh XFCE config (dev1 blue, dev2 green, dev3 red, dev4 purple;
bgcolor.pywrites the file) - binds the web desktop to
127.0.0.1, with the password set to the sandbox name by default. Change it for your setup. - waits for the web desktop to answer, then opens it in the default browser
- logs each run to
/data/sandboxes/logs/devN.log
Desktop icons
For each sandbox, make a Start icon that runs launch.sh devN with Terminal=false, and a Stop icon that runs stop.sh devN. Mark them trusted with gio set <file> metadata::trusted true. The Start icon needs no terminal, because the launcher opens the browser itself.
Per-sandbox state
Kaggle credentials follow the same pattern, in /data/sandboxes/devN/creds/kaggle, mounted as ~/.kaggle.
Sandboxes run with no-new-privileges, so sudo is refused inside them. That's deliberate. Anything that needs root has to run on the host.
Each sandbox keeps its own gh and gcloud folders under /data/sandboxes/devN/creds. They're mounted into the sandbox, so they survive the home reset. A sandbox's gh login is seeded from the template on its first start. gcloud still needs gcloud auth login in each sandbox.
Skills in the template
Skills are copied into the template, so every new sandbox gets them on its next start. The skills CLI ignores --skill when you pass it through npx, so check what it installed:
cd /tmp
HOME=/data/sandboxes/shared-home npx -y skills@latest add mattpocock/skills --skill=grill-me -g -a claude-code -y --copy
Run it with HOME set to the template, so the skills land in its .claude/skills. Use --copy so nothing links back to a store outside the template. This install added the whole repository, 36 skills, and we removed all but grill-me, along with the .agents lock folder it created. Check the result by starting a sandbox and listing /config/.claude/skills.
16. What is shared, and what is not
| Item | How it's shared | Access |
|---|---|---|
| Toolchains (Go, Node, Rust, uv, cargo registry) | /data/cache/* mounted into each sandbox | Read-only, except caches |
| Shared CLI tools (npm global prefix, gcloud SDK) | /data/cache/npm-global and /data/cache/google-cloud-sdk | npm prefix read-write, gcloud read-only |
| Workspace | /data/workspace mounted as /workspace | Read-write |
| Projects | /data/projects mounted as /data/projects | Read-write |
| Claude login | /data/cache/claude-auth, linked from the template | Read-write, so token refreshes reach every sandbox |
| Claude projects and history | /data/cache/claude-state, linked from the template | Read-write. Starts empty. Shared between sandboxes, not copied from the host. |
| gm bundle | /data/cache/gm | Read-write, so one self-update serves all sandboxes |
| GitHub login | Copied into the template, seeded per sandbox | Read-write per sandbox |
| Host home, browser profiles, host keyring | Not mounted | Never |
| Docker socket, X11, Wayland and D-Bus sockets | Not mounted | Never |
The sandboxes run with all capabilities dropped except the few the desktop needs, with no-new-privileges, a memory cap and a process limit. The web desktop listens only on 127.0.0.1.
17. Maintenance
- Claude login: log in on the host with
claudeonce.~/.claude/.credentials.jsonis a symlink into/data/cache/claude-auth, so every sandbox picks it up. - gm: the bundle updates itself. To reinstall, repeat section 11.
- gcloud: refresh the shared SDK after upgrading on the host (section 10).
- Template: edits apply to sandboxes on their next start, since every start clones the template again.
- Windows partition: it's mounted read-only and by hand, not from fstab. Mount it with
-o roand leave it read-only, so Windows' hibernation or fast startup can't corrupt it. - Kaggle: the CLI is shared, but each sandbox keeps its own token. Do not copy the host token into the template unless you choose to.
- Deleting files in the workspace: git makes its pack files read-only, and
rm -rasks for each one. The template aliasesrmtorm -ffor interactive shells, so deletes don't stop partway. The files belong to the sandbox user, so no sudo is needed. - Checks:
findmnt -t btrfsshould list every data path,systemctl --failedshould be empty, andfindmnt -n -o OPTIONS /should start withrw.
Laptop and hardware notes
- Hybrid GPU:
nvidia-smi, and any display query such asxrandrrun at login, wakes the discrete GPU. Monitoring should read sysfs only, and refresh-rate changes cost battery. - Wake from suspend: disabling wake sources in sysfs is not enough when the firmware arms GPIO pins. Check
/proc/acpi/wakeupand/sys/kernel/debug/gpio, then set the keyboard wake option in the BIOS. Disabling RTC wake also stops timers such as the 03:00 dedup from waking a suspended machine. - Windows partition: if
hiberfil.sysexists or Fast Startup is on, mount NTFS read-only. A read-write mount can corrupt Windows state. - Battery: a charge limit (for example 80%), the energy performance preference and the display timers can each be set on battery; none of these are set by default.
- Touchpad: XFCE reads libinput settings from
/etc/X11/xorg.conf.d, not from GNOME's gsettings. Runtimexinputchanges are lost at reboot.
Trust decisions to review
- The apt keyrings for gh and gcloud are fetched over HTTPS without a fingerprint check. Compare the fingerprint with the vendor's published value if the machine is sensitive.
- Installing Zed or Chrome with a
curl | shinstaller or a loose.debskips signature checks. Prefer the apt repository or flatpak. - The sandbox sudo rule and the passwordless sudo rule trade convenience for risk. Use them on single-user machines only.
18. Known gaps
- apt packages under
/usr(compilers, libraries) stay on the system drive. Only the download cache has moved. - snap stays on the system drive, because its store can't be relocated safely.
- The home directory is still on the system drive. The btrfs move is planned, not done.
- The gcloud SDK in
/data/cacheis a copy, so it doesn't follow apt upgrades. - The sandbox GitHub login is a copied token. Replace it with a fine-grained token (section 15).
- npx and install scripts: npm 12 blocks install scripts in
npxruns, so install packages that need them globally instead (section 9). - Shared Claude login and history mean one account is used by every sandbox, and token refreshes and session records are shared as well.