all posts

Per-Match Game Server Isolation on MicroVMs

Ajay Kumar··9 min read

Dedicated game servers all share a shape: a match (or a lobby, or a world) is a bounded session that a handful of players connect into, the server simulates the game state, and it broadcasts that state back to everyone tick by tick. Whether it's a Minecraft-style persistent world, an Agar.io-style browser arena, or a session-based competitive match, the operational reality is the same — matches are bursty and disposable, a few are live right now, most slots are empty, and any lobby can fill the instant someone hits "play." This post is about giving each match its own Firecracker microVM — one isolated VM per lobby or world — and why that maps onto game hosting almost too cleanly.

I'm Ajay, I built PandaStack. The pattern is: spin up a sandbox per match running the game server, snapshot a pre-loaded "warm" world so joining is instant, and tear it down when the lobby empties. Each sandbox is its own Firecracker microVM with its own guest kernel, so one match's crashed server or exploited plugin can't reach into another's — and the snapshot-restore create path (create ~49ms, p50 179ms) makes per-match VMs cheap enough to actually run this way.

A modded server is a remote code execution endpoint with a leaderboard

The moment your game supports mods, plugins, or user-uploaded content, you are running other people's code. Bukkit and Spigot plugins are arbitrary JVM bytecode. Lua mods are arbitrary Lua with whatever host bindings you exposed. A user-uploaded map or datapack can carry command blocks and scripts that execute when a player walks over a pressure plate. This isn't a hypothetical threat surface — it's the product feature. A modded server is, quite literally, a remote code execution endpoint that happens to also keep score.

That code is untrusted by definition, and it runs live while paying players are connected. In a shared-host setup where one process (or one container on a shared kernel) runs many worlds, a single malicious or merely incompetent plugin is a denial of service against every world on that box. Someone uploads a datapack with a `while true` loop, or a plugin that allocates until the JVM OOMs, or a Lua mod that opens a socket and starts mining — and now everyone else's match stutters, rubber-bands, or drops. The griefer running an infinite loop in their own lobby shouldn't be able to lag out the lobby next door.

The mental model: one microVM per match/lobby/world, not one server hosting all of them. The isolation boundary is the VM, so it's only meaningful if a VM maps to a single trust domain — one match, one set of players, one bundle of mods that group agreed to run together.

Why the match is the right isolation boundary

The match is the natural unit of blast radius. Everyone in a match already shares the same world state, the same physics, the same mod set — they've opted into that shared reality. Nobody outside the match should be affected by anything inside it. That's exactly the boundary you want a hypervisor to enforce. A match-per-microVM design makes the match boundary a hardware boundary: the game server process, the loaded world, the plugin JARs, the Lua VM, and any code they execute all live inside one Firecracker guest with its own kernel, memory, and filesystem.

A match that crashes, wedges, gets memory-bombed, or gets outright exploited takes down exactly one match — its own. The players in that lobby see a hiccup and reconnect; every other match on the host never notices. This is the same property AWS Lambda leans on to run untrusted functions from thousands of customers on shared fleets, applied with the match as the tenant. And crucially, a container won't give you this: namespaces and cgroups share the host kernel, so a kernel-level exploit in one match's mod reaches the neighbors. For genuinely untrusted plugin code, you want the guest kernel per match that a microVM provides.

Spin up a fresh match server on demand

Match hosting has a distinctive lifecycle: a lobby is created when it starts filling, active while the match runs, and dead the moment the last player leaves. Keeping thousands of idle game-server processes warm "just in case" is how hosting bills balloon. The alternative — cold-booting a fresh server from scratch every time a lobby fills — is too slow; nobody wants to stare at a matchmaking spinner while a JVM warms up. The middle path is snapshot restore: bake the server once, then restore it on demand.

The create/restore fast path is p50 179ms (p99 around 203ms), with the snapshot-restore step itself around 49ms, because every restore pages a baked memory image back in rather than cold-booting (which is roughly 3s, and only happens the very first time a template has no snapshot yet). Players don't wait: by the time the matchmaker has finished picking who's in the lobby, the server is already up and accepting connections.

from pandastack import Sandbox

