A Save is a copy of your server’s world at a moment in time, kept in your account. You create one from the dashboard or on a schedule, name it, and load it onto a server whenever you want.
Behind that one button sits most of our storage stack. First, what Saves are for and how they differ from backups. Then the path a single Save takes through the system, from the click to the object in Cloudflare R2, coordinated by a workflow on Inngest, a platform for durable, event-driven functions that is the real engine behind the feature.
Saves and backups
Most hosts give you backups: automatic, scheduled copies that sit in the background as a safety net. If a plugin update corrupts your world overnight, you roll back to last night’s copy. Backups answer one question well, which is how to recover after something breaks.
Saves answer a different one: where does a world live? On most hosts a world is bound to the server it runs on, so the two share a fate. A Save breaks that bond. It snapshots the world into a library in your account, where it sits on its own, ready to load onto a server whenever you reach for it.
Once a world lives in your account rather than on one server, a few things follow:
- Keep every modpack you play. Finish with one modpack, save its world, and load a different modpack onto the same server. The first world waits in your library, and you swap back to it whenever you like. Each modpack keeps its own world.
- Switch a server to another game. Point a server at a new game, and its current world is saved first, so the old one stays one click away.
- Cancel a server and keep the world. Save it, cancel the server, and the world stays in your account. When you come back, buy a new server and attach the Save to it. The world outlives the server it came from.
- Move a world between servers. Because Saves live in your account, you load one onto any server you own.
Both Saves and scheduled backups ride the same pipeline, the one below. The difference is where they live and who drives them.
| Backups | Saves | |
|---|---|---|
| Started by | an automatic schedule | you, from the Saves panel |
| Where it lives | attached to one server | your account library |
| Kept for | a retention window | until you remove it |
| Reach for it to | recover a broken world | swap modpacks, switch games, or carry a world to a new server |
What a Save captures
A game server has two parts: the process that runs it and the data it writes. The process comes from an image we rebuild at any time, so a Save focuses on the data directory, which holds your world, server.properties, plugin and mod files, and player data.
A Save also records the game and version it pairs with. That detail is what makes swapping clean: loading a Save rebuilds the matching runtime and drops the saved files into it, so a vanilla world comes back even while the server currently runs a modpack.
A server always has one active Save, the world it runs right now. The Saves panel shows it at the top, and every other Save in your library is a world parked and ready to take its place. For a busy Minecraft world the data is usually a few gigabytes, and a large modpack with an explored map can reach tens of gigabytes. The pipeline handles both the same way.
The path from click to storage
Here is the whole journey at a glance. A trigger emits an event, an Inngest workflow coordinates the steps, oxide snapshots your data on its node, a Hetzner worker packs it with restic, and the object lands in R2 with a manifest saved to Postgres. Live progress streams back to your browser the entire time.
Inngest is the coordinator
A Save touches a lot of machines: the dashboard that requests it, the Inngest service that runs the workflow, the oxide agent on your server’s node, a Hetzner worker, Postgres for the manifest, and R2 for the bytes. It also runs anywhere from a few seconds to a few minutes. Coordinating that reliably is the hard part, and it is where Inngest earns its place.
Inngest lets us write the Save as an ordinary function and supplies retries, persisted steps, scheduling, and concurrency on top. Functions trigger on events, and each unit of work inside a function is a step. Every step result is persisted. When a run retries, whether after a transient error or a full process restart, the finished steps hand back their stored values and execution resumes at the step that stalled. A Save that made it through the snapshot and died during upload picks up at the upload, with the snapshot already in hand.
Here is the real shape of the create function:
export const createSave = inngest.createFunction(
{
id: "create-save",
// Give each step a few attempts before the run gives up.
retries: 4,
// One Save per node at a time, so a burst of scheduled Saves
// paces itself and the box stays responsive.
concurrency: { key: "event.data.nodeId", limit: 1 },
// A double click on "Save now" collapses into a single run.
idempotency: "event.data.serverId + '-' + event.data.requestedAt",
},
{ event: "save/requested" },
async ({ event, step }) => {
const { serverId } = event.data;
// Each step is a durable checkpoint; its return value is stored.
const snapshot = await step.run("snapshot", () =>
oxide.snapshot(serverId),
);
const stored = await step.run("store", () =>
worker.backup(snapshot, { repo: "r2:saves" }),
);
const save = await step.run("record", () =>
db.saves.insert({ serverId, ...stored }),
);
// Hand a completion event to anything that cares: the UI, email, audit log.
await step.sendEvent("announce", {
name: "save/completed",
data: { serverId, saveId: save.id },
});
return save;
},
); A few things in that config carry most of the weight:
- Per-step retries. The
storestep talks to a worker and to R2 over the network, where the occasional timeout is routine.retries: 4gives each step its own budget of attempts with backoff, so a blip retries in place while the whole Save keeps going. - Concurrency by node. The
concurrencykey holds each box to one Save at a time. When a schedule fires Saves for fifty servers that share a node at midnight, they queue and run in order, and the node serves games at full speed while they do. - Idempotency. The key folds duplicate triggers into one run, so an impatient double click, or a retry from the dashboard, still yields a single Save.
- Steps as a ledger. Because every step is recorded, a Save is fully traceable. When one fails, we see the exact step and its error, and we replay from there.
The same primitives power the rest of the storage features, which is the quiet payoff. Restores run as their own workflow that pulls from R2 and hands off to oxide. Scheduled Saves are this create function on an Inngest cron. A version switch runs a small workflow that takes a safety Save first, waits for it to finish with step.waitForEvent, then performs the swap. One engine, a handful of workflows, every one of them durable in the same way.
Snapshot and handoff
When the workflow reaches the snapshot step, oxide (the agent on your server’s node) captures a consistent copy of the data directory. It coordinates with the running server so files that are mid-write land in the copy whole, which matters most for the world file a busy server touches constantly. The result is a point-in-time image of the data that is safe to move.
From there the heavy work runs on a backup worker, a dedicated Hetzner VM built for this one job. Chunking, compression, and encryption all cost CPU and disk, so running them on a separate machine leaves your server’s node free for games. The workers hold state only while a job is in flight, so we scale them out as Save volume grows, and the durable data lives in R2 the whole time.
Dedup, encryption, and R2
The worker runs restic, an open source backup tool that gives us three properties:
- Deduplication. restic splits data into content-defined chunks and stores each unique chunk once. Two Saves of the same world share almost every chunk, so the second Save uploads only the difference.
- Compression. Chunks are compressed with zstd before upload.
- Encryption. Everything is encrypted on the worker, so the objects that reach R2 stay opaque.
Dedup is also what keeps the Saves workflows affordable. Ten Saves of one world, or a pause-and-resume every weekend, store one full copy plus a series of small deltas. The savings appear on the first repeat:
files: 24,817
logical size: 5.418 GiB
added to repo: 143.2 MiB (only the changed chunks were uploaded)
snapshot a1b2c3d4 saved in 6.108s A 5.4 GiB world, and around 143 MiB actually uploaded. That ratio is why deep histories and frequent Saves stay cheap to keep.
Watching it live
While a Save runs, oxide streams progress over the same WebSocket that powers your console and stats, so the dashboard shows the current phase (snapshot, upload, finalize) and a live percentage.
When the workflow finishes, the record step writes a manifest row to Postgres. That row is what the dashboard reads to list your Saves, and what a restore reads to find the right snapshot and runtime:
{
"save_id": "save_3xZ9k2",
"server_id": "srv_9aFk2",
"snapshot": "a1b2c3d4",
"game": "minecraft:1.21",
"created": "2026-06-29T14:08:11Z",
"logical_bytes": 5817342976,
"stored_bytes": 150216704,
"files": 24817
}
logical_bytes is the full size of your data, stored_bytes is what this Save added to R2 after dedup, and game is the runtime a restore pairs the files with.
Restore, swap, and switch
Every action in the Saves panel is the save or restore pipeline aimed at a different target:
- Create a save snapshots the current world into your library. That is the pipeline from the top of this post.
- Swap into a save commits the active world, then loads a different Save from your library onto the server.
- Switch game commits the active world, then brings the server up on a new game, with the old world waiting in the library at its matching version.
- Start fresh commits the active world, then wipes the server to a clean slate.
- Attach to a new server takes a Save from your account and loads it onto a server you just bought, which is how a world survives a cancellation.
Notice the shared habit: every action that changes the live world commits the current one first, so a swap or a wipe always leaves a Save behind.
A load runs the pipeline in reverse, with one goal on top: keep your server up for most of it.
- You pick a Save.
- A worker pulls that snapshot from R2 and rebuilds the files in a scratch area, verifying chunk checksums as it goes. Your server keeps running the whole time.
- Once the rebuilt copy is complete and verified, oxide does the quick part: stop the server, swap in the restored data and its matching runtime, start it again.
The multi-gigabyte transfer happens on the worker, off to the side, so the pause your players feel is the final stop, swap, and start, which lasts a few seconds.
Retention
Scheduled backups age out on a retention window. Once a copy passes the window, restic prunes the chunks that belong to it alone, and chunks shared with newer copies stay put. Saves in your library follow your plan’s slot count instead, and stay until you remove them. A Save outlives the server it came from, so canceling a server keeps its worlds safe in your account for the next one. Either way, pruning frees exactly the unique data and leaves the rest whole.
All of it is the same pipeline whether a person or a cron pressed the button. Create a Save, swap between worlds, switch games, and carry any of them to a new server whenever you like.