# How Do I Cache Dependencies in a Sandbox With a Named Snapshot?

> Install dependencies once in a sandbox, save it with sbx snapshot under a name that includes a hash of your lockfiles, and start each job with sbx new --from that name. On prod this replaced about 51 seconds of npm, uv and pip installs with a hash check. Rebuild the snapshot when the hash changes.

Source: https://sandboxer.com/guides/cache-dependencies-in-a-named-snapshot. Updated 2026-10-27.

**Short answer:** Install dependencies once in a sandbox, save it with sbx snapshot under a name that includes a hash of your lockfiles, and start each job with sbx new --from that name. On prod this replaced about 51 seconds of npm, uv and pip installs with a hash check. Rebuild the snapshot when the hash changes.

## Results

We ran this on prod on 2026-10-27 on `default` sandboxes (4 vCPU, 8 GB) in eu-central. Three runs per row; the median is in bold.

| Install | Fresh sandbox, empty cache | From the snapshot, cache only (`node_modules` / venv deleted) | From the snapshot, as saved |
| - | - | - | - |
| `npm ci`, Next.js app, 1,455 packages | 23.4, **23.8**, 25.3 s | 17.5, **17.9**, 18.3 s | **0 s** (skipped) |
| `uv sync --frozen`, FastAPI app, 77 packages | 3.1, **1.9**, 1.9 s | 0.1, **0.0**, 0.0 s | **0 s** (skipped) |
| `pip install -r requirements.txt`, same FastAPI deps | 25.5, **25.5**, 23.2 s | 18.1, **18.4**, 18.5 s | **0 s** (skipped) |
| **Total** | **51.2 s** | **36.3 s** | **0.00 s** hash check |

