Checkpoint / Restore¶
checkpoint() captures a running container's filesystem; fromCheckpoint boots an ordinary
container from it. The headline use case is fixture reuse across a test suite: boot a database
once, migrate and seed it, checkpoint — then restore that seeded state per test instead of
re-running migrations every time.
Be honest about what this is¶
A checkpoint is a filesystem capture, not a memory snapshot:
- Everything written to disk before the checkpoint — migrated schema, seeded rows, files a process wrote — is there when you restore.
- Processes restart from scratch. A restored container runs the image's normal
entrypoint/command again from the beginning; it does not resume mid-execution, and no
in-memory state (open connections, caches, buffered writes not yet flushed to disk) survives.
If you have just written files via
execInContainer, runsyncin the guest before checkpointing — an unflushed write is exactly the kind of in-memory state a checkpoint does not capture. - RAM-backed mounts are not on disk at all. The microsandbox guest mounts
/tmpas tmpfs, so files written there never enter a checkpoint — write anything you need restored to a rootfs path such as/srvor/var. - Restoring is a normal, fresh container boot against a checkpoint reference — the same startup cost as any other container, just skipping whatever setup work is already baked into the filesystem.
True microVM memory snapshots — resuming a process mid-execution — need upstream microsandbox support and stay on the roadmap.
Mechanism per backend¶
Both backends support checkpoint/restore today, via different mechanisms:
| Docker | microsandbox | |
|---|---|---|
| Mechanism | Commit the running container to a new image | Stop the sandbox, snapshot its disk, and boot it back from that snapshot under the same name and ports |
| Source container afterward | Undisturbed — never stopped | Briefly stopped, then running again |
| Workload | Never restarts | Restarts (the VM reboots) |
capabilities.checkpointRestartsWorkload |
false |
true |
Checkpoint.ref shape |
rightsize/checkpoint:<12-hex> (an image tag) |
rz-ckpt-<12-hex> (a snapshot name) |
microsandbox's msb snapshot create requires the sandbox stopped, so checkpoint() there runs
msb stop → msb snapshot create --from <sandbox> <ref> → msb rm <sandbox> → a fresh attached
msb run --snapshot <ref> under the same name, ports, env, and memory limit — the sandbox ends up
running again under the same name, but its workload command re-ran from scratch to get there.
Because of that, checkpoint() re-applies the container's own wait strategy before returning
whenever the active backend's capabilities.checkpointRestartsWorkload is true — a bare return
would otherwise hand back a container that looks ready but whose workload hasn't actually come
back up yet (e.g. msb's loopback forwarder accepts TCP before the guest listens). Docker's
checkpoint never restarts anything, so no re-wait happens there. Either way this is transparent to
callers: checkpoint() only returns once the source container is genuinely ready again.
If the microsandbox snapshot step itself fails, the sandbox is left stopped and checkpoint()
throws naming the failed step — resume it by hand with msb start <sandbox> — rather than
best-effort restarting it, since the disk snapshot is unaffected by the sandbox's own state. If
the snapshot succeeds but the re-boot from it fails (after the stopped sandbox has already been
removed), checkpoint() throws naming the checkpoint ref: the original sandbox is gone, but its
state is still recoverable via GenericContainer.fromCheckpoint.
The reboot also kills microsandbox's emulated network links (the exec-tunneled installNetworkLinks
connections a container on a Network had at start), so checkpoint() re-establishes them against
the same sandbox before returning, right alongside the wait-strategy re-run.
Capability¶
capabilities.checkpoint is true on both real backends:
val backend = Backends.active()
backend.capabilities.checkpoint // true on both docker and microsandbox
backend.capabilities.checkpointRestartsWorkload // docker: false, microsandbox: true
checkpoint() checks capabilities.checkpoint before any backend call — this only fires for a
backend that doesn't declare the capability at all (e.g. a test double), not for either real
backend.
API¶
val db = GenericContainer("postgres:16-alpine")
.withExposedPorts(5432)
.waitingFor(Wait.forLogMessage(".*ready to accept connections.*"))
db.start()
db.execInContainer("psql", "-U", "postgres", "-f", "/schema.sql") // migrate + seed
val cp = db.checkpoint() // { ref: "rightsize/checkpoint:<12 hex>" or "rz-ckpt-<12 hex>", backend, spec }
db.stop()
val restored = GenericContainer.fromCheckpoint(cp)
.waitingFor(Wait.forLogMessage(".*ready to accept connections.*"))
restored.start() // fresh container, migrated schema and seed rows already on disk
checkpoint():
- only works on a running container — a never-started or already-stopped one throws
IllegalStateException; - on an unsupported backend, throws
CheckpointUnsupportedExceptionbefore any backend call is made — naming the active backend and pointing atcapabilities.checkpoint; - returns a
Checkpoint:ref(backend-shaped, random per call — two checkpoints of the same container never collide),backend(the backend that created it —"docker"/"microsandbox", matchingRIGHTSIZE_BACKEND), andspec(the source container's env, command, exposed ports, and memory limit); - optionally takes a
name(checkpoint("seeded-db")) to make the checkpoint durable and rediscoverable from any later process, instead of living only in the returnedCheckpoint— see Reusing checkpoints across runs below.
GenericContainer.fromCheckpoint(cp):
- builds a normal container whose env/command/exposed ports/memory limit default to
cp.spec— apply the usualwithXbuilders afterward to override any of them (a different wait strategy, extra env, more exposed ports) before callingstart(); - requires the active backend at
start()time to matchcp.backend— restoring an msb snapshot under the docker backend (or vice versa) throwsCheckpointBackendMismatchExceptionbefore any backend call, naming both backends and theRIGHTSIZE_BACKEND=<creator>remedy; - returns an ordinary container in every other respect: fresh host ports, a normal reaping
ledger entry, normal
stop(). Nothing about a restored container is special once it's running — it's indistinguishable from a container booted any other way.
The seeded-fixture pattern¶
The pattern this feature exists for: checkpoint once per test suite, restore once per test.
class OrderRepositoryTest {
companion object {
private lateinit var checkpoint: Checkpoint
@JvmStatic
@BeforeAll
fun seedOnce() {
val db = GenericContainer("postgres:16-alpine")
.withExposedPorts(5432)
.waitingFor(Wait.forLogMessage(".*ready to accept connections.*"))
db.start()
db.execInContainer("psql", "-U", "postgres", "-f", "/schema.sql")
db.execInContainer("psql", "-U", "postgres", "-f", "/seed.sql")
checkpoint = db.checkpoint()
db.stop()
}
}
private lateinit var db: GenericContainer<*>
@BeforeEach
fun restore() {
db = GenericContainer.fromCheckpoint(checkpoint)
.waitingFor(Wait.forLogMessage(".*ready to accept connections.*"))
db.start()
}
@AfterEach
fun teardown() = db.stop()
// each test gets its own restored copy of the migrated + seeded database, without
// re-running migrations or seed scripts, and without one test's writes leaking into another
}
Every test restores from the same ref, so each gets an independent copy of the seeded state —
writes in one test never leak into another, and there's no need to reset/truncate tables between
tests.
Restoring under the wrong backend¶
A checkpoint's ref is only meaningful to the backend that created it — a docker image tag means
nothing to msb, and an msb snapshot name means nothing to docker. Restoring under a different one
fails fast:
This checkpoint was created by the 'microsandbox' backend, but the active backend is 'docker' —
set RIGHTSIZE_BACKEND=microsandbox to restore it, or call checkpoint() again under 'docker' to
create one it can restore
Reusing checkpoints across runs¶
Everything above makes a checkpoint work like a pause button: the Checkpoint object
checkpoint() returns is the only way to reach it, and it only exists for as long as this
process keeps it around. Passing a name to checkpoint() instead makes the checkpoint
durable and rediscoverable from any later process — the headline use case is a checkpoint seeded
once (in CI setup, or by hand) and restored by every later test run without ever re-running the
seed step in-process:
val db = Checkpoint.find("seeded-db") ?: run {
val seed = GenericContainer("postgres:16-alpine")
.withExposedPorts(5432)
.waitingFor(Wait.forLogMessage(".*ready to accept connections.*"))
seed.start()
seed.execInContainer("psql", "-U", "postgres", "-f", "/schema.sql")
val cp = seed.checkpoint("seeded-db")
seed.stop()
cp
}
val restored = GenericContainer.fromCheckpoint(db)
.waitingFor(Wait.forLogMessage(".*ready to accept connections.*"))
restored.start() // migrated schema already on disk, whether this is the first run or the hundredth
The first run takes the ?: run { ... } branch and seeds a fresh checkpoint; every later run —
in the same process or a brand new one — finds it via Checkpoint.find and skips straight to
fromCheckpoint.
Names and refs¶
A name must match ^[a-z0-9][a-z0-9-]{0,40}$ — lowercase letters, digits, and hyphens, starting
with a letter or digit, up to 41 characters. An invalid name throws InvalidCheckpointNameException
before any backend call, the same fail-fast placement every other checkpoint precondition uses.
checkpoint(name)'s ref is derived from the name instead of being random: rightsize/checkpoint:<name>
for docker, rz-ckpt-<name> for microsandbox — the same shapes an unnamed checkpoint() uses,
just with the name in place of the random 12-hex suffix.
Replace semantics¶
Calling checkpoint(name) again for a name that already has a checkpoint replaces it: the old
backend artifact is best-effort removed first (only when that old entry's backend matches the
currently active one; see below), then the new one is captured, and only once that capture
succeeds is the registry entry rewritten to point at it — latest wins. A failed capture (the
backend call itself throws) leaves the previous registry entry untouched; a successful capture
that races a concurrent checkpoint(name) call for the same name has no defined winner beyond
"whichever registry write lands last."
The registry¶
A named checkpoint's metadata lives in one JSON file per name, under the rightsize cache
directory: <cache-dir>/checkpoints/<name>.json, written atomically (tmp file + rename) only
after the backend checkpoint itself succeeds. The shape — pinned identically across the three
rightsize libraries — is:
{
"name": "seeded-db",
"ref": "rightsize/checkpoint:seeded-db",
"backend": "docker",
"createdIso": "2026-07-11T12:00:00Z",
"spec": {
"env": {},
"command": null,
"exposedPorts": [5432],
"memoryLimitMb": null
}
}
Checkpoint.find(name), Checkpoint.list(), and Checkpoint.remove(name) are the only supported
way to read or write this file — treat it as an implementation detail, not a stable format to
hand-edit.
find / list / remove¶
Checkpoint.find(name): Checkpoint?—nullif there's no entry forname. If the entry'sbackendmatches the currently active backend, its artifact is probed (hasCheckpoint): gone means the entry is stale, so it's cleaned up andfindreturnsnulljust like the no-entry case. An entry recorded under a different backend than the active one is returned without probing — restoring it still goes throughfromCheckpoint's ownCheckpointBackendMismatchExceptiongate, sofindnever has to force-resolve a backend the host may not even have. A corrupt registry file is treated the same as no entry, with the bad file best-effort removed.Checkpoint.list(): List<Checkpoint>— every entry currently in the registry, no artifact probing at all. A stale entry (backend-side artifact already gone) can still show up here; onlyfind/removeresolve staleness. A corrupt entry is silently skipped.Checkpoint.remove(name): Boolean— best-effort removes the backend artifact (only when the entry'sbackendmatches the currently active one, same gate asfind) and the registry entry; returnstrueonly if an entry actually existed. Idempotent: calling it again on an already-removed (or never-existing) name is always safe and returnsfalse. Removing (or replacing) a checkpoint under a different active backend than its creator drops the registry record but leaves the artifact behind, and once the record is gone a laterremovefinds nothing to act on — remove a checkpoint under its creating backend in the first place, or use the manual CLI cleanup below.
Cleanup¶
Checkpoints are not auto-reaped — a docker checkpoint is an image, an msb checkpoint is a
snapshot artifact, and neither is a container, so none of Orphan Reaping's
ledger/sweep/watchdog machinery touches either. For a named checkpoint, Checkpoint.remove(name)
is the affordance for that — it works from any process, not just the one that created it. The
manual CLI one-liners below are still valid (useful for an unnamed checkpoint, or when you'd
rather not go through the API at all), just no longer the only way to clean one up: