# Manage toolchains and custom layers

> Add, remove, and update shared mise toolchains (node, python, rust, go, dotnet) and author custom image layers with network policies via devvm layer new.

- Repository: devvmhq/devvm
- GitHub: https://github.com/devvmhq/devvm
- Human docs: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853
- Complete Markdown: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853/llms-full.txt

## Source Files

- `internal/cli/tools.go`
- `internal/cli/layer.go`
- `internal/tools/tools.go`
- `internal/tools/share.go`
- `assets/shared-tools/mise.toml`
- `assets/shared-tools/build.sh`
- `internal/vm/tools.go`

---

---
title: "Manage toolchains and custom layers"
description: "Add, remove, and update shared mise toolchains (node, python, rust, go, dotnet) and author custom image layers with network policies via devvm layer new."
---

`devvm` separates language toolchains from disk image layers. Shared toolchains (`node`, `python`, `rust`, `go`, `dotnet`) and coding agents (`claude`, `codex`, `opencode`) are built once per version into compressed, read-only EROFS images in `~/.local/share/devvm/tools` and loop-mounted into project VMs at `/opt/devvm/tools/<tool>/<version>`, where `mise` activates them without modifying the VM disk. Image layers (`desktop`, `docker`, `nix`, and user scripts in `~/.config/devvm/layers/<name>.sh`) execute during image builds to modify the root filesystem on top of `base`; changing a VM's layers via `devvm layer add` or `devvm layer rm` remakes the VM disk from the new image chain while preserving its home directory and settings.

## Architecture: shared toolchains vs. image layers

Shared tools and image layers follow distinct build, storage, and attachment paths. Modifying a VM's shared tools updates `vm.json` and remounts EROFS images on the next shell launch, whereas modifying layers requires cloning a new VM disk from the target image chain.

```mermaid
flowchart TD
  subgraph HostConfig["Host configuration & lock (~/.config/devvm & ~/.local/share/devvm)"]
    MiseTOML["assets/shared-tools/mise.toml"]
    HostMise["Pinned host mise<br/>~/.local/share/devvm/cache/mise"]
    ToolLock["~/.local/share/devvm/mise.lock"]
    LayerScripts["Built-in & custom layers<br/>~/.config/devvm/layers/NAME.sh + NAME.policy"]
  end

  subgraph SharedStore["Read-only tool share (~/.local/share/devvm/tools)"]
    EROFS["<tool>-<version>-<key>.erofs<br/>+ .erofs.json record"]
  end

  subgraph ImageStore["Backend disk images"]
    BaseImg["base image<br/>(Ubuntu 26.04 or --from)"]
    LayerImg["Layered image<br/>(e.g. docker+mailpit)"]
  end

  subgraph GuestVM["Project VM"]
    VMDisk["Cloned VM root disk"]
    ShareMount["/mnt/devvm-tools<br/>(read-only host share)"]
    LoopMount["/opt/devvm/tools/<tool>/<version><br/>(EROFS loop mount via devvm-tools)"]
    MiseCfg["/etc/mise/config.toml<br/>& /etc/profile.d/devvm-tools.sh"]
  end

  MiseTOML --> HostMise --> ToolLock
  ToolLock -->|"build.sh in throwaway base VM"| EROFS
  BaseImg --> LayerScripts --> LayerImg
  LayerImg -. "CoW clone / overlay" .-> VMDisk
  EROFS --> ShareMount --> LoopMount --> MiseCfg
```

| Dimension | Shared toolchains (`--tools`, `devvm tools`) | Image layers (`--layers`, `devvm layer`) |
| --- | --- | --- |
| **Available names** | `node`, `python`, `rust`, `go`, `dotnet` (plus agents `claude`, `codex`, `opencode`) | Built-in `desktop`, `docker`, `nix`, plus custom `~/.config/devvm/layers/<name>.sh` |
| **Storage format** | Read-only `lz4hc`-compressed EROFS image (`<tool>-<version>-<key>.erofs`) | Full VM disk image keyed by 12-hex-digit SHA-256 chain hash |
| **Guest path** | Loop-mounted at `/opt/devvm/tools/<tool>/<version>` from `/mnt/devvm-tools` | Baked directly into the VM's root filesystem (`/`, `/usr`, `/etc`, `/nix`) |
| **Default selection** | Auto-detected from top-level project files and `devcontainer.json` features | Chosen via `devvm setup --layers`, `config.toml`, or `devcontainer.json` features |
| **Adding/removing on existing VM** | In-place state update (`devvm tools add` / `rm`); VM disk is untouched | Full VM disk remake (`devvm layer add` / `rm`); home folder and settings kept |
| **Updating versions** | `devvm tools update` or `devvm update` swaps mounted EROFS files | `devvm rebuild <image>` followed by `devvm update` remakes the VM disk |