The projects were two real public repos at their current commits: [mckaywrigley/chatbot-ui](https://github.com/mckaywrigley/chatbot-ui) (Next.js 14, `package-lock.json`, commit `81328b6`) and [fastapi/full-stack-fastapi-template](https://github.com/fastapi/full-stack-fastapi-template) (`uv.lock`, commit `f4bcfab`). The pip row installs the same locked dependencies, exported with `uv export`, into a plain `python3 -m venv`.

Creating the sandbox cost the same either way: `sbx new` and `sbx new --from` each reported ready in 3.2 seconds (one run each).

What the numbers say:

- **The big win is skipping the install, not the warm cache.** With `node_modules` and the venv already on disk, there is nothing to do. A warm npm or pip cache alone saved only about a quarter of the time, because both still unpack and link every package.
- **uv is already fast.** A cold `uv sync` took under 2 seconds once its Python was present (run 1 also fetched the project's managed CPython 3.14). The snapshot still matters for uv when you use it to skip a slow toolchain or a large wheel set, but the per-job saving here was small.
- **Measure your own project.** These are network-bound installs on one day. A project with native builds (node-gyp, Rust wheels, source-built Python packages) gains more.

## Step 1: name the snapshot after your lockfiles

A named snapshot has no versions: saving again under the same name replaces it. So put the version in the name. Hash the lockfiles, and the name changes exactly when your dependencies do:

```bash
cat web/package-lock.json api/uv.lock | sha256sum | cut -c1-12
```

```text
312bbcdb2bce
```

The snapshot is then `chatbot-deps-312bbcdb2bce`. Names are 1 to 63 lowercase letters, digits or dashes, so keep the prefix short.

## Step 2: a script that reuses or rebuilds it

`deps-snapshot.sh` prints the snapshot name for the current lockfiles. If no snapshot has that name, it builds one in a throwaway sandbox, saves it, deletes the builder, and removes older snapshots with the same prefix. It needs `sbx` and `jq` on the machine that runs it (your laptop or CI runner).

```bash
#!/usr/bin/env bash
# Print the name of a named snapshot whose dependencies match these lockfiles.
# Build it first if there is none.
# Usage: deps-snapshot.sh <prefix> '<install command>' <lockfile>...
set -euo pipefail
prefix=$1 install=$2; shift 2

# 1. Name the snapshot after a hash of the lockfiles.
hash=$(cat "$@" | sha256sum | cut -c1-12)
name="$prefix-$hash"

# 2. Reuse it if it exists.
if sbx snapshots --json | jq -e --arg n "$name" '.named[] | select(.name == $n)' >/dev/null; then
  echo "$name"; exit 0
fi

# 3. Build it: fresh sandbox, install, save, delete the builder.
id=$(sbx new --name "build-$name" --type default --json | jq -r 'select(.event == "ready") | .id')
trap 'sbx delete "$id" --yes >/dev/null' EXIT
sbx exec "$id" --timeout 600 "$install && echo $hash > ~/.deps-hash" >&2
sbx snapshot "$id" "$name" >&2

# 4. Remove older snapshots with the same prefix.
sbx snapshots --json |
  jq -r --arg p "$prefix-" --arg n "$name" '.named[] | select((.name | startswith($p)) and .name != $n) | .name' |
  while read -r old; do sbx snapshot rm "$old" >&2; done
echo "$name"
```

The install command for our two projects, in `install.sh`. It runs as `user` in `/home/user`, so the npm, pip and uv caches land in the home directory and are saved with the disk:

```bash
set -e
git clone -q --depth 1 https://github.com/mckaywrigley/chatbot-ui web
git clone -q --depth 1 https://github.com/fastapi/full-stack-fastapi-template api
cd ~/web && npm ci --no-audit --no-fund --loglevel=error
cd ~/api && uv sync --frozen --quiet
uv export --frozen --no-hashes --no-emit-workspace --quiet > requirements.txt
python3 -m venv pipvenv && pipvenv/bin/pip install -q -r requirements.txt
```

Run it from a checkout that has the same lockfiles:

```bash
time ./deps-snapshot.sh chatbot-deps "$(cat install.sh)" web/package-lock.json api/uv.lock
```

The first run built the snapshot (output trimmed):

```text
added 1455 packages in 24s
{"ok":true,"type":"snapshot.named.info","snapshot":{"name":"chatbot-deps-312bbcdb2bce",…,"status":"ready","type":"default",…}}
chatbot-deps-312bbcdb2bce

real	1m1.016s
```

The second run found it and returned at once:

```text
chatbot-deps-312bbcdb2bce

real	0m0.133s
```

The builder sandbox was gone afterwards (`sbx info` returned `not_found`), because the `trap` deletes it even if the install fails.

## Step 3: start every job from it

```bash
name=$(./deps-snapshot.sh chatbot-deps "$(cat install.sh)" web/package-lock.json api/uv.lock)
sbx new --from "$name"
```

The new sandbox takes the snapshot's size (`default` here) unless you pass `--type`. Its dependencies work straight away:

```bash
sbx exec sb_z6j5c9bgpc 'cd ~/web && npx next --version; cd ~/api && uv run --frozen python -c "import fastapi, sqlmodel; print(\"fastapi\", fastapi.__version__)"'
```

```text
Next.js v14.1.0
fastapi 0.141.1
```

## Step 4: check the hash inside the sandbox

The job may check out a newer commit than the one in the snapshot. Put a guard at the start of the job: skip the install when the lockfiles still match the hash saved at build time, and fall back to an install from the warm cache when they don't.

```bash
#!/usr/bin/env bash
# deps-check.sh: run at the start of every job in a sandbox made with --from.
set -euo pipefail
want=$(cat ~/web/package-lock.json ~/api/uv.lock | sha256sum | cut -c1-12)
if [ "$want" = "$(cat ~/.deps-hash 2>/dev/null)" ]; then
  echo "deps match snapshot ($want), skipping install"
else
  echo "lockfiles changed, installing from the warm cache"
  (cd ~/web && npm ci --prefer-offline --no-audit --no-fund)
  (cd ~/api && uv sync --frozen)
  echo "$want" > ~/.deps-hash
fi
```

When the lockfiles matched (three runs):

```text
deps match snapshot (312bbcdb2bce), skipping install
0.00 s
```

When we made the saved hash stale, the fallback ran and took 21.3 seconds (one run), still faster than a cold install because the caches were in the snapshot:

```text
lockfiles changed, installing from the warm cache
…
added 1455 packages in 21s
Checked 77 packages in 0.74ms
21.3 s
```

If the fallback runs often, your snapshot is out of date. Run `deps-snapshot.sh` again from CI on the main branch so the next job gets a fresh one.

## When to rebuild

| Change | Rebuild? |
| - | - |
| A lockfile changed (`package-lock.json`, `uv.lock`, `requirements.txt`, `pnpm-lock.yaml`) | Yes. The hash changes, so the script builds a new name. |
| Only source code changed | No. Pull the new code in the job; `node_modules` and the venv still match. |
| Your install step changed (a new apt package, another Node or Python version) | Yes. Pass `install.sh` as one more file to hash, so editing it also changes the name. |
| The snapshot is old but the lockfiles match | Only if the install step fetches something no lockfile pins, such as `apt-get upgrade`. |

## What it costs

A named snapshot has no separate charge. What it stores counts toward your plan's included storage, together with stopped sandboxes: 10 GB on the free trial. Storage is measured as the data beyond the shared OS image. In our build, `node_modules` was 1.4 GB, `~/.npm` 277 MB, the uv cache 253 MB and the pip cache 95 MB. That is why the script deletes older snapshots with the same prefix. See [Billing](/docs/billing) for the other plans.

## FAQ

### Should the snapshot hold node_modules or only the npm cache?

Both. In our runs the installed `node_modules` saved all 23.8 seconds, and the cache alone saved about 6. The cache is the fallback for when the lockfile changes between snapshot builds.

### Does the snapshot keep running processes?

No. A named snapshot saves the disk, so a sandbox started from it boots fresh. Enabled systemd services start; anything else you start in the job.

### Can I put API keys in the dependency snapshot?

Keep them out. A named snapshot saves the whole disk, including any `.npmrc` or credentials file the build step wrote. Pass secrets at job time with `--environment` next to `--from`.

### Why hash the lockfile and not package.json?

The lockfile pins every resolved version, so its hash changes exactly when the installed tree would. `package.json` or `pyproject.toml` can stay the same while resolved versions move.

### Is a fork faster than --from for this?

Both copy a disk, so the dependencies come across either way. A fork needs its source sandbox to still exist; a named snapshot doesn't, which is why the script deletes the builder.

## Related

- [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template)
- [How fast does a cloud sandbox start, resume and fork?](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork)
- [Run each GitHub Actions job in a fresh microVM](/guides/run-each-github-actions-job-in-a-fresh-microvm)
- [Snapshots](/docs/snapshots)