def start_match(match_id: str, mod_bundle: bytes) -> dict:
    """Spin up a dedicated microVM for one match, load its mods, launch the server."""
    # One VM per match. Persistent so it lives past a single request;
    # ttl_seconds is a backstop in case a lobby is abandoned mid-match.
    sbx = Sandbox.create(
        template="game-server",
        persistent=True,
        ttl_seconds=14_400,
        metadata={"match_id": match_id},
    )

    # Ship this match's (untrusted!) mod/plugin bundle into the guest.
    sbx.filesystem.write("/srv/plugins/bundle.zip", mod_bundle)

    # Launch the game server detached, listening on the game port.
    sbx.exec(
        "setsid /srv/run-server.sh --plugins /srv/plugins/bundle.zip "
        "> /var/log/gameserver.log 2>&1 < /dev/null &",
        timeout_seconds=20,
    )

    # Wait for the server to bind, then expose the port to players.
    sbx.exec("until nc -z localhost 25565; do sleep 0.2; done", timeout_seconds=30)

    # A sandbox port is reachable at https://<port>-<id>.<suffix> for its
    # lifetime -- hand this connect target to the matched players.
    return {"match_id": match_id, "sandbox_id": sbx.id, "connect": sbx.host_for(25565)}


def end_match(sbx: Sandbox) -> None:
    # Last player left -> tear the VM down. No idle process, no idle bill.
    sbx.kill()

The economics are the point. An empty lobby costs nothing because there's no VM — you created it when players arrived and killed it when they left. A dormant persistent world costs snapshot storage rather than resident RAM. So a long tail of thousands of finished matches and empty lobbies doesn't pin memory the way thousands of idle server processes would. Scale-to-zero isn't a feature you bolt on; it's the default shape when a match maps to a VM you create and destroy with the session.

Snapshot a warm world; fork to clone match state

Cold-starting a game server isn't just booting a kernel — it's loading the world, generating or reading chunks, initializing the plugin runtime, and running the JVM's warmup. That's the slow part, and it happens after the VM is up. The fix is to do it once, ahead of time, and snapshot the result. Boot a server, let it fully load your base world and plugins into memory, then snapshot it. Now every new match restores from that warm image: the world is already generated, the plugins are already loaded, and the first player joins into a running world instead of waiting on chunk generation.

Forking takes this further when many matches start from a common seed. If every ranked match begins from the same pristine map, or every co-op session starts from the same save file, fork a pre-warmed base instead of restoring each independently. A same-host fork is 400–750ms and shares the parent's memory copy-on-write, so a hundred matches branched off one warm world share pages until they diverge — each match mutating only the chunks its players actually touch. Cross-host forks (1.2–3.5s) let you spread matches across the fleet when one host fills up.

from pandastack import Sandbox

# ---- Bake once: a warm base world, fully loaded, frozen as a snapshot. ----
base = Sandbox.create(template="game-server", persistent=True)
base.filesystem.write("/srv/worlds/ranked-map/level.dat", RANKED_MAP)
base.exec("setsid /srv/run-server.sh --world ranked-map > /var/log/gs.log 2>&1 &",
          timeout_seconds=20)
base.exec("until nc -z localhost 25565; do sleep 0.2; done", timeout_seconds=60)
base.exec("/srv/warmup.sh", timeout_seconds=60)   # pre-generate spawn chunks
snapshot = base.snapshot()                        # world + plugins now in the image
base.hibernate()                                  # park the base, free its RAM


def new_ranked_match(match_id: str) -> Sandbox:
    """Fork the warm world -> instant join, no chunk-gen wait, isolated per match."""
    # Same-host fork (~400-750ms) shares the base world copy-on-write; this
    # match only pays for the chunks its own players change.
    child = snapshot.fork(metadata={"match_id": match_id})
    return child
A snapshot freezes wall-clock time inside the guest along with everything else — a world restored hours later resumes with its clock at snapshot time until it re-syncs. If your server timestamps saves, validates TLS to a licensing or anti-cheat backend, or expires sessions off the guest clock, sync time on wake before trusting it. Treat the restored world state as authoritative; treat the guest's sense of "now" as stale until corrected.

Per-match egress and resource limits

Isolation isn't only about crashes — it's about what a compromised match can reach. Because each match is its own guest with its own network namespace, you can put per-match limits at the boundary rather than trusting the mod code to behave. Cap CPU and memory so one match's runaway plugin can't starve the host. Restrict outbound network so a plugin that decides to become a crypto miner, a spam relay, or a DDoS source can't phone home — the egress policy lives at the VM's network layer, outside the reach of the untrusted code running inside it.

This matters specifically because game-server mods are a well-known abuse vector. "Free Minecraft hosting" and "free game server" tiers get hammered by people uploading plugins whose real job is to mine or send traffic, not to play. When the match is a VM you control the network egress on, that abuse is contained to the one lobby that authored it, and you can kill it without touching a single legitimate match. On PandaStack each agent pre-allocates 16,384 /30 subnets, so per-match networking isolation isn't the scaling ceiling — memory and CPU are the binding constraint, which is exactly why tearing down empty lobbies is load-bearing rather than a nicety.

Shared host vs container-per-match vs microVM-per-match