---

## Shared toolchains

Every toolchain is defined in `internal/image/tool.go` (`image.SharedTools`) and configured in `assets/shared-tools/mise.toml`. When a new VM is created without `--tools`, `devvm` scans the top-level project directory for marker files (`detectFiles` in `internal/vm/detect.go`) in a fixed order and selects every matching toolchain.

### Supported toolchains and project markers

| Toolchain | Lock members | Auto-detection files (top-level) | Project version file | Writable global install path | Allowlist hosts (`assets/shared-tools/<name>.policy`) |
| --- | --- | --- | --- | --- | --- |
| `node` | `node` (held at `24`), `pnpm`, `bun`, plus `yarn` enabled via `corepack` | `package.json`, `bun.lock`, `bun.lockb` | `.nvmrc`, `.node-version` | `~/.npm-global/bin` (`NPM_CONFIG_PREFIX=~/.npm-global`) | `registry.npmjs.org`, `registry.yarnpkg.com`, `repo.yarnpkg.com`, `nodejs.org`, `bun.sh` |
| `python` | `python` (prebuilt `python-build-standalone`), `uv` | `pyproject.toml`, `requirements.txt`, `uv.lock`, `setup.py`, `Pipfile` | `.python-version` | `~/.local/bin` (or project virtualenv) | `pypi.org`, `files.pythonhosted.org`, `releases.astral.sh` |
| `rust` | `rust` (via pinned `rustup-init` with `rust-src` and `rust-analyzer`) | `Cargo.toml` | `rust-toolchain.toml` | `~/.cargo/bin` | `crates.io`, `index.crates.io`, `static.crates.io`, `static.rust-lang.org` |
| `go` | `go` (with `go telemetry off`) | `go.mod` | `toolchain` directive in `go.mod` | `~/go/bin` (`go_set_gobin = false`) | `proxy.golang.org`, `sum.golang.org`, `go.dev`, `dl.google.com` |
| `dotnet` | `dotnet` (SDK and ASP.NET Core / .NET runtimes) | `*.sln`, `*.slnx`, `*.csproj`, `*.fsproj`, `global.json` | `sdk.version` in `global.json` | `~/.dotnet/tools` (workloads in `~/.dotnet`) | `api.nuget.org`, `crl3.digicert.com:80`, `dot.net`, `builds.dotnet.microsoft.com` |

<Note>
`node` is held on release line `24` in `assets/shared-tools/mise.toml` because Node 25+ drops Corepack. All other toolchains track `"latest"`.
</Note>

### How toolchains are resolved, built, and mounted

1. **Lock resolution on the host:** `devvm` downloads a pinned `mise` binary to `~/.local/share/devvm/cache/mise/<version>/mise` (verified against `miseSHA256` in `internal/tools/mise.go`) and resolves `assets/shared-tools/mise.toml` into `~/.local/share/devvm/mise.lock` for both `linux-<arch>` (`gnu`) and `linux-<arch>-musl` (`musl`).
   - For `dotnet`, `mise` locks only the version number; `devvm` fetches Microsoft's channel metadata (`releases.json`) and injects each platform's `dotnet-sdk-<rid>.tar.gz` URL and `sha512:` checksum into the lock (`withSDKDownloads` in `internal/tools/dotnet.go`).
   - If the newest upstream release of a tool lacks a binary download for either `gnu` or `musl` (for example, while musl builds are still publishing), `stepBack` inspects up to 5 previous releases and locks the newest version that provides prebuilt binaries for all target platforms.
   - Once a day, launching a VM spawns `devvm _refresh-tools` in the background (`RefreshDaily`) to refresh `mise.lock` unless `DEVVM_NO_TOOL_CHECK=1` is set in the environment.
