Freestyle Docs

Freestyle / Guides

How to Run a Minecraft Server in a Sandbox

Build a PaperMC server on a VM, snapshot the running server, then boot ready-to-play copies and publish them on a domain.

Install and start Paper once on a builder VM, snapshot it while the server is running, then every VM you boot from that snapshot comes up with the world loaded and the server already accepting players. A TLS rule publishes it on a domain players type straight into their multiplayer list.

Install the SDK

pnpm add freestyle@latest
bun add freestyle@latest
npm install freestyle@latest
yarn add freestyle@latest

Set your API key before calling the API:

export FREESTYLE_API_KEY="your-api-key"

Create a Builder VM

Build the server on a VM you throw away once it is captured. The default VM is 4 vCPU and 8 GiB of memory, which comfortably runs a server for a few dozen players. The firewall rule is what lets it reach apt and Paper’s download CDN.

import { Freestyle } from "freestyle";

const freestyle = new Freestyle();

const { vm: builder } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  slug: "minecraft-builder",
});

Install Java

Paper moves its minimum Java version with the game — Minecraft 26.1 and newer refuse to start on anything older than Java 25. The Minecraft version you are targeting decides the JRE, so pick the game version first and install what it asks for. OpenJDK 25 is in Ubuntu’s own repositories, so no third-party apt source is needed.

await builder.exec(
  "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq " +
    "openjdk-25-jre-headless curl ca-certificates",
);

const java = await builder.exec("java -version 2>&1");
console.log(java.stdout); // openjdk version "25" ...

-jre-headless is the right package: the server needs no compiler and no GUI libraries.

Download the Server Jar

Paper’s fill API lists every version and the latest build of each. Query it from your own process, then pass the download URL and its checksum into the VM. fill.papermc.io/v3 is the current API — the older v2 endpoints answer 410 Gone.

const version = "26.2"; // the Minecraft version your players will connect with

const build = await fetch(
  `https://fill.papermc.io/v3/projects/paper/versions/${version}/builds/latest`,
).then((r) => r.json());

const { url, checksums } = build.downloads["server:default"];

await builder.exec("mkdir -p /srv/minecraft");
await builder.exec(`curl -fsSL -o /srv/minecraft/paper.jar '${url}'`);

// Verify the download before running it as a long-lived network service.
const verified = await builder.exec(
  `echo '${checksums.sha256}  /srv/minecraft/paper.jar' | sha256sum -c -`,
);
console.log(verified.stdout); // /srv/minecraft/paper.jar: OK

Paper is a drop-in replacement for the vanilla server, substantially more efficient, with the same world format and the same gameplay. You can move a world either direction.

Accept the EULA and Configure the Server

The server refuses to start until Mojang’s EULA is accepted, and that acceptance is yours to give.

await builder.fs.writeTextFile("/srv/minecraft/eula.txt", "eula=true\n");

Write server.properties before the first launch, otherwise the server generates a default one and you are editing and restarting. One setting here is not optional: prevent-proxy-connections=false. Players reach your server through Freestyle’s edge, so the address Mojang’s session server saw is the edge’s, not the player’s. An online-mode server with proxy-connection prevention on compares those two addresses and rejects every login.

await builder.fs.writeTextFile(
  "/srv/minecraft/server.properties",
  `server-port=25565
motd=A Minecraft server on Freestyle
max-players=20

online-mode=true
enforce-secure-profile=true
prevent-proxy-connections=false

difficulty=normal
gamemode=survival
pvp=true
spawn-protection=0
white-list=false

view-distance=10
simulation-distance=8
network-compression-threshold=256
sync-chunk-writes=false

enable-rcon=false
`,
);

view-distance and simulation-distance are the two biggest performance levers; simulation distance is the expensive one, since it decides how far entities and redstone actually tick. spawn-protection=0 lets non-ops build near spawn. RCON stays off: Minecraft binds RCON to every interface and has no setting to restrict it, so enabling it means an admin port on the public Internet guarded by a plaintext password in a config file. The console file below gives you the same control with nothing listening.

Paper’s watchdog kills the server when the main thread has not ticked for a while, on the assumption that it has deadlocked. A paused VM stops ticking by definition, so on resume the watchdog sees the entire gap at once and kills a perfectly healthy server. Turn it off before the first start — Paper fills in the rest of spigot.yml from its own defaults.

await builder.fs.writeTextFile(
  "/srv/minecraft/spigot.yml",
  `settings:
  timeout-time: -1
`,
);

