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

2. Disk layout

Two drives. The system drive holds the OS, and the data drive is one btrfs partition with a subvolume per area.

PartitionSizeFilesystemMountContents
System 11 GiBFAT32/boot/efiBootloader
System 2200 GiBext4/Ubuntu, apt packages, snaps
DataRest (about 730 GiB)btrfs/data/* subvolumesEverything 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
SubvolumeMountPurposeCompression
@projects/data/projectsRepos and codezstd:3
@cache/data/cacheToolchains, package caches, shared tools (see below)zstd:3
@models/data/modelsModel weightsoff (set per subvolume)
@docker/data/dockerDocker data rootzstd:3
@tmp/data/tmp and /tmpTemporary fileszstd:3
@sandboxes/data/sandboxesSandbox homes, template, shared stores, logszstd:3
@workspace/data/workspaceThe shared coding workspace mounted into sandboxeszstd: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
Do not mask 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 directory on btrfs (planned, not yet done): move /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
Do not use a personal or shared work email as the commit author. If that address is verified on a different GitHub account, GitHub credits every commit made with it to that account. We hit this on a new system: a commit made with a work email was credited to another user. Changing the identity afterwards means rewriting history.

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
Install scripts in npm 12 (what we found). Some packages, including opencode-ai, stop working with a postinstall error when their script is blocked. We tested the options: What we do instead: install such tools globally into the shared prefix, which the sandboxes mount too, and allow the script on that install:
npm install -g --allow-scripts=opencode-ai opencode-ai
opencode --version
Git-hosted npm packages. npm 12 refuses git dependencies by default. To allow them in the sandboxes, set 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.

Known side effects of the installer, which we found after the fact. It writes a 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:

The template contains a GitHub login. We chose to copy our gh login into the template, so every sandbox starts signed in. Anything running in a sandbox can use that token. Prefer a fine-grained token limited to the repositories the sandboxes need, and replace the copy in the template with it.

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:

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

ItemHow it's sharedAccess
Toolchains (Go, Node, Rust, uv, cargo registry)/data/cache/* mounted into each sandboxRead-only, except caches
Shared CLI tools (npm global prefix, gcloud SDK)/data/cache/npm-global and /data/cache/google-cloud-sdknpm prefix read-write, gcloud read-only
Workspace/data/workspace mounted as /workspaceRead-write
Projects/data/projects mounted as /data/projectsRead-write
Claude login/data/cache/claude-auth, linked from the templateRead-write, so token refreshes reach every sandbox
Claude projects and history/data/cache/claude-state, linked from the templateRead-write. Starts empty. Shared between sandboxes, not copied from the host.
gm bundle/data/cache/gmRead-write, so one self-update serves all sandboxes
GitHub loginCopied into the template, seeded per sandboxRead-write per sandbox
Host home, browser profiles, host keyringNot mountedNever
Docker socket, X11, Wayland and D-Bus socketsNot mountedNever

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

Laptop and hardware notes

Trust decisions to review

18. Known gaps