Volumes
Persistent disks that outlive sandboxes. Create a volume once, attach it, and keep the data.
A volume is persistent storage that outlives a sandbox. Platinum has two explicit products with intentionally different guarantees.
Volumes are feature-gated per org. Manage them in the dashboard or through the API.
Choose a volume type
| Type | Backing | Best for | Constraints |
|---|---|---|---|
| Local Volume | host-local sparse ext4 over virtio-blk | databases and latency-sensitive single-sandbox state | host-pinned; one writable sandbox at a time; host loss loses the volume |
| Shared Volume | dedicated S3-compatible object namespace via host-side FUSE + virtio-fs | files, datasets, model weights, source, caches, and collaborative agents | portable and multi-attach, but not block storage or full POSIX/transactional semantics; never use for databases, SQLite, or lockfiles |
Constraints
- Local Volumes attach to one sandbox at a time and only on their pinned host.
- Shared Volumes can attach read-only to multiple sandboxes and hosts. Gated mounted writes allow at most one pending or active
rwattachment per volume, which may coexist withroreaders. Every attachment has its own mount path and optional confinedsubpath. - The S3 mount has no real symlinks, hard links, device nodes, advisory locks, or transactional rename. Workloads such as
npm installthat rely on symlinks are unsupported on Shared Volumes. - Sandbox snapshots/backups retain attachment metadata, not volume contents. Use explicit volume snapshots for volume data.
Historical prototype baseline
Measured on 2026-08-24 in one 1-vCPU KVM sandbox mounting Local and a now-disabled Shared RW prototype simultaneously, with Shared backed by Scaleway Object Storage in fr-par. Each cell is 30 guest-side samples in milliseconds. These are design evidence only, not supported-current behavior or durable-S3 commit latency, and are not projections for other hosts or regions.
| Operation | Local p50 / p95 / p99 | Shared p50 / p95 / p99 |
|---|---|---|
| Sequential write, 4 MiB | 0.920 / 1.008 / 2.251 | 2.746 / 2.985 / 3.134 |
| Sequential read, 4 MiB | 0.718 / 0.748 / 0.750 | 2.959 / 3.426 / 3.457 |
| Random read/write, 128 × 4 KiB | 0.638 / 0.705 / 0.726 | 5.841 / 10.390 / 11.139 |
| 20 create/stat/rename/delete cycles | 0.815 / 0.843 / 0.865 | 22.600 / 39.637 / 40.822 |
Create a volume
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | — | Required, unique case-insensitively per organization. Rename does not move data. |
type | local | shared | local | Choose behavior above. Shared is gated per organization. |
size_gb | int 1–500 | 1 | Local allocation size. Shared direct-file and mounted mutations are admitted against it. |
mount_path | string | /mnt/data | Local Volume default mount point. Shared paths belong to attachments. |
sandbox_id | string | — | Local only: place the volume on that sandbox's host and auto-attach if it runs. |
Local creation is synchronous and returns when the sparse ext4 disk is ready. Shared creation records the isolated S3 namespace immediately; objects are created only when files are written.
A new volume returns in state available. Pass a sandbox_id for a running sandbox and it returns attached. Omit sandbox_id and the API picks the host.
import { Platinum } from "@platinum-dev/sdk";
const client = new Platinum({ url: process.env.PT_API_URL, token: process.env.PT_TOKEN });
const vol = await client.volumes.create({ name: "project-db", type: "local", size_gb: 10, sandbox_id: "sbx_01K…" });from platinum import Platinum
client = Platinum(token="pt_live_…", api_url="https://api.platinum.dev")
vol = client.volumes.create("project-db", type="local", size_gb=10, sandbox_id="sbx_01K…")pt volume create --name project-data --type shared --size 10curl -sS -X POST "$PT_API_URL/v1/volumes" \
-H "Authorization: Bearer $PT_TOKEN" \
-d '{"name":"project-data","type":"shared","size_gb":10}'Attach at sandbox create
Use volumes for explicit Local or Shared attachments. One sandbox can mount several mixed volume types at distinct paths. Each descriptor carries its own mount path, Shared subpath, and access mode. volume_ids remains only as the backward-compatible Local Volume shorthand; a Shared id in volume_ids is rejected instead of being silently treated as a block disk.
const local = await client.volumes.create({ name: "database", type: "local", size_gb: 10 });
const shared = await client.volumes.create({ name: "project-data", type: "shared", size_gb: 20 });
const sbx = await client.sandboxes.create(
{
template: "pt-base",
volumes: [
{ volume_id: local.id, mount_path: "/mnt/database", mode: "rw" },
{ volume_id: shared.id, mount_path: "/mnt/project", subpath: "users/alice", mode: "ro" },
],
},
{ waitForRunning: true },
);Explicit volume snapshots
Volume snapshots are separate from sandbox snapshots. A Shared snapshot first rejects active rw attachments, blocks direct mutations, copies data/ into an S3 generation, then writes its manifest last. Only ready manifests are restorable. This is consistent only because writers are quiesced; it is not an atomic snapshot of concurrent writes.
const snapshot = await client.volumes.snapshots.create("vol_…");
await client.volumes.snapshots.restore("vol_…", snapshot.id);Shared restore is quiesced but S3 has no multi-object atomic rename: an error is returned if the copy fails, never a fake success. Each generation manifest records every object, size, and ETag; restore verifies the complete generation before replacing live data. Per-attempt fencing stops a stale control-plane worker from publishing or clearing a newer operation. On buckets without Object Lock this is application-enforced immutability, not provider WORM storage.
Shared snapshots are capped at 10,000 objects and a 16 MiB manifest. The API returns shared_snapshot_too_large before copying any object when a source exceeds the object cap, and refuses an oversized manifest before restore. Split larger datasets across volumes; the service never reports a partial generation as ready.
Local snapshots require the volume to be detached. The host proves no process holds the stable backing inode, runs read-only e2fsck, captures an atomic reflink beneath volume-snapshots/<volume-id>/<snapshot-id>/, and verifies the captured ext4 image before marking it ready. Restore uses the same offline checks and atomically replaces the stable backing path. A host filesystem without reflink support returns an explicit failure.
Local Volume limits
A Local Volume is a sparse ext4 file on one host's data disk, so its declared size is a claim on physical capacity every sandbox on that host shares. Limits are tier-configurable and enforced at admission.
| Limit | free | t1 | t2 / pro | t3 | t4 | enterprise |
|---|---|---|---|---|---|---|
| volumes | 0 | 1 | 2 | 5 | 10 | 20 |
| aggregate GiB | 0 | 5 | 20 | 100 | 250 | 1000 |
| max GiB per volume | 0 | 5 | 10 | 25 | 50 | 100 |
| snapshots per volume | 0 | 0 | 1 | 2 | 3 | 5 |
| mounted per sandbox | 0 | 1 | 2 | 4 | 6 | 8 |
| creates / minute · hour | 0 | 1 · 3 | 1 · 5 | 1 · 10 | 1 · 10 | 2 · 20 |
| snapshots / 10 min · day | 0 | 0 | 1 · 2 | 1 · 5 | 1 · 5 | 2 · 10 |
Zero disables an operation.
It never means unlimited. An organization on
free cannot create a Local Volume at all.
Local Volumes are opt-in per organization: flags.volumes_enabled alone
grants nothing. Creation and new attachments additionally require
flags.local_volumes_enabled and an explicit
limits.org_overrides[<orgId>].localVolumes = true. Revoking that grant stops
new creates and attachments only — listing, detaching, downloading, snapshot
restore and deletion stay available so no data is stranded.
Admin configuration cannot exceed the hard ceilings: 100 volumes, 1024 GiB aggregate, 500 GiB per volume, 20 snapshots per volume, 16 mounted per sandbox, 10 creates/minute, 100 creates/hour, 10 snapshots/10 minutes, 100 snapshots/day.
Lowering a limit below current usage blocks new allocation and deletes nothing.
Host capacity
Sparse allocation means a volume admitted today can fill a disk next week, so admission reserves the declared size rather than the written size:
projectedFree = observedFree - declaredButUnwritten - requested
required = max(local_volume_host_min_free_gib, total × local_volume_host_min_free_pct / 100)
admitted ⟺ projectedFree ≥ requiredobservedFree comes from the host agent's heartbeat, not from what the
scheduler believes it handed out. Defaults are 50 GiB and 15%; the stricter of
the two wins. A host whose disk telemetry is missing or older than 120 seconds
is refused, never estimated. Shared Volumes are not counted here — their bytes
live in the object store and their per-attachment cache is separately capped.
Capacity is released when the host confirms the backing file is gone, not when a delete is requested.
Errors
local_volume_creation_disabled · local_volume_max_size ·
local_volume_max_count · local_volume_max_total ·
local_volume_create_rate_minute · local_volume_create_rate_hour ·
local_volume_snapshot_count · local_volume_snapshot_rate_ten_minutes ·
local_volume_snapshot_rate_day · local_volume_host_capacity ·
local_volume_mounted_limit
Returned as 403 (not granted), 409 (count or mount ceiling), 413 (size or capacity) or 429 (rate). None of them expose host paths or fleet capacity.
Shared Volume file API and quota boundary
Use the direct file API while a Shared Volume is unmounted or mounted read-only. Uploads stream through the control plane and require Content-Length; the API enforces the configured size against direct writes and reports usage_bytes/object_count after mutations. The control plane's request-body ceiling is 128 MiB by default (PT_MAX_REQUEST_BODY_BYTES), so larger direct uploads are explicitly unsupported by the configured rollout limits. Direct mutations are serialized per volume so concurrent uploads cannot each reserve the same remaining capacity; callers receive 409 and retry when another mutation is active.
Mounted read/write is a default-off gated rollout. Admission requires the global Admin switch, an explicit organization/tier grant, and a fresh host advertising the exact write-gateway v1 fingerprint. Even if a configured tier contains a higher number, the hard protocol ceiling is one pending or active writer per volume. This is not multi-writer support.
For an admitted writer, bytes flow from virtiofs through rclone to a root-only loopback gateway and bounded durable spool, then directly to the configured Shared Volume S3 bucket. Provider credentials never enter the sandbox or RW rclone process, and file bytes never transit the control plane. The control plane atomically reserves growth and object count before the provider accepts bytes, then commits the actual logical delta from provider evidence. Reads do not contact quota accounting; a control-plane outage therefore leaves reads available while new writes fail closed.
The v1 filesystem contract is sequential create/truncate/write/fsync/close,
copy/rename, and delete. It is not a general POSIX filesystem: random or append
writes, mmap durability, hard links, locks, transactional rename, databases,
SQLite, and package-manager trees that require symlinks remain unsupported.
Quota or emergency-lock rejection surfaces as filesystem EIO at the write or
fsync durability boundary, and rejected bytes are never published to readers.
The host caps new Shared attachment processes from physical memory (one slot per 16 GiB, clamped to 1-32, with at most 2 GiB of bounded hardware-accounting slack) in addition to the 10 GiB hard cache image and 20 GiB host free-space floor. Existing retained-cache attachments keep their reservation across restart even when a host is over the current cap; new attachments fail explicitly until old slots drain.
Recursive directory deletion first enumerates the complete subtree without
mutating it, then deletes with bounded concurrency. It is capped at 10,000
objects per request; a larger subtree returns
shared_directory_delete_too_large before deleting anything. Delete smaller
subtrees in bounded calls, or delete the whole volume through its separate
asynchronous bounded reaper; a read-only mount cannot perform cleanup.
// Byte-like bodies infer the length. A ReadableStream needs the known byte count
// so the API can reserve quota before it starts reading the stream.
await client.volumes.files.upload("vol_…", "datasets/train.jsonl", stream, {
contentLength: datasetBytes,
contentType: "application/jsonl",
});
const page = await client.volumes.files.list("vol_…", { path: "datasets" });# Bytes infer their length. Iterators and async iterators stay streaming when
# given the known length; the SDK never buffers them to discover it.
client.volumes.upload("vol_…", "datasets/train.jsonl", chunks(),
content_length=dataset_bytes,
content_type="application/jsonl")# The CLI sends the local file as a stream; it does not read it all into memory.
pt volume files upload vol_… ./train.jsonl --path datasets/train.jsonl
pt volume files download vol_… datasets/train.jsonl ./train-copy.jsonlPresigned downloads are supported. Presigned direct uploads remain disabled: they would bypass snapshot barriers and quota admission. Direct API uploads above the control-plane request limit are unsupported; use an explicitly admitted v1 mounted writer for sequential filesystem workloads, or split the dataset.
Attach to a running sandbox
Local attach requires a running sandbox on the volume's pinned host. Shared
read-only attach may target any eligible host and may mount the same volume into
multiple sandboxes. Shared rw attach additionally requires every rollout
gate and the exact fresh write-gateway v1 capability; otherwise it fails closed.
The request returns 200 when a supported attach completed promptly or 202
while the durable attachment command continues.
Shared detach is fail-closed. It first performs a normal guest unmount. An open file, working directory, or bind mount can reject detach. The attachment and host backend remain live. Close the handles and retry. The host persists the guest kernel mount identity before unmount. It waits for descriptors with that identity, including renamed or unlinked files. Shared attach and detach are not automatically replayed after a definitive host error. The caller controls the retry.
Attachments survive stop and start. The volume comes back mounted. When you delete the sandbox, the volume returns to available with data intact. An initial create that never boots releases its admitted leases; a failed restart remains retryable and retains its Local Volume lease while Shared attachments are rearmed for replay.
await client.volumes.attach("vol_…", "sbx_01K…", {
mountPath: "/mnt/project",
subpath: "users/alice",
mode: "ro",
});client.volumes.attach("vol_…", "sbx_01K…", mount_path="/mnt/project",
subpath="users/alice", mode="ro")pt volume attach vol_… --sandbox sbx_01K… --mount-path /mnt/cachecurl -sS -X POST "$PT_API_URL/v1/volumes/vol_…/attach" \
-H "Authorization: Bearer $PT_TOKEN" \
-d '{"sandbox_id": "sbx_01K…", "mount_path": "/mnt/cache"}'Move data between sandboxes
Detach the volume, then attach it to another sandbox on the same host. Detach flushes all writes to disk. The next attach sees exactly what the last writer left.
List and get
Pass includeDeleted to include deleted volume records in the list.
const vols = await client.volumes.list();
const one = await client.volumes.get("vol_…");
const all = await client.volumes.list({ includeDeleted: true });vols = client.volumes.list()
one = client.volumes.get("vol_…")
all_ = client.volumes.list(include_deleted=True)pt volume list
pt volume get vol_…# routes are listed in /docs/api — or use the SDK or CLIDetach
await client.volumes.detach("vol_…");client.volumes.detach("vol_…")pt volume detach vol_…curl -sS -X POST "$PT_API_URL/v1/volumes/vol_…/detach" \
-H "Authorization: Bearer $PT_TOKEN" -d '{}'Delete
Delete removes the data for good. The record stays for include_deleted listings, but the data is not recoverable. Delete fails while the volume is attached — detach first. Local snapshots must be deleted explicitly before deleting their Local Volume. Shared Volume deletion tombstones first, then the bounded reaper removes both live data and snapshot generations; snapshot generations are never part of sandbox backups.
await client.volumes.delete("vol_…");client.volumes.delete("vol_…")pt volume rm vol_…# route is listed in /docs/api — or use the SDK or CLI