Run It Under systemd

systemd gives the server boot persistence, crash recovery, and log capture. The unit also solves the console problem: a Minecraft server reads admin commands from stdin, and a service has no terminal attached. tail -f on a regular file never reaches EOF, so redirecting the server’s stdin from one keeps it open forever, and appending a line to that file delivers a command.

await builder.fs.writeTextFile(
  "/etc/systemd/system/minecraft.service",
  `[Unit]
Description=Minecraft Server (Paper)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/srv/minecraft
ExecStart=/bin/bash -c 'exec /usr/bin/java \\
  -Xms2G -Xmx4G \\
  -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \\
  -XX:+UnlockExperimentalVMOptions \\
  -XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 \\
  -XX:G1HeapRegionSize=8M -XX:G1ReservePercent=20 \\
  -XX:InitiatingHeapOccupancyPercent=15 \\
  -Dfile.encoding=UTF-8 \\
  -jar /srv/minecraft/paper.jar --nogui \\
  < <(tail -n0 -f /srv/minecraft/console.in)'
ExecStop=/bin/bash -c 'echo stop >> /srv/minecraft/console.in'
Restart=on-failure
RestartSec=15
TimeoutStopSec=120
SuccessExitStatus=0 143

[Install]
WantedBy=multi-user.target
`,
);

await builder.exec("touch /srv/minecraft/console.in");
await builder.exec("systemctl daemon-reload && systemctl enable --now minecraft");

Three details in that unit are load-bearing:

  • ExecStart runs under /bin/bash. /bin/sh has no process substitution, so < <(tail -n0 -f …) is a syntax error there.
  • ExecStop sends stop through the same file, so the world saves properly instead of being killed mid-write. TimeoutStopSec=120 gives it time, and SuccessExitStatus=0 143 treats SIGTERM as a normal exit.
  • -XX:+UnlockExperimentalVMOptions comes before the G1* flags. Several of them are gated as experimental and the JVM refuses to start otherwise, with Error: The unlock option must precede 'G1NewSizePercent'.

The heap is 4 GiB of the VM’s 8 GiB, leaving room for the OS, the page cache, and the JVM’s own off-heap memory. If you install more than one JVM, replace /usr/bin/java with the full versioned path — /usr/bin/java follows update-alternatives and can point at the wrong one.

Wait Until It Is Accepting Players

The first start generates the world, which takes a minute or two. Poll the journal for the line Paper prints once it is listening, so you know the server is live before you capture it.

