# Hosting a Horizon server: design Horizon's dedicated server should be the easiest and lightest Minecraft 26.3 server to host. It should be one small native program with no Java and no Python, one command to install it, one config file, and a CLI, a web panel and a control API to manage it. At the same view and simulation distance and with the same players, it should beat vanilla, Paper and Fabric + Lithium on TPS/MSPT, RAM, CPU, chunk generation and start time. Gameplay stays vanilla: there are no Paper-style rule changes unless the owner turns one on. This page covers what the server does today (measured), the design of `horizon-server`, and what ships for the first working version (2026-10-23). The owner's guide is docs/hosting.md, also at https://playhorizon.dev/server. ## 1. Today ### How it runs The dedicated server is the game itself, run headless: `horizon-client --resources --dedicated [--nogui] [--port N] [--world NAME] [--universe DIR] [--initSettings]`, started in the server folder (`crates/horizon-client/src/game/dedicated.rs`). - **Startup.** It follows vanilla's `Main.main` and `DedicatedServer.initServer`. It reads `server.properties` and writes it straight back with vanilla's defaults filled in (`horizon_client::server`, byte-exact with vanilla's `Properties.store`), and refuses to start until `eula.txt` says `eula=true`. It then loads the operator, whitelist and ban lists in vanilla's order and format, and opens `/` with the same `Game` as single player, minus the local player. - **Network.** Players join through the play-state hub that Open to LAN uses (`crates/horizon-client/src/lan`). Status ping, legacy ping, compression, login gate (bans, whitelist, IP bans), operator permission levels, the player limit with `bypassesPlayerLimit`, MOTD and `server-icon.png` all follow `server.properties`. - **Main loop.** It ticks at 20 TPS on one thread, with chunk generation and lighting on the worker pool. Chunks stay loaded around every player (`lan_update_streaming`), or around the spawn while nobody is online. It supports `pause-when-empty-seconds`, `player-idle-timeout` and the autosave. - **Console (stdin).** `stop`, `save-all`, `save-on`/`save-off`, `list [uuids]`, `op`/`deop`, `whitelist on|off|list|reload|add|remove`, `ban`, `ban-ip`, `pardon`, `pardon-ip`, `banlist`, `kick` and `say` behave as in `DedicatedServer`. Every other command goes to the game's own dispatcher as the server (permission level 4). The log follows vanilla's Log4j pattern, in `logs/latest.log` and on stdout. - **Player names.** `usercache.json` in the universe folder works as vanilla's `CachedUserNameToIdResolver`. Players are added when they join and kept for a month, and the file keeps the 1000 most recently used, newest first. The console finds a player by name in it, in any case. A name it doesn't hold is asked of the profile service (`getByName`). If the service doesn't know the name, an offline server uses the offline player of the lower-case name; an online server answers "That player does not exist". `tools/test_user_cache_parity.py` checks this against vanilla, with a mock profile service. ### Measured (2026-10-16, Mac mini M4 Pro, main at bd8dff20, release build) The server ran in a scratch folder with `online-mode=false`, `level-seed=12345` and view and simulation distance 10. The vanilla server was the pinned 26.3 `server.jar` on Java 25 with `-Xmx4G`. | | Horizon (`horizon-client --dedicated`) | Vanilla 26.3 | |---|---|---| | Start, new world (wall clock, to `Done`) | 11.4 s (`Done (9.762s)`) | 12.5 s (`Done (2.066s)`, includes the bundler's first unpack) | | Start, existing world (wall clock) | **5.8 s** (resources load in 1.6 s) | 10.7 s (`Done (0.446s)`: the JVM and DataFixerUpper take the rest) | | RAM, idle with nobody online (RSS) | 1.40–1.51 GB | **0.93–0.97 GB** | | CPU, idle with nobody online | 1.2 % of one core | **0.6 %** | | `stop` to exit | 0.6 s | 0.6 s | | What has to be installed | one 46.8 MB binary + 589 MB of prepared resources | Java 25 + the 62 MB jar | | Crates linked | 384 (wgpu, winit, cpal, gilrs, arboard, naga…) | n/a | On Linux the full game loads `libasound.so.2` (cpal) and `libudev.so.1` (gilrs) at startup. wgpu, winit and the Wayland/X11 libraries are loaded only when a window opens. The server build (`horizon-engine`, section 3) is the same program built without its `audio` and `controllers` features, so it needs neither library, and a minimal server image runs it as it is. The two clear losses are RAM and idle CPU. The dedicated server loads everything the client needs to draw (the HUD atlas, item renderer, block-entity models, panorama, fog and music tables, entity models), and it builds the meshing tables at startup. None of these are used without a window. Loading them is startup work, not tick cost, so it is in scope here (see section 9). ### Joining it - **Horizon client:** works. A headless Horizon client (`--server 127.0.0.1:PORT`, the harness mode) logged in, received its 49 chunks at render distance 2, chatted, saw `/list`, and left. The server's log matched vanilla's lines (`logged in with entity id`, `joined the game`, `[Not Secure] …`, `lost connection: Disconnected`, `left the game`). - **Vanilla 26.3 client:** checked by `tools/test_dedicated_server_parity.py`. VanillaProtocolClient, a headless client built from vanilla's own codecs, pings and joins Horizon's server and vanilla's from the same world copy. It compares every login, configuration and play packet up to the first chunk batch, the chunk bytes, chat, `/list`, failing commands, the console lines and the files left behind. Result on main: see "Harnesses on main" below. - **The real vanilla 26.3 client** (`tools/vanilla_client.py`, from the owner's own files, dev-only) joins too. On 2026-10-18 it joined the server build of the game (`horizon-engine`, section 3) on the Mac mini: `python3 tools/vanilla_client.py --name Vanilla --join localhost:PORT`. It logged in, drew the terrain around spawn and stayed connected for four minutes, with no warning or error on the server. The tool runs on Linux under vscreen and on macOS from the logged-in screen session, even a locked one, with vsync off. ### What fails or is missing | Area | Status | |---|---| | `online-mode=true` | **Works** (ServerLoginPacketListenerImpl): a 1024-bit RSA key pair at start ("Generating keypair"), the encryption request, the shared secret and challenge, AES/CFB8, and the session service's `hasJoined` check (with the address when `prevent-proxy-connections` is on), with vanilla's log lines and messages. `test_online_mode_parity.py` runs the same logins against vanilla's server and Horizon's with a mock session service. | | Secure chat | **Works** (`SignedMessageChain`, `LastSeenMessagesValidator`, `MessageSignatureCache`): the chat session update and its profile key, checked against the services' `playerCertificateKeys`; signed chat, `say`, `me` and `msg`, relayed with their signatures and last-seen lists; `enforce-secure-profile` (with `online-mode`) refuses unsigned chat; and vanilla's log lines (`[Not Secure]`, the decoder's warnings, the kicks). Offline servers and players without a chat session chat unsigned, as on vanilla. `test_secure_chat_parity.py` runs the same signed and unsigned players against vanilla's server and Horizon's, with test keys and a mock services server. | | Nether and End | **Works**: the server holds a level per dimension, and each player is in its own (portals, `execute in … tp`, respawn). `test_server_dimension_players_parity.py` checks it against vanilla. How it works, its cost and what's left: [server-levels.md](server-levels.md). | | Refused logins | Logged as vanilla does: `Disconnecting (/address): `, then ` (/address) lost connection: `, and the session errors (`Username '' tried to join with an invalid session`, `Couldn't verify username because servers are unavailable`). | | Gameplay for remote players | Checked against vanilla's server with a vanilla-codec client (#484, `tools/test_dedicated_server_parity.py`): a right click with an item on a block uses it once, a dead player's commands run, players on a team without friendly fire don't hurt each other, and a player's projectiles are theirs (their ender pearl takes them). The recipe book, status effects and block sync while digging (#500) work. Left: a player's projectiles don't hit other players (no bow PvP between guests), and a mob a guest kills counts in the host's statistics and advancements, not the guest's (`mob_died` runs as the host). | | Defaults that surprise owners | `white-list=true` is vanilla 26.3's default, so a fresh server refuses everyone until they are added. That matches vanilla, so the first-run flow (section 3) asks about the whitelist up front. | | Management | Only stdin. There is no service, no backups, no status beyond `list`, no restart after a crash, and a server can't be managed while it runs in the background. | | Packaging | The server is the full game client. Resources come from `horizon-prepare`, which needs a JDK and the official launcher's client install, both awkward on a headless VPS. | | Tick times | The dedicated loop never recorded its tick times in the game clock (only the windowed loop did), so `/tick query` on a dedicated server reported 0 ms. Fixed in PR 2b, which the benchmark needs (section 9). | | Scale | Measured against vanilla on equal work (section 9, `benchmarks/server-equal-work-2026-10-17/`): 20 TPS up to 50 bots, 9 to 11 TPS at 100, where vanilla's watchdog stops it. Horizon is ahead with few players and when exploring; with 25 to 50 players near spawn vanilla's tick is shorter. | ### Harnesses on main On the tip of the code PRs (2a to 3, rebased on b4ad6538), `tools/parity.py dedicated_server online_mode server_files server_player_combat server_player_damage` passed all five. That includes the vanilla protocol client joining Horizon's server and vanilla's from the same world. The full `--changed origin/main` set (309 harnesses, since the PRs touch the client crate) had passed 60 of 60 when it was stopped for disk space; CI runs it on every pull request. ## 2. Architecture: a supervisor and an engine ``` owner ──► horizon-server (CLI, TUI) browser ──► web panel AI ──► MCP (stdio) │ │ │ └────────── control API: HTTP + JSON on 127.0.0.1, token ────────┘ │ horizon-server run (the supervisor: no GPU, no game code) server.toml ──► server.properties ──► │ ◄── stdout: the vanilla log backups, updates, crash restart │ ──► stdin: console commands │ ◄─► engine endpoint: status, TPS/MSPT, players ▼ the engine: horizon-client --dedicated (the game, headless) ``` `horizon-server` is a new crate and binary (`crates/horizon-server`). It is the only program an owner runs. It depends only on the light crates (`horizon-core`, `horizon-protocol`, `horizon-world`, `horizon-bench`) plus `serde`, `toml`, `toml_edit` and `zip`, which are already in the lockfile. It doesn't link wgpu, winit, audio or controller crates. It runs the engine as a child process. The supervisor/engine split stays even after the engine becomes headless, for these reasons: - **Crash restart and diagnostics.** The supervisor outlives an engine crash. It keeps the crash report, restarts the engine with backoff, and the panel stays up to say what happened. - **Safe updates.** An update swaps the engine binary and restarts. If the new engine fails its start check, the supervisor rolls back to the previous one. - **Parity stays cheap.** The engine keeps reading vanilla's own files (`server.properties`, the four JSON lists, `eula.txt`) exactly as `test_dedicated_server_parity.py` checks them today. The clean config is a layer on top and never changes what the engine sees. - **No conflicts.** The supervisor touches none of the game code that the tick and integrated-server work (perf-server-1, perf-ticks-2) are changing. **The engine today** is `horizon-client --dedicated`. It needs one small, additive change: a status endpoint. With `--control-socket /.horizon/engine.json` (the existing control protocol: TCP on 127.0.0.1 plus a token, on every OS), the dedicated loop answers `server-status` with TPS, MSPT (average, P50/P95/P99 from the tick clock `/tick query` uses), players (name, UUID, address, ping), loaded chunks and uptime. It answers `console ` with the command's output. **The headless engine** comes after perf-ticks-2's work lands: Resources split into shared and per-game, the game built in `Game::new`, the integrated server on its own thread. On that base: 1. The dedicated path skips everything only a window uses: the HUD atlas, item renderer, block-entity and entity models, panorama, fog, music, and mesh tables (perf-ticks-2 already stops server-side meshing). This is the RAM and start-time item. 2. The game moves into a library crate, with the window parts behind a `client` feature, so `horizon-engine` links no wgpu or winit either. Sound and controllers are already optional (the `audio` and `controllers` features, on by default): `horizon-engine` today is the game built without them (section 3). ## 3. Packaging **Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and `horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`, `horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no controllers), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library; CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else `horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features` and copy `target/release/horizon-client` as `horizon-engine`. macOS and Windows have no such libraries to avoid, so they keep the one archive. **Game files: the owner's own copy, as with `prepare`.** Nothing of Mojang's is redistributed and nothing is downloaded on the owner's behalf. `horizon-server` looks for prepared resources in this order: 1. `[engine] resources` in `server.toml`; 2. `resources/` in the server folder; 3. `.local/horizon-client/26.3` beside the engine or up to four folders above it: a release folder, the installer's `game-files` (below), or a checkout. If none is found, `init` says where it looked. Today the owner runs `horizon-prepare --prepare --server-jar PATH` from the release folder (it needs a JDK and the client files, as for playing), or copies a folder prepared on their own computer to the server (`scp -r`). Planned wrappers: - **On this machine:** `horizon-server prepare --server-jar PATH` runs `horizon-prepare` on the owner's own server jar plus their launcher install. - **Prepared elsewhere:** `horizon-server import-resources DIR` copies a folder prepared on the owner's own computer, for example by the launcher, to a headless machine. A server-only `prepare` (from `server.jar` alone, without the client jar or a JDK) is the follow-up that removes the last Java step. It needs a list of which prepared files the server really reads, and that list comes out of the headless-engine work. **One-command install** (`packaging/server/`): - Linux and macOS: `curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh` The script picks the release archive for the OS and architecture (on Linux x86_64 the server archive, on macOS arm64 the game's), downloads it from GitHub Releases, and checks it against the SHA-256 GitHub publishes for each asset. It never asks for sudo. Options: `--version vX.Y.Z`, `--dir` (default `~/.local/share/horizon`), `--bin` (default `~/.local/bin`), and `--archive FILE` to install an archive already on the machine (a build of your own; no download and no checksum). A Linux release from before the server archive installs the full game, which links ALSA's and udev's libraries (`libasound.so.2`, `libudev.so.1`); minimal server images often lack them (Debian and Ubuntu: `sudo apt install libasound2 libudev1`, `libasound2t64` on Ubuntu 24.04 and later). Then the script lists any that are missing, `init` warns, and `run` stops at once with what to install, rather than restarting a game that can't load. - Windows: `irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex` The script does the same into `%LOCALAPPDATA%\Horizon` (`-Version`, `-Dir`), with junctions instead of links (no admin rights), and adds `bin` to the user's PATH. - **Layout.** Each release goes to `/releases/` and `/current` points at the one in use, so an update or a rollback is one link. `/horizon-server` and `/horizon-prepare` are two-line launchers that run `current`. The prepared game files live in `/game-files/horizon-client` and each release's `.local/horizon-client` links there, so updates keep them. The script then prints the next steps (`horizon-prepare`, `horizon-server init`). **Docker** (`packaging/server/Dockerfile`, built the same way as the relay's image): a multi-stage build that ends on `debian:bookworm-slim` as user `horizon`, with the server build as `horizon-engine`, so the image needs no ALSA or udev. It uses volume `/data` for the server folder, takes the owner's prepared game files as a read-only mount on `/opt/horizon/.local/horizon-client/26.3`, exposes port `25565/tcp`, and keeps the control API (and later the panel) on 127.0.0.1 inside the container: `docker exec horizon-server status|cmd|backup`. On the first start, `HORIZON_EULA=true` and `HORIZON_WORLD`, `HORIZON_SEED`, `HORIZON_PORT`, `HORIZON_WHITELIST`, `HORIZON_PLAYERS` and `HORIZON_OP` answer `init`'s questions; without the EULA answer the container exits and says why. `docker stop` sends SIGTERM, which saves and stops cleanly (give it `--stop-timeout 180`). The image is published only with Ludovic's OK (it's hosted infrastructure). **Services.** - `horizon-server service install` writes and enables a systemd unit (`--user`, or a system unit with `--system`) on Linux, a launchd agent (`~/Library/LaunchAgents/dev.horizon.server..plist`) on macOS, or a Scheduled Task at logon on Windows (a real Windows service needs a wrapper and comes later). - Each runs `horizon-server run --dir ` through the installer's launcher, so an update applies at the next restart. The supervisor restarts the engine itself, and the service manager restarts the supervisor. `service uninstall` stops and removes it and leaves the server folder alone. - `service print` shows the unit or agent it would write, for owners who'd rather install it by hand. **First run (`horizon-server init`, interactive on a terminal):** 1. **EULA.** It shows the link to https://aka.ms/MinecraftEULA and asks "Do you agree? [y/N]". Only a yes writes `eula=true`. 2. **World.** It asks for the world name (`world`) and an optional seed. An existing world folder, or a vanilla server folder, is detected and kept: its `server.properties` is imported (section 4). 3. **Port.** 25565 by default, checked free right away. 4. **Players.** It asks whether to use a whitelist (yes by default, as vanilla) and who to add first, then offers to make the first player an operator. 5. **Game files.** It finds or explains the prepared resources (see above). 6. It writes `server.toml`, shows the panel address and the commands to start it in the background (`horizon-server start`) or as a service. Every question has a flag (`--eula`, `--world`, `--seed`, `--port`, `--whitelist`/`--no-whitelist`, `--player NAME`, `--op NAME`, `--yes`) and an environment variable for Docker (`HORIZON_EULA`, …; flags win). ## 4. One config file: `server.toml` The owner edits only `server.toml`. The supervisor writes `server.properties` from it before every engine start, so the engine stays exactly vanilla. The file is short: typed sections for what owners change, and a `[properties]` table that passes any other vanilla key through unchanged. ```toml # Horizon server settings (docs/server.md). The server writes them into server.properties before # every start: edit this file, not that one. [server] motd = "A Minecraft Server" port = 25565 bind = "" # listen on every address, or only on this one (server-ip) max_players = 20 online_mode = true # check players' Minecraft accounts (false: anyone joins under any name) whitelist = true # only whitelisted players and operators can join enforce_whitelist = false # kick online players taken off the whitelist view_distance = 10 simulation_distance = 10 [world] name = "world" # the world folder seed = "" # empty: a random seed on the first start game_mode = "survival" # survival, creative, adventure or spectator difficulty = "easy" # peaceful, easy, normal or hard hardcore = false # Any other server.properties key, spelled as vanilla spells it, for example: # spawn-protection = 16 [properties] [engine] path = "" # the game program; empty: the one installed beside horizon-server resources = "" # the prepared game files; empty: resources/ here, else the usual places restart_on_crash = true [backups] every = "6h" # automatic backups while the server runs: 30m, 6h, 1d...; "0" turns them off keep = 12 # automatic backups kept (backups made by hand are never deleted) [updates] keep = 1 # other releases kept by updates, besides the one in use and the one rollback goes to ``` This is the file `init` writes today (PR 4 adds `[panel] bind = "127.0.0.1:8130"` and `allow_public = false`, section 6). The defaults are vanilla 26.3's (a test checks each one). Unknown keys are errors, as are a typed key set again under `[properties]` and values vanilla would refuse. Planned: `backups.before_update` (section 7). - **Import.** `horizon-server init` in a folder with a `server.properties`, or `horizon-server import server.properties`, reads it with vanilla's own parser (`ServerProperties`). Keys with typed fields go into their sections; any other key that differs from vanilla's default goes into `[properties]`. Keys left at their defaults are dropped, so the TOML stays short. The original file is kept as `server.properties.imported`. - **Changes made in game.** The engine writes `server.properties` itself, for example after `/whitelist on`. After it stops, the supervisor compares the file with what it wrote and copies any changed keys back into `server.toml`, so an in-game change survives the next start. Edits go through `toml_edit`, so the owner's comments and order are kept. - **Changes without opening the file.** `horizon-server config set KEY=VALUE...` (the launcher's Servers page uses it) writes through `toml_edit`, checks the result as `server.toml` is checked, and writes nothing if a value is wrong. On a running server it sends what vanilla changes live through its console: `difficulty`, `defaultgamemode` and `whitelist on|off`; the backup settings apply at once because the supervisor reads them each time. Everything else waits for the next start, and the command says which. A real hot reload (view distance, MOTD) needs engine support and comes later. - `server.properties`, `ops.json`, `whitelist.json`, `banned-*.json` and `world/` keep vanilla's names and formats. A vanilla server folder works as is, and so does going back to vanilla. ## 5. CLI and TUI ``` horizon-server init first run (section 3) horizon-server run supervisor + engine in the foreground; the console on stdin horizon-server start | stop | restart in the background (detached `run`); each waits for the game horizon-server status [--json] up?, uptime, TPS, MSPT, players, RAM, CPU, crashes horizon-server cmd one console command, with its output horizon-server logs [-f] [-n N] horizon-server op|deop horizon-server whitelist on|off|list|add|remove [player] horizon-server backup [NAME] backups restore [--yes] horizon-server import horizon-server config [--json] config set KEY=VALUE... [--json] horizon-server service install|uninstall|print [--system] horizon-server panel the panel's address and a one-time login link horizon-server update [--check] [--version vX.Y.Z] [--timeout S] rollback [--timeout S] horizon-server update --archive FILE --version NAME an archive you have (your own build) Planned: horizon-server console attach: the live log, and send commands (Ctrl-D detaches) horizon-server ban|pardon [reason] ban-ip|pardon-ip
(`cmd ban …` works now) horizon-server import-resources prepare --server-jar PATH horizon-server mcp MCP server on stdio (section 8) ``` - Every command takes `--dir` (the server folder; default: the current folder). - While the server runs, management commands go through the control API, so the engine applies them as vanilla's console would and its lists stay right. While it's stopped, they edit the vanilla files directly with the same code the engine uses (`horizon_server_files::user_lists`). - **The TUI** is `horizon-server console`: the log, a one-line header (TPS, MSPT, players, RAM) that updates every second, and a command line. It uses plain ANSI with no TUI framework, and falls back to plain lines when stdout isn't a terminal. ## 6. Web panel **Scope (MVP, PR 4):** status (phase, uptime, version, crashes), live TPS, MSPT, RAM and CPU graphs (the last 10 minutes, sampled once a second), players (list with ping, op, kick), the console (live log plus a command line), and backups (list, back up now, restore with confirmation), with restart and stop. The panel lives in the supervisor, so it is up exactly when the server is: starting a stopped server stays a command (or the service manager's job). Whitelist and bans go through the console for now. **Updates:** when a newer release is out, the panel says so with an Update button, which runs the same path as `horizon-server update` (section 7) and then shows how it went. **Later:** bans and IP bans, worlds, `server.properties` import, logs and crash reports, and modpacks (with the modpack work). **Tech:** one HTML page with plain JS and CSS (`crates/horizon-server/panel/`), embedded in the binary with `include_str!`. No build step, no framework, no CDN. It uses Horizon's design system tokens (`horizon-theme`'s colours, light and dark) and draws charts on a canvas. It polls the control API's routes once a second (`status`, `metrics?since=`, `logs?since=`), served by the same small HTTP code on `std::net`, with no async runtime or new dependency. Server-sent events can replace the polling later if a tab ever costs too much. **Auth: local or tailnet only, never an open admin port.** - **Bind.** The panel listens on `127.0.0.1:8130` by default. `panel.bind` can name a tailnet address (100.64.0.0/10 or Tailscale's fd7a:115c:a1e0::/48) or another loopback address. Any other address, such as 0.0.0.0 or a public IP, is refused unless `panel.allow_public = true` is set, and even then the panel logs a warning at every start. - **Who may act.** The API token in `.horizon/api.json` (file mode 0600) is what the CLI and MCP use. Browsers get a one-time login link from `horizon-server panel`: a code good for 10 minutes, swapped for an `HttpOnly; SameSite=Strict` session cookie. Anyone who can read the server folder can manage the server, and no one else can. Sessions end when the supervisor stops. - **Protection.** The `Host` header must name the panel itself: its address, `localhost` on loopback, or a tailnet name (a MagicDNS short name or `*.ts.net`). That stops DNS rebinding. Changes are POST only and need the `X-Horizon: 1` header, which stops CSRF. After 10 failed logins in a minute, logins are refused for a while. The page is served with a strict Content-Security-Policy (nothing from elsewhere) and can't be framed. - **Remote access** goes over Tailscale/SSH (`ssh -L 8130:127.0.0.1:8130 host`), with no TLS or passwords to manage. HTTPS comes for free with `tailscale serve`, and the doc shows how to set it up. ## 7. Backups, updates and crash restart - **Backups.** The supervisor runs `save-off`, then `save-all flush`, then zips the world plus the vanilla files and `server.toml` into `backups/-.zip`, then runs `save-on`. Players stay online. Zip works for everyone, Windows included. It runs on the `every` schedule and on demand (later also before an update). It keeps the last `keep` automatic backups and never deletes one made by hand. A backup is written to a temporary name and renamed when complete, so a crash never leaves a half-written backup that looks finished. A stopped server is backed up straight from its files. - **Restore.** `restore NAME` takes a file name, a unique prefix, or the name given to `backup` (the newest with it). The server stops. The current world is moved to `backups/replaced-