There are three common ways to host dedicated game servers. They trade off density, isolation, and blast radius differently:

  • One shared host process, many worlds — Density: highest (worlds are threads or entries in one server). Isolation: none; one wedged world, leaked JVM heap, or malicious plugin degrades every match on the box, and untrusted mod code runs in your address space. Fine only if you 100% control every mod, which defeats the point of supporting mods.
  • Container per match — Density: high; startup: fast. Isolation: namespaces + cgroups on a shared host kernel, so a kernel bug or container escape in one match's untrusted plugin reaches the neighbors. Acceptable for your own vetted server binary, risky for user-supplied Bukkit/Spigot/Lua code.
  • MicroVM per match — Density: lower per host, but empty lobbies cost nothing (killed) and dormant worlds cost snapshots (hibernated), not RAM. Isolation: a hardware-virtualized guest kernel per match, so a crashed, memory-bombed, or exploited match is contained to itself; startup is a snapshot restore (create ~49ms, p50 179ms), not a cold boot; per-match egress limits live at the VM boundary.

The right choice depends on trust. If every match runs only your own signed server binary with no user-supplied mods, plugins, or scripted maps, a shared process or container-per-match is simpler and denser — don't reach for a VM per match to run code you wrote and trust. The calculus flips the instant matches load untrusted content, which for most moddable games is the whole business model.

When a VM per match is the wrong call

Per-match microVMs are not free, and they're not always the answer. If your matches are tiny, ultra-high-churn, and run only trusted first-party logic — think a purely server-authoritative arena shooter with no mods, no user scripts, and sessions that last ninety seconds — the per-VM overhead and create latency may outweigh isolation you don't actually need; a sharded process is leaner. The design earns its keep when matches load untrusted mods or plugins, run user-uploaded scripted content, are meaningfully stateful with a long idle tail (persistent worlds), or need hard per-match resource and egress caps.

One honest caveat on alternatives: managed game backends and orchestrators like Agones, Nakama, PlayFab, and the various "game server hosting" clouds each give you a per-session server abstraction with their own isolation and pricing models — verify their guarantees against their own docs rather than assuming they match a per-VM boundary. The distinguishing property of the microVM approach is the hardware isolation and the ability to run genuinely untrusted per-match mod code safely, plus scale-to-zero economics where an empty lobby costs nothing at all.

Frequently asked questions

Why give each match or lobby its own microVM instead of one shared game server?

The match is the natural blast-radius boundary — everyone in it already shares the world and mod set, and nobody outside it should be affected. In a shared host process, one match's crashed server, leaked JVM heap, or malicious plugin degrades every match on the box, and untrusted mod code runs in your address space. A microVM per match makes that boundary a hardware boundary: each match's server, world, and plugin code live in a Firecracker guest with its own kernel, so a match that crashes, OOMs, or gets exploited takes down only itself.

How do players avoid waiting while a match server spins up?

Every create restores a baked snapshot rather than cold-booting, so the create fast path is p50 179ms (p99 ~203ms), with the restore step itself around 49ms. A cold boot (~3s) only happens the first time a template has no snapshot. Bake a warm world — boot a server once, let it fully load the map and plugins, and snapshot it — so new matches restore into an already-loaded world and the first player joins instantly instead of waiting on chunk generation or JVM warmup.

Can I run untrusted mods, plugins, and user-uploaded maps safely?

Yes — that's the strongest reason for a VM per match. Bukkit/Spigot plugins, Lua mods, and scripted maps are user-authored and therefore untrusted, and they run live while players are connected. With a microVM per match, that code executes inside the match's own guest kernel: a while-true loop, a fork bomb, or a memory bomb is contained to the one lobby whose players loaded it. You can also cap CPU/memory and restrict outbound egress at the VM boundary, so a plugin that tries to mine or send traffic can't reach past its own match.

How do I avoid paying for empty lobbies and finished matches?

Because a match maps to a VM you create and destroy with the session, an empty lobby costs nothing — there's no VM until players arrive, and you kill it when the last one leaves. For persistent worlds you want to keep, hibernate them: that snapshots memory and disk and stops the VM, so a dormant world costs snapshot storage rather than resident RAM. The next join auto-wakes it via snapshot restore. A long tail of finished matches and idle worlds therefore doesn't pin host memory the way idle processes would.

How do I clone match state or start many matches from one map?

Fork a pre-warmed base. Boot one server with your base map and plugins fully loaded, snapshot it, then fork that snapshot per match. A same-host fork is 400–750ms and shares the parent's memory copy-on-write, so a hundred matches branched off one warm world share pages until they diverge — each match only pays for the chunks its own players change. Cross-host forks (1.2–3.5s) spread matches across the fleet when one host fills up.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.