let ready = false;
for (let i = 0; i < 300; i++) {
  const probe = await builder.exec(
    `journalctl -u minecraft --no-pager | grep -q 'For help, type' && echo ready || true`,
  );
  if (probe.stdout.trim() === "ready") {
    ready = true;
    break;
  }
  await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (!ready) throw new Error("the server never finished starting");

const listening = await builder.exec("ss -tln | grep 25565");
console.log(listening.stdout); // LISTEN 0 ... *:25565 ...

Snapshot the Running Server

A Freestyle snapshot is a full memory and disk capture, so it preserves the running JVM — the loaded world, the warm chunk cache, all of it — not just the installed files. Snapshot while the server is up and every VM booted from it is a server already accepting players, with no start-up to sit through.

Flush the world to disk first so the snapshot’s filesystem is consistent too, then capture it and delete the builder.

await builder.exec("echo save-all >> /srv/minecraft/console.in");
await new Promise((resolve) => setTimeout(resolve, 5000));

const { snapshotId } = await builder.snapshot({ slug: "minecraft-paper" });
await builder.delete();

The slug is a handle you can boot from in place of the id, so later code says snapshotId: "minecraft-paper" without carrying an id around. Everything above — the apt install, the download, the world generation — happens exactly once. From here on, starting a server is one API call.

Boot a Server From the Snapshot

const { vm, vmId } = await freestyle.vms.create({
  // The server calls Mojang's session servers to authenticate joining players.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  slug: "minecraft",
  snapshotId,
  idleTimeoutSeconds: 900,
});

const listening = await vm.exec("ss -tln | grep 25565");
console.log(listening.stdout); // already LISTEN — nothing was started

The public egress rule is not only for the build. With online-mode=true the server checks every joining player against Mojang’s session servers, so a server with no outbound access lets nobody in.

idleTimeoutSeconds: 900 pauses the VM after fifteen minutes with no network traffic, so an empty server stops drawing compute; the next section covers how it wakes. Pass null instead to keep it running whether or not anyone is on it.

Every VM from this snapshot starts from the same world. To give each one its own world, delete /srv/minecraft/world* and restart the service on the new VM — or build a snapshot per world and boot the one you want.

Publish It on a Domain

A TLS rule with protocol: "minecraft" serves the VM from the edge’s Minecraft front on port 25565 instead of over HTTPS.

const domain = "my-server.style.dev"; // any unused style.dev subdomain

await freestyle.tls.rules.create({
  action: "allow",
  domain,
  protocol: "minecraft",
  source: { public: true },
  destination: { vmId, port: 25565 },
});

Any unused subdomain of style.dev is yours to take, with no verification and no DNS records. To use a name you own, verify it and point it at beta-web.freestyle.sh with a CNAME, exactly as for a web domain. A Minecraft rule terminates nothing and needs no certificate, so a name that only serves Minecraft does not need the NS delegation that a certificate challenge would.

You write no firewall rule to publish this. The edge reaches the VM over a standing grant every VM already has, and a TLS rule mints no firewall rule of its own.

The edge reads the server address out of the client’s handshake — the routing key, the way Host is over HTTP — and splices the session through to your VM.

Connect

In the Minecraft launcher — Java Edition, on the same version the server runs — open Multiplayer, Add Server, and enter the domain with no port. 25565 is the default, so my-server.style.dev is the whole address. Bedrock Edition clients cannot connect: it is a different protocol over UDP.

The row in the multiplayer list is a status ping, and a status ping never starts a VM — clients ping that list on a loop, and waking on it would bill runtime for an open menu. While the VM is running the row shows the server’s own MOTD, player count, and icon. While it is paused the row reads Sleeping — join to start the server.

Joining is the wake. Click Join Server and the VM resumes, straight back into the JVM the snapshot captured. A resume is fast enough that the first join normally connects. If the server is coming back from a full stop rather than a pause, the client says The server is starting but is not accepting players yet — try again in a moment, and a second attempt connects.

Run Server Commands

Anything appended to the console file lands on the server’s stdin, so the whole admin console is reachable through vm.exec with nothing extra listening.

async function send(command: string) {
  await vm.exec(`echo ${JSON.stringify(command)} >> /srv/minecraft/console.in`);
}

await send("op Notch");
await send("whitelist add Notch");
await send("say the server is up");
await send("save-all");

The server answers in its log, not on stdout, so read replies out of the journal:

const log = await vm.exec("journalctl -u minecraft --no-pager -n 20 -o cat");
console.log(log.stdout);

systemctl restart minecraft stops the server through the same channel and brings it back, which is what you want after editing server.properties.

Stream the Server’s Logs

vm.exec() buffers a command and only returns once it finishes, so it can’t show a long-running service’s output as it happens. To watch the console live, open a PTY on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit’s journal. onData delivers the bytes as they arrive:

const session = await vm.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes), // live console lines
});

// Follow the service; join and chat messages stream in until you detach.
session.write("journalctl -u minecraft -f -o cat\n");

// session.detach() drops your handle — the server keeps running in the VM.

Update Paper

Updating is the same loop as building: boot a VM from the snapshot, swap the jar, and capture a new snapshot. Back up the world before a version bump — world format changes are one-way.

const { vm: upgrade } = await freestyle.vms.create({
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  slug: "minecraft-upgrade",
  snapshotId: "minecraft-paper",
});

const next = await fetch(
  "https://fill.papermc.io/v3/projects/paper/versions/26.2/builds/latest",
).then((r) => r.json());

await upgrade.exec("systemctl stop minecraft");
await upgrade.exec(
  `curl -fsSL -o /srv/minecraft/paper.jar '${next.downloads["server:default"].url}'`,
);
await upgrade.exec("systemctl start minecraft");
// Poll for 'For help, type' again, exactly as on the first build.

A snapshot slug is unique across your account, so the new capture takes its own name. Move the stable handle over once you are happy with it, and old servers keep booting from the id you already have.

const { snapshotId: updated } = await upgrade.snapshot({ slug: `minecraft-paper-${next.id}` });
await upgrade.delete();

// Point `minecraft-paper` at the new snapshot.
await freestyle.vms.snapshots.update(snapshotId, { slug: "" });
await freestyle.vms.snapshots.update(updated, { slug: "minecraft-paper" });

Running servers are untouched by any of this — a VM booted from a snapshot has no ongoing link to it. Roll a server onto the new build by creating a VM from the updated snapshot and moving the TLS rule’s destination to it.

esc