2. **Isolated EROFS build:** For each unbuilt tool image `<tool>-<version>-<key>.erofs`, `Share.Build` (`internal/tools/share.go`) boots a throwaway VM cloned from `base` with a dedicated temporary output directory mounted at `/mnt/devvm-tools`. Inside the build VM, `assets/shared-tools/build.sh` installs the locked version into `/opt/devvm/tools/<tool>/<version>`, scans ELF binaries with `readelf -W -V` on `gnu` builds to record the maximum `GLIBC_2.x` symbol requirement in `<file>.glibc`, and packs the directory using `mkfs.erofs -zlz4hc --all-root`.
3. **Guest mounting and `mise` activation:** On every VM launch, `guest/sync.sh` writes the VM's active tool list to `/etc/devvm/tools` and invokes `/usr/local/sbin/devvm-tools` (`assets/guest/tools.sh`). `devvm-tools` attaches each `.erofs` file from `/mnt/devvm-tools` via `losetup --find --show --read-only --direct-io=on` and mounts it read-only at `/opt/devvm/tools/<tool>/<version>`.
   - `sync.sh` generates `/etc/mise/config.toml` pointing `shared_install_dirs` to `["/opt/devvm/tools"]` and enabling idiomatic version files (`["node", "python", "bun", "pnpm", "dotnet"]`).
   - Non-interactive shells (`bash -c` from coding agents or SSH editors) load `/etc/profile.d/devvm-tools.sh` via `BASH_ENV`, while interactive shells also run `eval "$(mise activate bash)"` from `~/.bashrc.d/mise.sh`.
   - If any listed tool image is missing from `/mnt/devvm-tools` or fails to mount, `devvm-tools` writes the failed filenames to `/run/devvm/tools-unmounted` and exits `1`; `devvm` aborts the launch rather than allowing the guest to download an unverified replacement.

### Base libc and glibc compatibility

