packaging/server/amp/ holds a template for CubeCoders AMP's generic module. Its update stage runs the egg's own install script (the release asset checked against its SHA-256), the start runs the same horizon-start.sh, and its settings are the egg's variables with the same defaults. Tests keep both in step and check the console patterns against the lines the server logs. Fixes #8238 Co-Authored-By: Claude Opus 5.5 <[email protected]>
43 KiB
Host a server
horizon-server hosts a Minecraft Java Edition 26.3 server with Horizon's own game as the engine: one native
program, no Java, one settings file, a command line, and a web panel. Vanilla 26.3 clients and Horizon clients
can join it. This page covers what works on main today and says plainly what doesn't yet. The design and the
plan are in server.md.
Before you start
- Online mode checks accounts (release v2026.10.17.07 and newer). With
online_mode = true, the default, every player joins with their own account, as on a vanilla server: vanilla players signed in with the official Minecraft launcher, Horizon players with the Microsoft account they signed in with in Horizon's launcher (microsoft-account.md). - Names you add are accounts (release v2026.10.17.14 and newer).
init --player,horizon-server whitelist addandop, and the console'swhitelist add,opandbanlook a name up the way vanilla does: players already seen (kept inusercache.json), then Mojang's profile service. In online mode a name with no account is refused. So you can whitelist friends before they first join. On an offline server,horizon-server whitelist addalways writes the offline UUID, the one the player joins with, also while the server runs (it writes the entry and the game reloads the list): the console's ownwhitelist addtakes the account's UUID when someone owns that name, as vanilla does, and that player is then refused. - An offline server (
online_mode = false) never checks accounts: anyone who reaches it can join under any name. Keep it on your LAN or a tailnet (Tailscale, ZeroTier), or share it only with friends you trust. A Horizon player under an offline name can join only such a server. - Secure chat works as on vanilla's server: in online mode, vanilla clients and Horizon's (Nightly
2026-11-01 and newer) sign their chat, and with
enforce-secure-profile=true(the default) unsigned chat is refused. On an offline server, or for a player without a chat session, chat stays unsigned and shows as not secure. - Nether and End: each player travels on their own through portals, and operators with
execute in <dimension> run tp. The players in the level they leave stop seeing them, and the ones in the level they arrive in start to, as on vanilla (checked against vanilla with two players, by command and through an End portal:tools/test_server_dimension_players_parity.py). The console can't send another player to a dimension yet (execute in ... run tp NAME). - Movement checks and chunk pacing (release v2026.10.17.16 and newer). The server pulls back players who move too fast ("moved too quickly"), as vanilla does, and sends chunks as fast as each client acknowledges them.
- No Docker image is published. You build it yourself from the repository (below). Publishing an image needs the project owner.
- Nothing of Mojang's is downloaded or shipped. The server reads the game files you prepare from your own copy of Minecraft 26.3.
1. Install
Linux (x86-64) and macOS (Apple Silicon; Intel from the next Nightly):
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
Windows (x64 or ARM64), in PowerShell:
irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex
The script picks the machine's x64 or ARM64 archive, including when PowerShell runs under x64 emulation. It downloads
the current Horizon Nightly from GitHub (the only build Horizon ships;
--version picks another), checks it against the SHA-256 GitHub publishes, and never asks for sudo or administrator rights. It installs:
- the release in
~/.local/share/horizon/releases/<version>(Windows:%LOCALAPPDATA%\Horizon), withcurrentpointing at the one in use; horizon-serverandhorizon-preparein~/.local/bin(Windows: added to your PATH);- a
game-filesfolder for the files you prepare, kept across updates.
Options: --version vX.Y.Z, --dir FOLDER, --bin FOLDER (Windows: -Version, -Dir). Other systems,
such as Linux on ARM, build from source: cargo build --release -p horizon-client --bin horizon-client -p horizon-server and keep both programs in one folder.
The Horizon launcher also installs releases. Every release folder holds horizon-server, so you can run it
from there too.
2. Prepare the game files
Horizon reads Minecraft's data from your own copy of the game. You need Minecraft Java Edition 26.3, played
once from the official launcher on this computer, a Java 25 runtime, and the 26.3 server.jar: it's the
downloads.server.url in your launcher's versions/26.3/26.3.json. Then:
horizon-prepare --prepare --server-jar /path/to/server.jar
Add --minecraft-dir PATH if your launcher keeps the game elsewhere. The prepared files go to
game-files, which updates keep.
On a headless machine (a VPS, a home server), prepare on your own computer instead, with the launcher or
horizon-prepare, and copy the prepared 26.3 folder to the server with horizon-server import-resources DIR (it checks the
folder and its version first), or by hand: into
~/.local/share/horizon/game-files/horizon-client/26.3, or into resources/ in the server folder, or anywhere
[engine] resources in server.toml names. A folder copied this way needs release v2026.10.17.07 or
newer: older ones don't keep registries.json and commands.json with the prepared files, and the
server then stops with "registries.json not found next to the resources". Prepare again with a newer release.
3. First run
mkdir my-server && cd my-server
horizon-server init
init asks five things and writes server.toml:
- whether you agree to the Minecraft EULA: only a yes writes
eula=true; - the world's name and an optional seed (an existing world or vanilla server folder is kept and its
server.propertiesimported); - the port, 25565 by default;
- whether to use a whitelist (yes by default, as in vanilla) and who to add;
- whether to make the first player an operator.
It then tells you where it found the game and its files. Every question has a flag, so it also runs from a script:
horizon-server init --eula --world survival --seed 12345 --port 25565 --whitelist --player Alex --op Alex --yes
--no-whitelist turns the whitelist off, and --offline turns online mode off (see "Before you start").
The players you add are looked up as Minecraft accounts in online mode, and get offline UUIDs on an offline
server, as vanilla does.
Start the server in the foreground, with the console on your terminal (Ctrl-C stops it cleanly):
horizon-server run
or in the background:
horizon-server start
horizon-server status
Running, up 29 s
TPS 20.0, MSPT 1.0 ms average, 3.1 ms P95
0 of 20 players, 377 chunks loaded
RAM 1.42 GB, CPU 2 % of a core
Players join at your computer's address and the port (192.168.1.20:25565, or a tailnet name). Every
command takes --dir FOLDER to work on a server folder from elsewhere.
4. Settings: server.toml
You edit one file. The server writes vanilla's server.properties from it before every start, so the game
reads exactly what a vanilla server reads, and a vanilla server folder works as it is.
[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 accounts with Mojang (see "Before you start")
whitelist = true
enforce_whitelist = false
view_distance = 10
simulation_distance = 10
max_view_distance = 0 # past view_distance, saved chunks only, up to this (0: off)
admin_feedback_to_ops = true # operators see each other's commands ([name: ...]); false: only the log
accept_older_clients = false # true: 26.2 clients join too (see below; work in progress)
[world]
name = "world"
seed = "" # empty: a random seed on the first start
game_mode = "survival"
difficulty = "easy"
hardcore = false
# Any other server.properties key, spelled as vanilla spells it:
[properties]
# spawn-protection = 16
# resource-pack = "https://example.com/pack.zip" # offered to players as they join; with
# resource-pack-sha1, require-resource-pack and resource-pack-prompt, as on vanilla
[engine]
path = "" # the game program; empty: the one beside horizon-server
resources = "" # the prepared game files; empty: resources/ here, else the usual places
restart_on_crash = true
low_memory = false # true: less memory, slightly slower ticks (see below)
[backups]
every = "6h" # 30m, 6h, 1d...; "0" turns automatic backups 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
[panel]
bind = "127.0.0.1:8130" # this computer only, or a tailnet address (100.x); "" turns it off
allow_public = false
[api]
bind = "" # the control API: a free port on 127.0.0.1, or a fixed one like 127.0.0.1:8131
token = "" # 16 characters or more; "" a new random one at every start
allow_public = false
The defaults are vanilla 26.3's. Unknown keys and values vanilla would refuse are errors, so a typo stops the
start instead of being ignored. When the game changes a setting itself (/whitelist on), the change is copied
back into server.toml when it stops, and your comments are kept. Changes to the file apply at the next
restart: there is no live reload yet.
Operators' commands: admin_feedback_to_ops
On by default, as vanilla: when an operator runs a command that reports to admins (/gamemode, /give, /tp...),
the other operators read [name: ...] in gray, and the console logs it. Players who aren't operators never see it
either way. false keeps it to the console's log (logs/latest.log): nobody in game sees an operator's commands,
while each operator still reads their own command's answer.
Older clients: accept_older_clients
Off by default, a Horizon setting with no vanilla equivalent: a vanilla server, and Horizon's, turn away a client of
any other release. With accept_older_clients = true a 26.2 client may join too. Horizon renumbers its packets and
the blocks, items, entities, particles and sounds in them both ways, with no proxy in between; what 26.3 added and
26.2 lacks is shown as the nearest thing 26.2 has (a poplar log as a birch log, a wool slab as a stone slab), and
what has no such thing (a cushion) isn't shown. The server list shows the server as joinable for 26.2.
This is work in progress (#10404): the packets whose layout changed between 26.2 and 26.3 aren't translated yet, so for now a 26.2 player gets through the login and is then told that 26.2 can't be translated yet, before the server sends anything it would misread. 26.3 clients are served exactly as before, whatever the setting.
Seeing further: max_view_distance
Off by default (0), a Horizon setting with no vanilla equivalent. Past view_distance, up to
max_view_distance (at most 32), each player is also sent the chunks the world has already saved, as far
as their own render distance goes. Those chunks are only read and sent: the server never generates them,
keeps them loaded or ticks them, so what lies out there stands still (no mobs, no growing crops, no
redstone) until a player comes within view_distance. A chunk the world hasn't saved yet stays empty on
the player's screen. To fill the ring, generate the world ahead of the players with /pregen
(horizon-pregen = true under [properties], then horizon-server cmd pregen start 2500,
pregeneration).
The far ring goes after everything else: its chunks wait behind the ones inside view_distance, are paced
by how fast each player's game takes them, and aren't read while the world's saves are behind. When the
average tick goes over 40 ms, the ring pulls in by two chunks a second, never inside view_distance; after
30 s of ticks under 25 ms, it grows back by one chunk every 5 s. What it sent is kept in memory for 5
minutes (96 MB at most), so a player walking back isn't sent the save again.
Already have a vanilla server? Run horizon-server init in its folder, or horizon-server import server.properties to write server.toml from it. The world, ops.json, whitelist.json and the ban lists
keep vanilla's names and formats, so going back to vanilla works too.
Native server export
A native-mods build exports a standalone engine
(horizon-launcher native-build FOLDER --server-output DIR --version TAG --trust-native-code;
see Native mods). Point the server at it:
horizon-server --dir SERVER config set engine.path=/absolute/export/horizon-engine
horizon-server --dir SERVER restart
Use the absolute engine path printed by the build. Build on the same OS and architecture as the server
(no cross compilation), using a prepared installed launcher release. Updates do not rebuild a pinned engine.path: build a new
export folder, set engine.path to it, restart, and keep the old folder for manual rollback. The supervisor does
not auto-update or rebuild a pinned engine path, and there is no compatibility handshake yet (follow-up #6304).
Native mods run unsandboxed: only build code you have read and trust.
Behind a Velocity proxy
A network can put one Velocity proxy in front of several servers: the proxy signs the players in with their Microsoft accounts, and the servers behind it trust what it forwards. To run Horizon as one of them:
- Set
online_mode = falseinserver.tomland keep the server's port reachable only from the proxy (bind it to127.0.0.1or put it on a private network). - Put the proxy's forwarding secret, the one in Velocity's
forwarding.secret, into a fileforwarding.secretin the server folder, and setplayer-info-forwarding-mode = "modern"invelocity.toml. - Restart the server. The log says
Velocity player information forwarding is on.
Players then join with the UUID, name, skin and address the proxy forwards. Anyone who connects without going
through the proxy is refused, and so is a login signed with another secret. With the file in place but
online_mode = true the file is ignored (with a warning), and an empty file stops the start, because an empty
secret could be signed by anyone. How the exchange works: velocity-forwarding.md.
Bedrock players that join through Geyser and Floodgate arrive with a Floodgate UUID (00000000-0000-0000-0009- and
the Xbox id), which is never on whitelist.json. With white-list=true, list that prefix in a file
whitelist-bypass.txt beside server.properties (one UUID prefix per line, # comments, at least 8 characters) and
everyone whose UUID starts with it passes the whitelist. Bans and the player limit still apply. The log says
whitelist-bypass.txt: 1 UUID prefix(es) skip the whitelist.
5. Commands
horizon-server init first run
horizon-server run in the foreground, with the console; Ctrl-C stops it
horizon-server start | stop | restart in the background (its output goes to logs/supervisor.log)
horizon-server status [--json] up or not, TPS, MSPT, players, chunks, RAM, CPU
horizon-server cmd COMMAND... one console command, and what it answers
horizon-server logs [-f] [-n N] the log; -f follows it
horizon-server console attach: the live log, TPS/players at the top, type commands; Ctrl-D detaches
horizon-server mcp an MCP server on stdio, so an AI agent can run and moderate the server
horizon-server report [SECONDS] record a performance report (below)
horizon-server op|deop NAME
horizon-server whitelist on|off|list
horizon-server whitelist add|remove NAME
horizon-server players [--json] who is online, the operators, the whitelist and the bans
horizon-server kick|ban NAME [REASON] on the running server
horizon-server pardon NAME
horizon-server ban-ip ADDRESS|NAME [REASON]
horizon-server pardon-ip ADDRESS
horizon-server backup [NAME] back up now
horizon-server backups [--json] list the backups
horizon-server restore BACKUP [--dimension ID] [--yes]
horizon-server service install|uninstall|print [--system]
horizon-server panel a one-time login link for the web panel
horizon-server import FILE server.toml from a vanilla server.properties
horizon-server import-resources DIR copy prepared game files into place (step 2)
horizon-server config [--json] the settings in server.toml
horizon-server config set KEY=VALUE... [--json]
change settings, checked first (below)
horizon-server update [--check [--json]] [--version TAG] [--timeout SECONDS]
install the newest release and restart on it (below);
--archive FILE --version NAME installs an archive you have
horizon-server rollback [--timeout SECONDS]
back to the release used before
While the server runs, op, whitelist and cmd go to the game's console, so they act exactly as vanilla's
console commands do. While it's stopped, they edit vanilla's files directly. kick runs the vanilla command
on the running server, through its control API; ban, pardon, ban-ip and pardon-ip do the same while it
is running and edit the vanilla files directly while it is stopped. players reads the
lists from the files when the server is stopped. Any console command works through
cmd: horizon-server cmd ban Griefer, horizon-server cmd say Restarting in 5 minutes.
Temporary bans
tempban PLAYER DURATION [REASON] is a Horizon extra for dedicated servers (vanilla has only ban, which is
permanent). Operators of level 3 (/ban's level) run it in game as /tempban; the console takes the same words
without the slash, and horizon-server cmd tempban Griefer 12h griefing runs it through the control API. The
duration is a whole number and s, m, h, d or w: 30m, 12h, 7d. It writes an entry with expires set
to banned-players.json, the way vanilla writes a ban and the chat filter's automatic temporary ban does, so it
survives a restart, banlist shows it, pardon lifts it and the entry goes away once it has ended. A player who
is online is kicked with the reason and the time the ban ends, and the same message is what they read when they
try to join. A player who is already banned (and whose ban has not ended) is left as they are: pardon first to
change a ban. The player may be offline if the server has seen them. The console logs it as
[tempban] <operator> banned <player> for <duration>, and the event stream (GET /api/v1/events) gets a ban
event with reason, by and until. tempbanlist lists the bans that end by themselves, soonest first, with
who banned, the time left, the end and the reason. Both are in operators' command tree (level 3 and above), so
their clients complete them, as they do the mute commands.
To generate the world around spawn ahead of the players, Horizon has its own pregen command, off until
server.toml has horizon-pregen = true under [properties]: horizon-server cmd pregen start 2500
(pregeneration).
To let only the Horizon client join, set horizon-clients-only = true under [properties] (off by default).
Other clients are turned away while they connect with "This server is for the Horizon client:
playhorizon.dev", and the console says which client it was. The check reads the brand a client names itself
by (minecraft:brand), which any client can fake: it keeps out players who didn't install Horizon, not
cheaters. The server's own checks (reach, movement) are what protect it.
Horizon clients can get their far terrain (Video Settings → LOD Distance) from the server: set
horizon-lod-distance under [properties] to how far, in chunks (1024 at most), and the server samples the
tiles each player's client asks for out to there. It is off (0) by default: far tiles show the world's terrain
far past the view distance, which helps players find the seed. A player's own LOD Distance applies when it is
shorter. Each player may have 8 tiles waiting and ask for 64 a second on average, and one background thread
samples them for everyone in turn, behind the ticks, so far terrain never slows the server down; a tile past the
distance or over a player's limits is refused, and a client that keeps breaking the limits is disconnected.
Tiles are sampled again for each player who asks (there is no cache yet). Vanilla clients never hear of it.
horizon-server report records the running server for 60 seconds (report 300 for five minutes, up to 600),
then prints a short summary and saves it in reports/: perf-<time>.txt and perf-<time>.json. They say where
the ticks' time went, phase by phase (mobs, block ticks, block entities, the network and the chunks it
sends, saving...), the slowest ticks, how many entities of each type, chunks, players, memory, CPU, and the
settings that matter (view and simulation distance, max players). Since format 2 they also give each dimension's
share of the tick (level_*), what each mob type costs per dimension (one tick in four times every mob: ms a
tick, mobs, µs a mob), the chunk workers' CPU by job (load, generate, decorate, finish, light), the network bytes
a player sends and receives, and what recording cost the tick (measured, usually well under 1 % of MSPT). They
hold no names, UUIDs or addresses, so you can attach them to an issue as they are. On the console it is
horizon-report [SECONDS]; horizon-report stop writes the report now and horizon-report status says how far
it is. Nothing restarts: the report runs beside the game.
To see a slow stretch tick by tick, horizon-trace [SECONDS] (30 by default, up to 300) writes every thread's timed
steps to reports/trace-<time>.json; ui.perfetto.dev opens it as a timeline, one row per
thread. horizon-trace stop ends it early. It holds step names and times only.
Between reports, the log keeps the spikes: a tick slower than 250 ms writes a Horizon: slow tick line with
its phases (HORIZON_SPIKES=MS in the engine's environment sets another threshold), and a server more than
two seconds behind says Can't keep up! as vanilla's does, at most every 15 seconds.
config set changes server.toml without opening it: horizon-server config set world.difficulty=hard server.max_players=10. Each key is a section and a name from the file (properties.spawn-protection=0 for any
other vanilla key). The values are checked as the server checks them, and nothing is written if one is wrong.
A running server takes the difficulty, the default game mode, the whitelist, the message of the day, the
player limit and the backup settings at once; the rest applies at its next start, and config set says which. --json prints that as saved, applied
and restart.
The server restarts the game by itself if it crashes: after 5 s, then waiting longer each time, up to 5
minutes. It keeps the crash report and gives up after 5 crashes in 10 minutes, and status says why.
Updates (a server installed with the install script). The server checks for a newer release a minute
after it starts and every 6 hours after, and says so in its log, in status and in the panel; it never
updates by itself. horizon-server update --check (add --json for machine output) asks once. horizon-server update:
- downloads the current Nightly and checks it against its signed SHA-256;
- stops the game, backs up the world (
backups/<time>-before-<release>.zip) and switches to the new release; - starts the game on it and waits up to 5 minutes (
--timeout SECONDS) for it to come up.
If the new game crashes or doesn't come up in time, the server goes back to the release it was on by itself
and puts the world back as it was just before the switch (the world the new game left is kept in
backups/). horizon-server rollback goes back to the release before by hand, keeping the world as it is
(it backs it up first). --version vX.Y.Z installs a given release, and --archive FILE --version NAME an
archive you have, such as your own build. A server still on a release from before the last dated one doesn't know
that 0.2 comes after the dated releases and says it is up to date: run horizon-server update --version v0.2.0
once (versioning). A stopped server is switched and starts on the new release next
time. Set HORIZON_NO_UPDATE_CHECK=1 in the server's environment to turn the check off.
6. Web panel
The panel runs inside horizon-server while the server runs. It shows live TPS, MSPT, RAM and CPU graphs for
the last 10 minutes, the players with their ping (and a kick button), the console with a command line, and the
backups (back up now, restore with a confirmation), with Restart and Stop. When a newer release is out it says
so, with an Update button that runs horizon-server update (section 5), and shows how it went.
horizon-server panel
prints a login link that works once, for 10 minutes. The panel listens on 127.0.0.1:8130, this computer
only. To reach it from your other devices:
- put your tailnet address in
[panel] bind(for examplebind = "100.101.102.103:8130"), or - forward the port over SSH:
ssh -L 8130:127.0.0.1:8130 your-server, then open the link on your computer.
Any other address, such as 0.0.0.0 or a public IP, is refused unless you set allow_public = true, and
even then the panel warns at every start. It has no passwords to leak: whoever can read the server folder can
make a login link, and no one else can. Never put it on the open internet.
A bot or a tool of your own
Everything the panel does goes through the control API (api/server.md), which a moderation bot
(a Discord !ban), a monitoring tool or a script can call too. It listens on a free port of 127.0.0.1 and
writes the address and a fresh token to .horizon/api.json at every start. For a program that runs elsewhere
or keeps its settings, fix both in server.toml:
[api]
bind = "127.0.0.1:8131" # or your tailnet address, 100.x.y.z:8131
token = "a long random string only the bot knows"
The same rules as the panel's hold: a tailnet or loopback address, unless allow_public = true (the token is then
all that guards the server, and it warns at every start). The API can't change [engine], [panel], [api] or
[discord], nor turn on RCON or another remote console.
Chat in a Discord channel
[discord] posts the server's chat, joins, leaves and deaths to a Discord channel. In the channel's settings,
Integrations → Webhooks → New Webhook → Copy Webhook URL, then:
[discord]
webhook = "https://discord.com/api/webhooks/..." # a secret: whoever has it can post in the channel
events = ["chat", "join", "leave", "death"] # and "kick", "announce" (lines native mods post)
enabled = true # false pauses it and keeps the webhook
The lines of each 2 seconds are drawn as one picture of the in-game chat (transparent, shadowed letters, colours
included), and a channel gets at most one post every 2 seconds (more when Discord asks for it: a burst beyond 20
lines is counted, not drawn). Only chat is forwarded, never a message the server's chat filter held back. Posts
never ping: they allow no mention, and an @everyone typed in game reaches Discord defused. Every post comes
from the webhook's own name and avatar (set them in Discord), never a player's; players' addresses are never sent,
and a player is always named by their account's name (a nickname only next to it). The server only makes outgoing HTTPS calls to Discord;
the API and the panel show the webhook as (set). The letters are Pixelify Sans (SIL Open Font License,
crates/horizon-theme/fonts), not Minecraft's font.
Tools written for vanilla's management protocol
Vanilla 26.3 has its own management protocol, JSON-RPC over a WebSocket, and Horizon's server speaks it too, so a
tool written for a vanilla server manages a Horizon one. It's off by default; turn it on in server.toml:
[properties]
management-server-enabled = true
management-server-port = 8132 # 0: a free port, logged at the start
# management-server-secret: 40 letters and digits; left out, the server makes one and writes it here
# management-server-tls-keystore = "management.p12" # your certificate (PKCS12); left out, see below
# management-server-tls-keystore-password = "..." # or MINECRAFT_MANAGEMENT_TLS_KEYSTORE_PASSWORD
It listens on localhost (management-server-host). A client sends the secret as Authorization: Bearer <secret>,
or, from a browser, in the WebSocket subprotocol (minecraft-v1,<secret>) from an origin listed in
management-server-allowed-origins. rpc.discover lists the methods; Horizon answers more of them with each release
(api/server.md).
TLS is on, as in vanilla (wss://). The certificate and its key come from a PKCS12 keystore, as vanilla's do:
keytool -genkeypair -storetype PKCS12 ...makes one, oropenssl pkcs12 -exportturns a PEM certificate and key into one.- Its password comes from the
MINECRAFT_MANAGEMENT_TLS_KEYSTORE_PASSWORDvariable, else frommanagement-server-tls-keystore-password.
Where vanilla refuses to start without a keystore, Horizon makes one:
- a self-signed certificate for
localhost,127.0.0.1andmanagement-server-host, saved in.horizon/management-keystore.p12, which only the server's user can read; - its path and password are written to
[properties]; - each start logs the certificate's SHA-256 fingerprint, which a client pins or adds to its trusted certificates.
Keep the protocol on localhost or your tailnet even so. management-server-tls-enabled = false turns TLS off.
As in vanilla, a wrong secret or keystore stops the server from starting.
Tools that speak RCON
Hosting panels, Discord bridges and scripts often run commands through RCON, vanilla's remote console, and Horizon's
server serves it. It's off by default and needs a password; turn it on in server.toml (the dotted keys are
quoted in TOML):
[properties]
enable-rcon = true
"rcon.port" = 25575
"rcon.password" = "a long random string"
It listens on the address [server] bind names (server-ip; every address when it's empty), as vanilla's does.
A client logs in with the password and each command runs as one typed in the server's console, with the same
output (horizon-server cmd does the same from the shell). RCON isn't encrypted: the password and every command
travel in clear text, and it gives full control of the server. Keep its port closed to the internet: a firewall, a
tailnet or an SSH tunnel. As in vanilla, an empty password or a port outside 1 to 65535 leaves RCON off with a
warning in the log, and a port already in use is a warning too, not a server that doesn't start. The settings are
read when the server starts. To turn it off again, set enable-rcon = false: taking the lines out of
server.toml leaves what server.properties already holds, as for any other key.
Where it differs from vanilla: a connection that hasn't logged in within 30 seconds is closed, at most 16 are open at once, and a command a client's TCP splits in two still works.
A command runs from a source named Rcon: operators in game see what it did as [Rcon: ...] (gray, italic) unless
broadcast-rcon-to-ops is false, and a say shows as [Rcon] ....
Tools that read the query
Server lists, status pages and monitoring tools (mcstatus, Prometheus exporters, hosting panels) often read the
MOTD, the version and who is online through vanilla's GS4 query, a UDP port, and Horizon's server serves it. It's off
by default; turn it on in server.toml:
[properties]
enable-query = true
"query.port" = 25565
It listens on UDP on the address [server] bind names (every address when it's empty), as vanilla's does: the same
number as the game's TCP port is fine, they are different ports. The query tells anyone who can reach the port the
player names and the version, and has no password: open the port only if you want that. As in vanilla, a port
outside 1 to 65535 leaves it off with a warning in the log, and a port already in use is a warning too. The
settings are read when the server starts. To turn it off again, set enable-query = false.
Where it differs from vanilla: while the game isn't running (starting, restarting) nothing answers, and who is online is read from the game at most once a second.
7. Backups
horizon-server backup before-the-update
horizon-server backups
horizon-server restore before-the-update
A backup zips the world, vanilla's files and server.toml into backups/. A running server saves first and
stays up: players don't notice. Automatic backups run on the [backups] every schedule while the server runs,
and the oldest automatic ones beyond keep are deleted. Backups you make by hand are never deleted.
restore takes a backup's file name, a unique start of it, or the name you gave it. It moves the current
world aside to backups/replaced-<time>/ (never deleted), puts the backup in place, and restarts the server if
it was running. Add --dimension ID (like minecraft:the_nether) to bring back only that dimension and leave
the rest of the world as it is. The panel does the same with one click.
A smaller world: [prune_chunks]
Players who travel far leave a trail of chunks nobody comes back to. This deletes the chunks players spent
less than a minute near (vanilla's InhabitedTime, 1200 ticks), so they generate again from the seed, as they
were, if someone returns. Back up first. Add to server.toml, then restart:
[prune_chunks]
mode = "dry-run" # "dry-run": only the report, the game starts at once; "delete": before the game starts
min_inhabited_ticks = 1200 # chunks players spent less than this near go
spawn_radius = 32 # chunks this close to the spawn stay (0 0 in the Nether and the End)
ring = 1 # chunks this close to a kept chunk stay, so no seam opens beside a build
keep_files = ["horizon-smp/claims.json", "horizon-smp/places.json"] # in the world folder: claims, homes
After the run, mode is set back to "off" and logs/prune-chunks-<time>.txt says, per dimension, how many
chunks went (or would go) and how much disk was freed. The chunk's entities and points of interest go with it;
region files are rewritten compacted (vanilla still reads them) and a file left empty is deleted. Chunks that
can't be read, files in keep_files (keys or text like minecraft:overworld 12 -3, objects with dimension,
x and z), level.dat and player data are never touched; a keep_files file that exists but can't be read
stops the prune before anything is deleted.
Deletion stages each region's terrain, entities and POI together, keeping originals in the dimension's
.horizon-prune folder until it commits. An interrupted region is rolled back before the next server
start, even though mode is now "off"; committed earlier regions stay pruned. A failed prune or failed
recovery stops startup and reports an error instead of reporting planned deletions as successful. Keep
the recovery folder if recovery fails; do not delete it to force the server to start. Staging retains
hard links to the originals and needs room for the compacted files of one region group. The three
folders must support hard links on the same filesystem. Unix flushes files and directories; Windows
flushes files and uses write-through renames. This does not replace a backup or promise recovery from
filesystem corruption or storage hardware that ignores flushes.
1.8 combat: [combat]
Fights follow 26.3's rules. For 1.8.9's (no attack cooldown or sweep, 1.8 weapon damage, Strength and
Sharpness; the list is in pvp.md), add to
server.toml, then restart:
[combat]
legacy = true
Players join with any vanilla 26.3 client: only the server decides the damage.
8. Start it with the computer
horizon-server service install
- Linux: a systemd user unit (
--systemfor a system unit, with sudo). To keep a user unit running while you're logged out:loginctl enable-linger $USER. - macOS: a launchd agent in
~/Library/LaunchAgents. - Windows: a task that starts at logon (a real Windows service comes later).
The service runs horizon-server run through the installed current release, so an update applies at the
next restart. service print shows what it would install, service uninstall removes it and leaves the server
folder alone.
Hosting panels (Pterodactyl, AMP)
The egg installs the server build of the current Nightly (nothing of Mojang's) and runs horizon-server
through packaging/server/panel-start.sh: the first start writes server.toml from the panel's settings, and the
port always follows the panel's allocation. Prepare the game files on your own computer (section 2) and upload the
prepared 26.3 folder's contents to resources/ in the server's files. Set the EULA setting to true before the
first start: until then the server stops and says why. The settings (world, seed, whitelist, players, operator,
online mode) are read on the first start; afterwards edit server.toml. The panel's Stop sends stop, which saves
the world first.
Import packaging/server/pterodactyl-egg.json under Nests. Its Docker image is
ghcr.io/ptero-eggs/yolks:debian. The HORIZON_VERSION variable picks the release to install (nightly, or a tag);
it is read at install and reinstall.
AMP (CubeCoders): packaging/server/amp/ is a template for AMP's generic module, the same setup with AMP's
words. Its update runs the egg's install script (the release named by "Horizon release", checked against its
SHA-256) and the start runs the same horizon-start.sh, with the port from AMP's "Server Port". AMP reads templates
from the root of a configuration repository: put the five files at the root of a repository of yours (a fork of
CubeCoders/AMPTemplates, for example), add it under Configuration → Instance Deployment → Configuration Repositories,
then Fetch latest, and "Horizon" is in the list of applications for a new instance. Linux only, x86-64.
9. Docker (build it yourself)
No image is published: build one from the repository. The image holds none of Minecraft's files; you mount the folder you prepared (step 2) read-only.
git clone https://github.com/horizon-rust/horizon && cd horizon
docker build -f packaging/server/Dockerfile -t horizon-server .
docker run -d --name horizon -p 25565:25565 --stop-timeout 180 \
-v horizon-data:/data \
-v /path/to/26.3:/opt/horizon/.local/horizon-client/26.3:ro \
-e HORIZON_EULA=true -e HORIZON_WHITELIST=true -e HORIZON_PLAYERS=Alex,Steve -e HORIZON_OP=Alex \
horizon-server
HORIZON_EULA=trueaccepts the Minecraft EULA for this server. Without it the container stops and says why.- The first start writes
/data/server.tomlfromHORIZON_EULA,HORIZON_WORLD,HORIZON_SEED,HORIZON_PORT,HORIZON_WHITELIST,HORIZON_PLAYERS,HORIZON_OPandHORIZON_ONLINE. Later starts use the file. - The mounted folder must come from release v2026.10.17.07 or newer (see step 2).
- Manage it with
docker exec horizon horizon-server status(andcmd,backup,logs, ...).docker stopsaves and stops cleanly;--stop-timeout 180gives it the time. - The panel stays on 127.0.0.1 inside the container.
- The image still carries ALSA and udev libraries because the game links them, even though a server never plays a sound. A smaller engine without them is planned.
10. How fast is it
The latest bot benchmark compares Horizon with vanilla 26.3, Paper and Fabric + Lithium: benchmarks/server-four-way-2026-10-19. It ran release v2026.10.19 on a 4-thread laptop CPU (i7-7500U), with 1 to 50 players walking, exploring and standing around spawn, the bots on another machine. All four servers did the same work. Earlier measurements are in docs/server.md. In short:
- Memory: 0.7 GB with one player and 1.1 GB with 50, against 3.4 to 4.5 GB for the Java servers (each with a 4 GB heap): 3.8 to 4.8 times less.
- Start: ready in 8 to 9 s, against 15 s for vanilla, 17 s for Fabric and 19 to 22 s for Paper.
- CPU: from 10 players up, the least of the four (1.4 cores at 50, against Paper's 2.1 and Fabric's 2.4).
- Average tick: Paper's is shorter at every count (10.3 against 13.7 ms with one player, 46.9 against 47.8 with 50), and Fabric + Lithium's with 1 and 10 players. Horizon's is shorter than vanilla's (equal with 10), and than Fabric's from 25 players up.
- Slow ticks: from 10 players up, Horizon's slowest ticks (99th percentile) are the shortest of the four (73 ms with 50, against 110 for Paper and 174 for Fabric).
- 50 players: Horizon and Paper keep 20 TPS, Fabric + Lithium drops to 17, and vanilla's watchdog stopped it.
For more than 50 active players, give it more cores.
Less memory: low_memory
Off by default. On Linux, the game's allocator (mimalloc) backs its memory with transparent huge pages
(2 MB each) where the system allows them. Many of those pages are only partly used, so the server holds
more memory than it needs, but the tick runs a little faster. Under [engine], low_memory = true starts
the game with huge pages off. Measured on a 4-core EPYC VPS with 20 bots playing (2026-11-01):
| Default | low_memory = true |
|
|---|---|---|
| Memory, idle after start | ~300 MB | ~245 MB (−60 MB) |
| Memory, 20 players | ~1180 MB | ~980 MB (−190 MB) |
| Average tick, 20 players | 27.1 ms | 27.5 ms (+1.2 %) |
| Start | 2.1 s | 2.2 s |
Turn it on when memory is what you're short of, for example on a small VPS. It applies at the next start.
A game started without horizon-server (horizon-client --dedicated, a container of your own) gets the same
effect from the environment variable MIMALLOC_ALLOW_THP=0. On macOS and Windows it changes nothing.
When something goes wrong
- "no prepared game files":
initandrunlist the folders they looked in. Prepare (step 2), or set[engine] resources. - "registries.json not found next to the resources": the prepared folder is from an older release. Prepare again with a newer one.
- Nobody can join: the whitelist is on by default (
horizon-server whitelist add NAME), and the port must be open in your firewall. On a tailnet, use the server's tailnet address. - It keeps restarting:
horizon-server statusshows the last crash andhorizon-server logs -n 200the log before it. Crash reports are incrash-reports/. - Report problems at https://github.com/horizon-rust/horizon/issues, with the end of the log.