Every toolchain builds separately for `gnu` (built in `devvm`'s Ubuntu `base` and shared across all `gnu` VMs) and `musl` (built in the specific `--from` Alpine base image and keyed to its digest). Before attaching a `gnu` tool image to a `--from` Debian or Ubuntu container base, `Share.Runs` compares the base image's recorded glibc version against the tool image's recorded `Glibc` requirement and refuses the tool if the base's glibc is older:

| Shared tool | Required glibc (`arm64`) | Required glibc (`amd64`) |
| --- | --- | --- |
| `node` | `2.28` | `2.28` |
| `pnpm` / `bun` (bundled with `node`) | `2.18` / `2.17` | `2.18` / `2.17` |
| `python` | `2.17` | `2.17` |
| `uv` (bundled with `python`) | `2.28` | `2.17` |
| `rust` (with `rustup`) | `2.17` | `2.17` |
| `go` | none (statically linked / no glibc symbol needs) | none |
| `dotnet` | `2.27` (plus `libicu` / `icu-libs` in `base`) | `2.27` |

---

## Managing a VM's shared tools (`devvm tools`)

The `--tools` and `--agents` flags on `devvm` apply only when creating a new VM. On an existing VM, use `devvm tools` and its subcommands to inspect, add, remove, or update shared toolchains and coding agents in place.

<CodeGroup>
```bash title="Interactive picker or table"
# In a terminal: open the interactive checkbox picker for toolchains and agents
devvm tools

# Without a terminal, with --no-input, or with --json: inspect current and newer versions offline
devvm tools --no-input
devvm tools --json
```

```bash title="Add, remove, and update tools"
# Add toolchains or agents (separate words; builds missing images, then opens a shell)
devvm tools add python claude

# Remove shared tools from the VM (software installed inside the VM's disk stays)
devvm tools rm python

# Check upstream, build newer tool images, switch the VM to them, and open a shell
devvm tools update
```
</CodeGroup>

### Subcommands and behavior

- **`devvm tools` (interactive):** In a terminal, opens the three-section picker (`Toolchains`, `Coding agents`, `Layers`) with the VM's current tools checked and its layers greyed out (`Fixed: true`). Confirming applies additions and removals to `vm.json` and opens a shell in the VM. If nothing changed, `devvm` prints `Nothing changed.` and exits without starting the VM.
- **`devvm tools` (non-interactive or `--json`):** Reads `vm.json` and the local `~/.local/share/devvm/mise.lock` without network access or starting the VM. Prints a table with columns `TOOL`, `VERSION`, `NEWER`, and `BUILT` (with `pnpm`, `bun`, and `uv` listed on their own rows).
- **`devvm tools add TOOL...`:** Validates that each argument is a known toolchain or agent name (refusing names joined with `+` or `,`), builds any missing EROFS images, updates the `tools` section of `vm.json`, and opens a shell. If you add a coding agent whose host settings folders (`~/.claude`, `~/.codex`, etc.) are not yet mounted in a running VM, `devvm` prompts to restart the VM so the read-only host mounts take effect.
- **`devvm tools rm TOOL...`:** Removes the specified shared tools from `vm.json` and unmounts their EROFS images on the next sync. Refuses layer names (pointing to `devvm layer rm`) and tools the VM does not have. Any packages or alternate versions installed into the VM's home directory remain untouched.
- **`devvm tools update` vs. `devvm update`:** `devvm tools update` refreshes `mise.lock` online, builds any missing or newer EROFS images for the VM's current tools, updates `vm.json`, and opens a shell without touching the VM's root disk. `devvm update` runs this exact shared-tools update first, and then—if the VM's base or layer image chain or `devcontainer.json` has changed—prompts to remake the VM disk.

### `devvm tools --json` output schema

<RequestExample>
```bash
devvm tools --json
```
</RequestExample>

<ResponseExample>
```json
{
  "version": 1,
  "tools": [
    {
      "tool": "node",
      "version": "24.10.0",
      "newer": "24.11.0",
      "newer_built": false
    },
    {
      "tool": "pnpm",
      "version": "10.18.0"
    },
    {
      "tool": "bun",
      "version": "1.3.1"
    }
  ]
}
```
</ResponseExample>

<ResponseField name="version" type="integer" required>
  JSON schema version (`1`).
</ResponseField>
<ResponseField name="tools" type="array" required>
  Ordered list of individual lock tools mounted in the VM (`node` expands to `node`, `pnpm`, and `bun`; `python` expands to `python` and `uv`).
</ResponseField>
<ResponseField name="tools[].tool" type="string" required>
  Tool name in `mise.lock`.
</ResponseField>
<ResponseField name="tools[].version" type="string" required>
  Version currently mounted in the VM.
</ResponseField>
<ResponseField name="tools[].newer" type="string">
  Version in `mise.lock` when the lock's current image file differs from the VM's mounted file. Omitted when the VM already uses the lock's current build.
</ResponseField>
<ResponseField name="tools[].newer_built" type="boolean">
  Present only when `newer` is set. `true` if the newer `.erofs` image is already built in `~/.local/share/devvm/tools`; `false` if `devvm tools update` must build it first.
</ResponseField>

---

## Built-in layers and Nix projects

Unlike shared tools, layers run as bash build scripts inside an image VM on top of `base` and persist in the image's disk. Image names join layers bottom-up with `+` (for example, `docker+nix` runs `nix` on top of `docker` on top of `base`).

| Built-in layer | Source script & policy | What it installs into the image |
| --- | --- | --- |
| `desktop` | `assets/layers/desktop.sh`, `assets/layers/desktop.policy` | Xfce desktop environment and TigerVNC server (~370 MB) for `devvm desktop`. Needs no extra allowlist hosts. |
| `docker` | `assets/layers/docker.sh`, `assets/layers/docker.policy` | Docker Engine, CLI, BuildKit, and Compose from Docker's apt repository (or Alpine packages on `--from alpine`), adds the guest user to the `docker` group, and enables `docker.service`. |
| `nix` | `assets/layers/nix.sh`, `assets/layers/nix.policy` | Multi-user Nix daemon with `flakes` and `nix-command` enabled, plus `direnv` and `nix-direnv`. |

### Working with Nix projects

When a project contains `flake.nix`, `shell.nix`, `devenv.nix`, or a `default.nix` containing `mkShell` (up to the first 64 KiB, checked by `nixShell` in `internal/vm/detect.go`), `devvm` annotates the `nix` row in the interactive new-VM picker with that filename. Because layers are not enabled automatically from project files, pass `--layers nix` in non-interactive runs or check `nix` in the picker:

```bash
devvm --layers nix
```

- ** Automatic dev shells:** Add `use flake` (or `use nix`) to `.envrc` and run `direnv allow` once inside the VM. `nix-direnv` caches the evaluated profile under `~/.cache/nix-direnv` inside the VM home directory so no `.direnv` directory is written into the host project folder. While inside the project directory, flake binaries precede `/opt/devvm/tools` shims on `PATH`.
- **Daemon trust and sandboxing:** Only `root` is a trusted Nix user in the VM (`nix` ignores `--option substituters` from the unprivileged guest user), and Nix package builds always run inside the Nix sandbox.
- **Shared host binary cache (`http://devvm.proxy/nix`):** On every network tier with a host proxy, `guest/sync.sh` configures `/etc/nix/devvm-cache.conf` (`substituters = http://devvm.proxy/nix https://cache.nixos.org`) and sets the proxy environment on `nix-daemon`. When any VM requests a store path from `cache.nixos.org`, the host proxy verifies the `cache.nixos.org-1` signature and caches the NAR in `~/.local/share/devvm/cache/nix` (capped at 10 GiB by default, configurable with `devvm retention nix-cache.size=20G`, and cleared with `devvm gc --nix-cache`).

---

## Authoring custom layers (`devvm layer new`)

Custom layers let you bake system packages, daemons, or prebuilt Nix environments into reusable images. User layers are stored in `~/.config/devvm/layers` (`$XDG_CONFIG_HOME/devvm/layers`).

:::files
~/.config/devvm/
├── config.toml
└── layers/
    ├── mailpit.sh
    ├── mailpit.policy
    ├── myteam.sh
    └── myteam.policy
:::

### Naming rules and constraints

`build.CheckLayerName` (`internal/build/layer.go`) enforces strict naming rules before creating or building a layer:
- Must match `^[a-z0-9][a-z0-9-]*$` (lowercase ASCII letters, digits, and hyphens, starting with a letter or digit).
- Cannot be `base`.
- Cannot be a built-in layer name (`desktop`, `docker`, `nix`).
- Cannot be a shared tool name (`node`, `python`, `rust`, `go`, `dotnet`, `claude`, `codex`, `opencode`).

<Warning>
Do not run `devvm` inside `~/.config/devvm` to edit layer scripts with a coding agent. `devvm` refuses to create a project VM for any directory inside `~/.config/devvm` or `~/.local/share/devvm` to prevent a guest VM from modifying host configuration or layer scripts. Keep custom layers in a separate Git repository and copy them into `~/.config/devvm/layers/`.
</Warning>

### Creating and building a custom layer

<Steps>
  <Step title="Scaffold the layer script and network policy">
    Run `devvm layer new NAME` to atomically create `~/.config/devvm/layers/NAME.sh` and `~/.config/devvm/layers/NAME.policy` (if the policy file does not already exist). `devvm` refuses to overwrite an existing `NAME.sh`.

    ```bash
    devvm layer new mailpit
    ```

    This writes the starter script and policy template:

    ```bash title="~/.config/devvm/layers/mailpit.sh"
    #!/bin/bash
    # mailpit: a devvm layer. devvm runs it once, with bash as the VM's user (who has sudo), in an image
    # on top of base: devvm build mailpit builds it, and a new VM gets it with --layers mailpit. Editing it rebuilds that image,
    # and the ones built on it, on next use.
    # - Put PATH changes in ~/.profile.
    # - Download with devvm-fetch URL SHA256 OUT: it fails when the checksum doesn't match.
    # - On the allowlist tier, mailpit.policy next to this file lists the hosts VMs with this layer reach.
    set -euo pipefail

    # sudo apt-get install -y postgresql-client
    ```
  </Step>

  <Step title="Write the installation commands and pin downloads">
    The script runs once via `bash` as the unprivileged VM user (with passwordless `sudo`) during `devvm build`.
    - Use `devvm-fetch URL SHA256 OUT` (installed in `/usr/local/bin/devvm-fetch` on `base`) for all binary downloads so the build aborts on a SHA-256 mismatch.
    - Put interactive shell customizations in `~/.bashrc.d/<name>.sh` and `PATH` additions in `~/.profile`.

    ```bash title="~/.config/devvm/layers/mailpit.sh"
    #!/bin/bash
    set -euo pipefail

    mailpit_version=1.32.0
    mailpit_sha256_amd64=4b5993c978e8604c70d43927e2ad103b311bba3b1822fa0cf1d4ee4ec890a93c
    mailpit_sha256_arm64=e1e45d1dc72bd497fb262a1e4100178eee057c42c5fc36d143eb8621769e1137

    case "$(uname -m)" in
      x86_64) arch=amd64 sha256=$mailpit_sha256_amd64 ;;
      aarch64) arch=arm64 sha256=$mailpit_sha256_arm64 ;;
      *) echo "mailpit: no build for $(uname -m)" >&2; exit 1 ;;
    esac
    devvm-fetch "https://github.com/axllent/mailpit/releases/download/v$mailpit_version/mailpit-linux-$arch.tar.gz" \
      "$sha256" /tmp/mailpit.tar.gz
    sudo tar -xzf /tmp/mailpit.tar.gz -C /usr/local/bin mailpit
    rm /tmp/mailpit.tar.gz

    printf '%s\n' '[Unit]' 'Description=Mailpit' '[Service]' 'ExecStart=/usr/local/bin/mailpit' \
      'DynamicUser=yes' '[Install]' 'WantedBy=multi-user.target' |
      sudo tee /etc/systemd/system/mailpit.service >/dev/null
    sudo systemctl enable --now mailpit
    ```
  </Step>

  <Step title="Configure the layer's network policy">
    Edit `~/.config/devvm/layers/NAME.policy` to specify host rules for VMs using this layer (`netpolicy.Policies` in `internal/netpolicy/netpolicy.go`). Put one rule per line (`#` comments are ignored):
    - `example.com` or `*.example.com` (any subdomain on default HTTP/HTTPS/SSH ports)
    - `host:port` (specific port, such as `crl3.digicert.com:80`)
    - IPv4/IPv6 address or CIDR block
    - `!rule` (explicit deny rule; enforced on `allowlist`, `internet`, and `none` tiers)

    ```text title="~/.config/devvm/layers/mailpit.policy"
    # Hosts a VM with the mailpit layer can reach on the allowlist tier, one rule per line:
    api.sendgrid.com
    *.mailgun.net
    ```
  </Step>

  <Step title="Build or launch a VM with the layer">
    Build the image directly with `devvm build`, pass `--layers` when creating a new VM, or set it as a default for all new VMs:

    ```bash
    # Pre-build the image (or stacked image docker+mailpit)
    devvm build mailpit
    devvm build docker+mailpit

    # Create a new project VM with docker at the bottom and mailpit on top
    devvm --layers docker,mailpit

    # Set mailpit as a default layer for all new VMs
    devvm setup --layers mailpit
    ```
  </Step>
</Steps>

<Tip>
Because an image's 12-hex-digit content key (`image.Input.Key` in `internal/image/key.go`) hashes the parent image's key and the exact bytes of `NAME.sh`, editing `~/.config/devvm/layers/NAME.sh` automatically marks `NAME` and any layers stacked above it as `stale` and rebuilds them on next use. To re-run an unchanged script (for example, to pull newer unpinned `apt` packages), run `devvm rebuild NAME`.
</Tip>

### Baking a team Nix flake into a custom layer

To pre-populate a team's Nix dev shell inside an image so VMs start with all store paths already on their CoW-cloned disk, create a custom layer and stack it on top of `nix` (`devvm --layers nix,myteam` -> image `nix+myteam`):

```bash title="~/.config/devvm/layers/myteam.sh"
#!/bin/bash
set -euo pipefail

# Pin the team flake to an exact commit so the image key corresponds to that dev shell.
flake=github:myorg/devshell/0123456789abcdef0123456789abcdef01234567
bin=/nix/var/nix/profiles/default/bin
"$bin/nix" develop "$flake" --profile /tmp/myteam-shell --command true

# Register a GC root in the VM's home directory so nix-collect-garbage keeps the store paths.
mkdir -p ~/.cache/myteam
"$bin/nix-store" --add-root ~/.cache/myteam/dev-shell --realise "$(readlink -f /tmp/myteam-shell)"
rm -f /tmp/myteam-shell /tmp/myteam-shell-*-link
```

---

## Adding and removing layers on an existing VM

Because layers modify the root filesystem during image build, changing an existing VM's layers (`devvm layer add` or `devvm layer rm`) remakes the VM from the new image chain (`Lifecycle.AddLayers` and `Lifecycle.RemoveLayers` in `internal/vm/remake.go`).

```bash
# Add layers on top of the VM's current image (e.g., docker -> docker+desktop)
devvm layer add desktop
devvm layer add docker nix

# Remove layers while keeping remaining layers in their original order (e.g., docker+nix+desktop -> docker+desktop)
devvm layer rm nix

# Run non-interactively in scripts or start with a fresh home directory from the image
devvm layer add desktop --force
devvm layer rm docker --clean-home
```

### Flags for `devvm layer add` and `devvm layer rm`

<ParamField body="--force" type="boolean">
  Skip the interactive confirmation prompt (`Make <vm> again from <image>? [y/N]`). Required when running without a terminal or with `--no-input`. Does not bypass the parent-folder prompt when invoked from a subdirectory.
</ParamField>
<ParamField body="--no-export" type="boolean">
  Skip exporting coding agent session history (`~/.local/share/devvm/exports`) before replacing the VM disk.
</ParamField>
<ParamField body="--clean-home" type="boolean">
  Start the remade VM with the new image's clean home folder instead of saving and restoring the existing VM's home folder.
</ParamField>
<ParamField body="-C, --chdir" type="string">
  Run as if `devvm` was started in the specified project directory.
</ParamField>

### Remake lifecycle, preservation, and crash recovery

Before changing anything, `remaking.check` verifies that:
- All requested layer names exist (for `layer rm`, you can still remove a custom layer whose `.sh` script you deleted from `~/.config/devvm/layers/`, which is how you recover an outdated VM after deleting a layer script).
- The VM is not a `--clone` VM (to change layers on a `--clone` VM, run `devvm clone off`, then `devvm layer add`/`rm`, then `devvm clone on`).
- The backend supports all required features of the target image (`docker-layer`, `nix-layer`) and the host volume has sufficient free disk space.

Once confirmed, `remaking.remake` executes five atomic phases:
1. **Build target image:** Builds the new image chain (`base` through the top layer) so a build failure leaves the existing VM untouched.
2. **Export history and archive home:** If the VM has coding agents, exports their transcripts to `~/.local/share/devvm/exports/` (unless `--no-export`). Ensures the VM is running and archives its home directory to `~/.local/share/devvm/vms/<hash>/home.tar` (unless `--clean-home`), excluding `~/.cache`, `~/.config/devvm`, `~/.ssh/authorized_keys`, setuid files, and host-mounted agent config links.
3. **Set old machine aside:** Records the pending `remake` section in `vm.json`, stops the VM, proxy, port forwarders, and link sessions, and renames the backend VM to `<vm>-old` (`vmname.Aside`).
4. **Create and boot new machine:** Clones the new VM from the target image with the preserved settings (size, network tier, allowlist, secret bindings, SSH agent mode, links, groups, and `devcontainer.json` state), starts it, and restores the saved home archive before `guest/sync.sh` runs.
5. **Commit and drop old machine:** Marks `done: true` in `vm.json`, deletes the `<vm>-old` machine, clears the `remake` section and temporary `home.tar`, and opens a shell in the VM.

If any step fails or is interrupted with `Ctrl+C` before the home directory is restored in Step 4, the cleanup handler (`remaking.undo`) deletes the new machine and renames `<vm>-old` back to `<vm>`. If the `devvm` process is killed (for example, `SIGKILL` or power loss), `vm.json` retains the `remake` state; the next `devvm`, `devvm layer add`, `devvm layer rm`, or `devvm update` in the project directory automatically resumes and finishes the remake (`finishPending`).

## Related pages

<CardGroup cols={2}>
  <Card title="Images, layers, and content keys" href="/image-and-layer-model">
    Deterministic SHA-256 key computation for base images, built-in and custom layers, and read-only EROFS shared tool images.
  </Card>
  <Card title="Configure coding agents and credentials" href="/coding-agents-and-credentials">
    Run Claude Code, Codex, and opencode inside VMs, import and save agent settings, and bind host secrets.
  </Card>
  <Card title="Use devcontainer.json and OCI base images" href="/devcontainers-and-oci-images">
    Translate .devcontainer/devcontainer.json properties into VM plans and build VM base images from container references with --from.
  </Card>
  <Card title="CLI reference: images, storage, and host commands" href="/cli-image-space-and-host-commands">
    Complete flag and syntax reference for devvm build, rebuild, images, layer, tools, space, gc, retention, and setup.
  </Card>
</CardGroup>
